From dda30d39134dbb5093295024add6cedf0dcaf9e3 Mon Sep 17 00:00:00 2001 From: Walt Drummond Date: Mon, 26 Aug 2024 13:38:11 -0700 Subject: [PATCH] Implement 'copy' plugin with tests and documentation changes. --- README.md | 88 ++++- dotbot/cli.py | 4 +- dotbot/plugins/__init__.py | 1 + dotbot/plugins/copy.py | 421 +++++++++++++++++++++++ tests/test_copy.py | 676 +++++++++++++++++++++++++++++++++++++ 5 files changed, 1187 insertions(+), 3 deletions(-) create mode 100644 dotbot/plugins/copy.py create mode 100644 tests/test_copy.py diff --git a/README.md b/README.md index 971f066..9407113 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Dotbot makes installing your dotfiles as easy as `git clone $url && cd dotfiles - [Rationale](#rationale) - [Getting Started](#getting-started) - [Configuration](#configuration) -- [Directives](#directives) ([Link](#link), [Create](#create), [Shell](#shell), [Clean](#clean), [Defaults](#defaults)) +- [Directives](#directives) ([Link](#link), [Create](#create), [Copy](#copy), [Shell](#shell), [Clean](#clean), [Defaults](#defaults)) - [Plugins](#plugins) - [Command-line Arguments](#command-line-arguments) - [Wiki][wiki] @@ -278,6 +278,92 @@ Implicit sources: relink: true ``` +### Copy + +Copy commands specify how files should be copied. By default, files will be copied if the destination file does not exist however this behavior can be changed to overwrite existing files or copy if the source and destination contents differ. Like Link, environment variables in paths are automatically expanded. + +#### Format +Copy commands are specified as a dictionary that maps targets to source locations. Source locations are specified relative to the base directory (that is specified when running the installer). If the source is a directory, that entire directory hierarchy will be copied. + +Copy commands support an optional extended configuration. In this type of configuration, instead of specifying source locations directly, targets are mapped to extended configuration dictionaries. + + +| Parameter | Explanation | +| --- | --- | +| `path` | The source file for the copy, the same as in the shortcut syntax (default: null, automatic (see below)) | +| `create` | When true, create parent directories to the destination file as needed. (default: false) | +| `overwrite` | Overwrites the destination file if one exists (default: false) | +| `if` | Execute this in your `$SHELL` and only copy if it is successful. | +| `ignore-missing` | Do not fail if the source is missing (default: false) | +| `exclude` | Array of glob patterns to remove from list of source files top copy. Uses same syntax as `path`. (default: empty, keep all matches) | +| `check-content` | Compare the contents of the source and destination files, copy if different (default: false) | +| `dryrun` | Don't copy any files, output what would have been copied. Useful for debugging configuration (default false) | +| `mode` | Set the mode on the destination file (default: copy metadata from source) | +| `dir-mode` | Set the mode on any directories created (default: 0755) | +| `prefix` | Prepend prefix prefix to the path at the root of the source tree when creating the destination path. See the example below for more details (only enabled if glob characters are in use). (default: '') | +| `backup` | Make a backup of the destination file, if it exists. `backup` can be a bool or a string. If set to `false`, Copy will not create a backup of the destination. If set to `true`, Copy will use the destination path with the suffix `.BAK` as the backup path name. If set to a string, Copy will add that string to the destination path to create a backup path name. (default: None) | + +Unlike Link, Copy will apply globbing if the source path contains any of the shell wildcard characters. Wildcard expansion for the `path` and `exclude` parameters are identical to Link. + +#### Example +```yaml +- copy: + ~/.project: dot.project + ~/.login: dot.login + ~/.cshrc: dot.cshrc + ~/.emacs.d: + path: dot.emacs.d + exclude: [ .saves*, *~ ] + ~/bin: + mode: 0750 + overwrite: true +``` + +This configuration copies a directory tree `source_dir` to `~/dest_dir`, excluding any files that match the wildcard pattern. It also creates destination directories as needed, forcing the mode to 0700: + +```yaml +- copy: + ~/dest_dir: + path: source_dir + exclude: [ *~, do-not-copy* ] + dir-mode: 0700 +``` + +Copy and Link can be used together. The following example copies a tcsh configuration file to `~/.cshrc` and creates a link from an alternate default file name `~/.tcshrc` to the copied file. + +```yaml +- copy: + ~/.cshrc: dot.cshrc +- link: + ~/.tcshrc: .cshrc +``` + +This example shows how `prefix` can be used. For the source directory: +``` +dot/profile +dot/config/gnome/... +dot/config/nano/... +forward +``` + +the config: + +```yaml +- copy: + ~/: + path: dot/** + prefix: '.' + create: true +``` + +Will create: +``` +~/.profile +~/.config/gnome/... +~/.config/nano/... +~/.forward +``` + ### Create Create commands specify empty directories to be created. This can be useful diff --git a/dotbot/cli.py b/dotbot/cli.py index f421003..23db00f 100644 --- a/dotbot/cli.py +++ b/dotbot/cli.py @@ -9,7 +9,7 @@ import dotbot from .config import ConfigReader, ReadingError from .dispatcher import Dispatcher, DispatchError from .messenger import Level, Messenger -from .plugins import Clean, Create, Link, Shell +from .plugins import Clean, Copy, Create, Link, Shell from .util import module @@ -121,7 +121,7 @@ def main(): plugins = [] plugin_directories = list(options.plugin_dirs) if not options.disable_built_in_plugins: - plugins.extend([Clean, Create, Link, Shell]) + plugins.extend([Clean, Copy, Create, Link, Shell]) plugin_paths = [] for directory in plugin_directories: for plugin_path in glob.glob(os.path.join(directory, "*.py")): diff --git a/dotbot/plugins/__init__.py b/dotbot/plugins/__init__.py index f75bef5..4463f29 100644 --- a/dotbot/plugins/__init__.py +++ b/dotbot/plugins/__init__.py @@ -1,4 +1,5 @@ from .clean import Clean +from .copy import Copy from .create import Create from .link import Link from .shell import Shell diff --git a/dotbot/plugins/copy.py b/dotbot/plugins/copy.py new file mode 100644 index 0000000..f5b38d8 --- /dev/null +++ b/dotbot/plugins/copy.py @@ -0,0 +1,421 @@ +import filecmp +import fnmatch +import glob +import os +import re +import shutil +import stat + +from ..plugin import Plugin +from ..util import shell_command + +class Copy(Plugin): + """ + Copies files. + """ + + # Constants + _DIRECTIVE = "copy" + _GLOB_CHARS = r'[*?\[\]]' + _DEFAULT_DIRECTORY_MODE = 0o777 + + _default_cfg = None + _extended_cfg = None + _target_destination = "" + + + def can_handle(self, directive): + return directive == self._DIRECTIVE + + def handle(self, directive, data): + if not self.can_handle(directive): + raise ValueError(f"Copy cannot handle directive {directive}") + self._default_cfg = self._context.defaults().get(self._DIRECTIVE, {}) + return self._process_copy(data) + + def _get_config(self, key, defaultValue): + """ + Return configuration for 'key' + """ + + keyValue = self._default_cfg.get(key, defaultValue) + if isinstance(self._extended_cfg, dict): + keyValue = self._extended_cfg.get(key, keyValue) + + return keyValue + + def _test_success(self, command): + """ + Execute a shell command + """ + + ret = shell_command(command, cwd=self._context.base_directory()) + if ret != 0: + self._log.debug(f"'if' command '{command}' returned non-zero exit code {ret}") + + return ret == 0 + + def _normalize_source(self): + """ + Return a fully expanded absolute source path + If the source is implied (ie, no 'path' key, and _extended_cfg is not a str type), + extract the source from the destination + """ + + source = None + + # self._extended_cfg could be: + # - a dict, which may or may not have a path key, or + # - a string representing a source file or directory + if isinstance(self._extended_cfg, dict): + source = self._get_config("path", None) + elif isinstance(self._extended_cfg, str): + source = self._extended_cfg + else: + source = os.path.basename(self._target_destination) + if source.startswith("."): + source = source[1:] + + source = os.path.normpath(os.path.expandvars(os.path.expanduser(source))) + source = os.path.join(self._context.base_directory(), source) + + # This should never happen as long as self._context.base_directory() is an absolute path + if not os.path.isabs(source): + self._log.error(f"Error: Invalid source path '{source}'") + raise OSError("Error: Invalid source path") + + return source + + def _normalize_destination(self): + """ + Return a fully expanded absolute destination path + """ + + destination = os.path.normpath(os.path.expandvars(os.path.expanduser(self._target_destination))) + + if not os.path.isabs(destination): + self._log.error(f"Error: Invalid destination path '{destination}'") + raise OSError("Error: Invalid destination path") + + return destination + + def _contains_glob_chars(self, path): + """ + Test if the path contains shell glob characters? + """ + + pattern = re.compile(self._GLOB_CHARS) + return bool(pattern.search(path)) + + def _extract_root_from_glob(self, path): + """ + Extract the root directory from a glob pattern + """ + + glob_chars = self._GLOB_CHARS + + index = min([path.find(char) for char in glob_chars if char in path]) + if index != -1: + path = path[:index] + + return os.path.normpath(path) + + def _remove_root(self, root, path): + """ + Remove the root from a path if it's a prefix + """ + + if path.startswith(root): + return path[len(root):].lstrip(os.path.sep) + + return path + + def _cleanup_backup_extension(self, backup): + """ + Validate/set the optional backup extension + """ + + if backup: + if type(backup) == bool: + backup = "BAK" + elif type(backup) == str: + if "/" in backup or ".." in backup: + self._log.error(f"Invalid backup extension '{backup}'") + raise Exception("Bad extension") + if backup.startswith("."): + backup = backup[1:] + + return backup + + def _get_copy_paths(self, prefix=""): + """ + Returns a list of source path:destination path pairs, handling globbing or directory traversal as needed. + """ + + source = self._normalize_source() + destination = self._normalize_destination() + pathlist =[] + + if os.path.isdir(source): + # Find all files in the directory tree rooted at `source` + for root, _, files in os.walk(source): + rel_path = os.path.relpath(root, source) + dst_root = os.path.abspath(os.path.join(destination, rel_path)) + for file in files: + pathlist.append([os.path.abspath(os.path.join(root, file)), os.path.abspath(os.path.join(dst_root, file))]) + + elif self._contains_glob_chars(source): + # Find all files that match the glob in `source`. + # If we're not prepending a prefix, set include_hidden to get same behavior as os.walk(); + # If we are prepending prefix, do not match hidden files. + + root = self._extract_root_from_glob(source) + for source_file in glob.glob(source, include_hidden=not prefix, recursive=True): + if not os.path.isfile(source_file): + continue + dest_file = self._remove_root(root, source_file) + if prefix: + dest_file = prefix + dest_file + dest = os.path.join(destination, dest_file) + pathlist.append([source_file, dest]) + + else: + # We just have a [source, destination] pair, return that + pathlist.append([source, destination]) + + return pathlist + + def _filter_excludes(self, paths, exclude_patterns): + """ + Filter out any source path names that match entries (with wildcards) in exclude_patterns + """ + + if isinstance(exclude_patterns, str): + exclude_patterns = [ exclude_patterns ] + + full_patterns = [] + for pattern in exclude_patterns: + new_pattern = os.path.join(self._context.base_directory(), pattern) + full_patterns.append(new_pattern) + + filtered_paths = [] + for pair in paths: + matches = any(fnmatch.fnmatch(pair[0], exclude) for exclude in full_patterns) + if not matches: + filtered_paths.append(pair) + + return filtered_paths + + def _process_copy(self, copies): + """ + Process a copy directive + """ + + + for self._target_destination, self._extended_cfg in copies.items(): + + # Overwrite existing files + overwrite = self._get_config("overwrite", False) + # Create any directories that don't exist in destination path + create = self._get_config("create", False) + # Filter out path names that match globbing patterns + exclude_paths = self._get_config("exclude", []) + # if the shell command returns exit code 0, proceed with copy + test = self._get_config("if", None) + # Follow existing destination links + follow_links = self._get_config("follow-links", True) + # Force change the file mode + file_mode = self._get_config("mode", None) + # Force change directory mode + dir_mode = self._get_config("dir-mode", None) + # Copy if content differs + check_content = self._get_config("check-content", False) + # Don't fail is source file is missing + ignore_missing = self._get_config("ignore-missing", False) + # Don't actually copy files + dryrun = self._get_config("dryrun", False) + # Append 'prefix' to the destination path when globbing (disables recursive copying) + prefix = self._get_config("prefix", "") + # Make a backup if overwriting destination + backup = self._get_config("backup", None) + + # Get a valid backup extension, if set + backup = self._cleanup_backup_extension(backup) + + paths = self._get_copy_paths(prefix) + paths = self._filter_excludes(paths, exclude_paths) + + success = True + for source, destination in paths: + if not success: + break + + self._log.debug(f"Processing {destination}") + + if test is not None and not self._test_success(test): + self._log.lowinfo(f"Skipping {destination}") + continue + + if not os.path.exists(source): + if ignore_missing: + self._log.lowinfo(f"Source does not exist, skipping: {source} -> {destination}") + else: + self._log.lowinfo(f"Error: source does not exist: {source} -> {destination}") + success = False + continue + + if not os.path.isfile(source): + if ignore_missing: + self._log.lowinfo(f"Source is not a file, skipping: {source} -> {destination}") + else: + self._log.lowinfo(f"Error: source is not a file: {source} -> {destination}") + success = False + continue + + path_has_link = destination != os.path.realpath(destination) + if path_has_link and not follow_links: + self._log.warning(f"Destination {destination} is a link and follow_links is not set, skipping.") + success = False + continue + + if check_content and os.path.exists(destination) and not filecmp.cmp(source, destination, shallow=False): + self._log.debug(f"Content checking enabled and content differs, forcing overwrite") + overwrite = True + + if not overwrite and os.path.exists(destination): + self._log.lowinfo(f"Destination {destination} exists, skipping") + continue + + if dryrun: + self._log.info(f"dryrun: copy {source} to {destination}") + continue + + ### Changing the filesystem + if backup and os.path.exists(destination): + backup = destination + "." + backup + if not self._copyFile(destination, backup): + success = False + continue + + success &= self._copyFile(source, destination, file_mode, dir_mode, create, backup) + + if success: + self._log.info(f"All files copied") + else: + self._log.error(f"Some files failed to copy") + return success + + def _update_mode(self, path, mode): + """ + Updates the file mode for `path` if different + """ + + if not os.path.exists(path): + return False + + if os.path.islink(path): + return True + + self._log.debug(f"Updating file mode on {path}: {oct(mode)}") + + file_stat = os.stat(path) + if stat.S_IMODE(file_stat.st_mode) != mode: + try: + os.chmod(path, mode) + except FileNotFoundError: + self._log.warning(f"Path does not exist: {path}") + return False + except PermissionError: + self._log.warning(f"Can't change permissions of {path}, permission denied") + return False + except OSError as e: + self._log.warning(f"Error: {e}") + return False + + return True + + def _create_dirs(self, path, dir_mode=None): + """ + Create a directory hierarchy. + Essentially a wrapper around os.makedirs() that sets mode for each directory created + """ + + if not dir_mode: + dir_mode = self._DEFAULT_DIRECTORY_MODE + elif isinstance(dir_mode, str): + try: + if dir_mode.startswith('0o') or dir_mode.startswith('0'): + dir_mode = int(dir_mode, 8) + else: + dir_mode = int(dir_mode) # Otherwise, treat as a decimal + except ValueError: + self._log.error(f"Invalid directory mode: {dir_mode}") + return False + + parent = os.path.normpath(os.path.dirname(path)) + + parts = [] + while True: + head, tail = os.path.split(parent) + if head == parent: # Root reached + parts.append(head) + break + elif tail == parent: # Root reached + parts.append(tail) + break + else: + parent = head + parts.append(tail) + + parts.reverse() + + # Create each directory + current_path = parts[0] + for part in parts[1:]: + current_path = os.path.join(current_path, part) + if not os.path.exists(current_path): + try: + os.mkdir(current_path, mode=dir_mode) + except Exception as e: + self._log.error(f"Error: mkdir failed with '{e}'") + return False + + return True + + def _copyFile(self, source, destination, file_mode=None, dir_mode=None, create=False, backup=None): + """ + Copies source to dest, optionally changing file mode and creating a backup of existing `destination` file + + Returns true if successfully copied files. Expects absolute path names for source and destination + """ + + if create and not self._create_dirs(destination, dir_mode): + return False + + try: + self._log.lowinfo(f"Copying file {source} -> {destination}") + shutil.copy2(source, destination, follow_symlinks=False) + except FileNotFoundError: + if not os.path.exists(source): + self._log.error(f"The source file '{source}' was not found") + if not os.path.exists(os.path.dirname(destination)): + self._log.error(f"The destination path '{os.path.dirname(destination)}' was not found") + return False + except PermissionError: + self._log.error(f"Can't access the source or destination, permission denied") + return False + except IsADirectoryError: + self._log.error(f"The source path is a directory") + return False + except shutil.SameFileError: + self._log.error(f"Source and destination are the same file") + return False + except OSError as e: + self._log.error(f"Error copying file: {e}") + return False + + # shutil.copy2() also copies metadata. If the caller wants to override the mode, we do that here. + if file_mode is None: + return True + + return self._update_mode(destination, file_mode) diff --git a/tests/test_copy.py b/tests/test_copy.py new file mode 100644 index 0000000..f525bb9 --- /dev/null +++ b/tests/test_copy.py @@ -0,0 +1,676 @@ +import os +import stat + +import pytest + +def test_copy(home, dotfiles, run_dotbot): + """ + Test that we can copy a file + """ + + expected = "apple" + dotfiles.write("source", expected) + dest_file = os.path.join(home, ".dest") + config = [ + { + "copy": { + "~/.dest": { + "path": "source", + }, + } + } + ] + dotfiles.write_config(config) + run_dotbot() + + with open(dest_file, "r") as file: + assert file.read() == expected + +def test_copy_with_mode(home, dotfiles, run_dotbot): + """ + Test that we can copy a file and set it's mode + """ + + expected = "apple" + expected_mode = 0o600 + + dotfiles.write("source", expected) + dest_file = os.path.join(home, ".dest") + config = [ + { + "copy": { + "~/.dest": { + "path": "source", + "mode": expected_mode, + }, + } + } + ] + dotfiles.write_config(config) + run_dotbot() + + st = os.stat(dest_file) + current_mode = stat.S_IMODE(st.st_mode) + assert current_mode == expected_mode + + with open(dest_file, "r") as file: + assert file.read() == expected + +def test_copy_dryrun(home, dotfiles, run_dotbot): + """ + Test that with nothing is copied with `dryrun` enabled + """ + + expected = "apple" + dotfiles.write("source", expected) + dest_file = os.path.join(home, ".dest") + config = [ + { + "copy": { + "~/.dest": { + "path": "source", + "dryrun": True, + }, + } + } + ] + dotfiles.write_config(config) + run_dotbot() + + assert os.path.exists(dest_file) == False + +def test_copy_overwrite(home, dotfiles, run_dotbot): + """ + Test that we overwrite files when `overwite` is set + """ + expected = "apple" + + dotfiles.write("source", expected) + with open(os.path.join(home, ".dest"), "w") as file: + file.write("grape") + + config = [ + { + "copy": { + "~/.dest": { + "path": "source", + "overwrite": True, + }, + } + } + ] + dotfiles.write_config(config) + run_dotbot() + + with open(os.path.join(home, ".dest"), "r") as file: + assert file.read() == expected + +def test_copy_backup(home, dotfiles, run_dotbot): + """ + Test that we backup destination files when `backup` is set. + """ + expected = "apple" + + dotfiles.write("source", expected) + with open(os.path.join(home, ".dest"), "w") as file: + file.write("grape") + + config = [ + { + "copy": { + "~/.dest": { + "path": "source", + "overwrite": True, # force a copy, as this check would trigger before a backup is made + "backup": True, + }, + } + } + ] + dotfiles.write_config(config) + run_dotbot() + + with open(os.path.join(home, ".dest"), "r") as file: + assert file.read() == expected + assert os.path.exists(os.path.join(home, ".dest.BAK")) == True + +def test_copy_backup_extension(home, dotfiles, run_dotbot): + """ + Test that we backup destination files with custom extension when `backup` is set. + """ + expected = "apple" + + dotfiles.write("source", expected) + with open(os.path.join(home, ".dest"), "w") as file: + file.write("grape") + + config = [ + { + "copy": { + "~/.dest": { + "path": "source", + "overwrite": True, + "backup": "BACKUP", + }, + } + } + ] + dotfiles.write_config(config) + run_dotbot() + + with open(os.path.join(home, ".dest"), "r") as file: + assert file.read() == expected + assert os.path.exists(os.path.join(home, ".dest.BACKUP")) == True + +def test_copy_without_overwrite(home, dotfiles, run_dotbot): + """ + Test that we do not overwrite files when `overwrite` is not set + """ + content = "apple" + expected = "grape" + + dotfiles.write("source", content) + with open(os.path.join(home, ".dest"), "w") as file: + file.write(expected) + + config = [ + { + "copy": { + "~/.dest": { + "path": "source", + "overwrite": False, + }, + } + } + ] + dotfiles.write_config(config) + run_dotbot() + + with open(os.path.join(home, ".dest"), "r") as file: + assert file.read() == expected + +def test_copy_without_overwrite_with_check_content(home, dotfiles, run_dotbot): + """ + Test that `check-content` works when `overwrite` is not set + """ + content = "apple" + expected = "grape" + + dotfiles.write("source", expected) + with open(os.path.join(home, ".dest"), "w") as file: + file.write(content) + + config = [ + { + "copy": { + "~/.dest": { + "path": "source", + "overwrite": False, + "check-content":True, + }, + } + } + ] + dotfiles.write_config(config) + run_dotbot() + + with open(os.path.join(home, ".dest"), "r") as file: + assert file.read() == expected + +def test_copy_follow_links(home, dotfiles, run_dotbot): + """ + Test that copies will follow pre-existing destination links when `follow-links` is set + """ + # Create source + # Create a destination link + # Create config and run dotbot + # Check link destination == source + expected = "apple" + + dotfiles.write("source", expected) + dest_link = os.path.join(home, ".dest") + dest_file = os.path.join(home, "dest_file") + os.symlink(dest_file, dest_link) + + config = [ + { + "copy": { + "~/.dest": { + "path": "source", + "follow-links":True, + }, + } + } + ] + dotfiles.write_config(config) + run_dotbot() + + with open(dest_file, "r") as file: + assert file.read() == expected + +def test_copy_not_follow_links(home, dotfiles, run_dotbot): + """ + Test that copies will not follow pre-existing destination links when `follow-links` is set + """ + # Create source + # Create a link + # Create link destination w/ content different than source + # Create config and run dotbot + # Check link destination != source + + expected = "apple" + content = "grape" + + dotfiles.write("source", expected) + dest_link = os.path.join(home, ".dest") + dest_file = os.path.join(home, "dest_file") + os.symlink(dest_file, dest_link) + with open(dest_file, "w") as file: + file.write(content) + + config = [ + { + "copy": { + "~/.dest": { + "path": "source", + "follow-links":True, + }, + } + } + ] + dotfiles.write_config(config) + run_dotbot() + + with open(dest_file, "r") as file: + assert file.read() != expected + +@pytest.mark.skipif( + "sys.platform[:5] != 'win32'", + reason="These if commands only run on Windows.", +) +def test_copy_if(home, dotfiles, run_dotbot): + """ + Verify 'if' directives are checked when copying. + (Lifted from test_link.py) + """ + + os.mkdir(os.path.join(home, "d")) + dotfiles.write("f", "apple") + dotfiles.write_config( + [ + { + "link": { + "~/.f": {"path": "f", "if": "true"}, + "~/.g": {"path": "f", "if": "false"}, + "~/.h": {"path": "f", "if": "[ -d ~/d ]"}, + "~/.i": {"path": "f", "if": "badcommand"}, + }, + } + ] + ) + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".g")) + assert not os.path.exists(os.path.join(home, ".i")) + with open(os.path.join(home, ".f")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".h")) as file: + assert file.read() == "apple" + +def test_copy_glob_1(home, dotfiles, run_dotbot): + """ + Verify globbing works. + (Lifted from test_link.py) + """ + + dotfiles.write("bin/a", "apple") + dotfiles.write("bin/b", "banana") + dotfiles.write("bin/c", "cherry") + dotfiles.write_config( + [ + {"copy": { + "~/bin": { + "path": "bin/*", + "create": True, + } + } + } + ] + ) + run_dotbot() + + with open(os.path.join(home, "bin", "a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, "bin", "b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, "bin", "c")) as file: + assert file.read() == "cherry" + +def test_copy_recursive_glob(home, dotfiles, run_dotbot): + """ + Verify recursive globbing ("**") works. + (Lifted from test_link.py) + """ + + dotfiles.write("bin/one/a", "apple") + dotfiles.write("bin/b", "banana") + dotfiles.write("bin/two/c", "cherry") + dotfiles.write_config( + [ + {"copy": { + "~/bin": { + "path": "bin/**", + "create": True, + } + } + } + ] + ) + run_dotbot() + + with open(os.path.join(home, "bin/one/a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, "bin/b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, "bin/two/c")) as file: + assert file.read() == "cherry" + +def test_copy_glob_2(home, dotfiles, run_dotbot): + """ + Verify globbing works with a trailing slash in the source. + (Lifted from test_link.py) + """ + + dotfiles.write("bin/a", "apple") + dotfiles.write("bin/b", "banana") + dotfiles.write("bin/c", "cherry") + dotfiles.write_config( + [ + {"defaults": { + "copy": { + "create": True + } + } + }, + {"copy": { + "~/bin/": "bin/*" + } + }, + ] + ) + run_dotbot() + + with open(os.path.join(home, "bin", "a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, "bin", "b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, "bin", "c")) as file: + assert file.read() == "cherry" + +def test_copy_with_create(home, dotfiles, run_dotbot): + """ + Test that destination directories are created when `create` is set + """ + + expected = "apple" + dest_file = "dest_parent/dest_dir/dest_dir2/file" + dest_target = "~/" + dest_file + dotfiles.write("source", expected) + dest_file = os.path.join(home, dest_file) + + config = [ + { + "copy": { + "~/dest_parent/dest_dir/dest_dir2/file": { + "path": "source", + "create": True, + }, + } + } + ] + + dotfiles.write_config(config) + run_dotbot() + + assert os.path.isdir(os.path.normpath(os.path.join(dest_file, ".."))) == True + assert os.path.isfile(dest_file) == True + +def test_copy_with_create_and_dir_mode(home, dotfiles, run_dotbot): + """ + Test that destination directories are created and have their mode set correctly when + `create` and `dir_mode` are set + """ + expected = "apple" + expected_mode = 0o700 + dest = "dest_parent/dest_dir/dest_dir2/file" + dest_target = "~/" + dest + dotfiles.write("source", expected) + dest_file = os.path.join(home, dest) + + config = [ + { + "copy": { + dest_target: { + "path": "source", + "create": True, + "dir-mode": expected_mode + }, + }, + } + ] + dotfiles.write_config(config) + run_dotbot() + + # Check that the file was copied + assert os.path.isfile(dest_file) == True + + # Check the mode of each directory in the path + partial_path = os.path.dirname(os.path.normpath(dest)) + parts = partial_path.split(os.sep) + current_path = home + for part in parts: + current_path = os.path.join(current_path, part) + assert os.path.isdir(current_path) == True + assert stat.S_IMODE(os.stat(current_path).st_mode) == expected_mode + +def test_copy_excludes(home, dotfiles, run_dotbot): + """ + Verify excludes works + """ + + dotfiles.write("bin/a.doc", "apple") + dotfiles.write("bin/b.txt", "banana") + dotfiles.write("bin/c.doc", "cherry") + dotfiles.write("bin/d.txt", "dates") + dotfiles.write_config( + [ + {"defaults": { + "copy": { + "create": True + } + } + }, + {"copy": { + "~/bin/": { + "path": "bin/*", + "exclude": "*.doc", + }, + }, + } + ] + ) + run_dotbot() + + assert os.path.exists(os.path.join(home, "bin/a.doc")) == False + assert os.path.exists(os.path.join(home, "bin/b.txt")) == True + assert os.path.exists(os.path.join(home, "bin/c.doc")) == False + assert os.path.exists(os.path.join(home, "bin/d.txt")) == True + +def test_copy_excludes_with_globs(home, dotfiles, run_dotbot): + """ + Verify excludes works with globs + """ + + dotfiles.write("bin/one/a.doc", "apple") + dotfiles.write("bin/b.txt", "banana") + dotfiles.write("bin/c.doc", "cherry") + dotfiles.write("bin/two/d.txt", "dates") + dotfiles.write_config( + [ + {"defaults": { + "copy": { + "create": True + } + } + }, + {"copy": { + "~/bin/": { + "path": "bin/**", + "exclude": "*.doc", + }, + }, + } + ] + ) + run_dotbot() + + assert os.path.exists(os.path.join(home, "bin/one/a.doc")) == False + assert os.path.exists(os.path.join(home, "bin/b.txt")) == True + assert os.path.exists(os.path.join(home, "bin/c.doc")) == False + assert os.path.exists(os.path.join(home, "bin/two/d.txt")) == True + +def test_copy_excludes_with_globs_2(home, dotfiles, run_dotbot): + """ + Verify deep globbing with multiple globbed exclusions. + (Lifted directly from link_test.py) + """ + + dotfiles.write("config/foo/a", "apple") + dotfiles.write("config/bar/b", "banana") + dotfiles.write("config/bar/c", "cherry") + dotfiles.write("config/baz/d", "donut") + dotfiles.write("config/baz/buzz/e", "egg") + dotfiles.write("config/baz/bizz/g", "grape") + dotfiles.write("config/fiz/f", "fig") + dotfiles.write_config( + [ + { + "defaults": { + "copy": { + "create": True, + }, + }, + }, + { + "copy": { + "~/.config/": { + "path": "config/*/*", + "exclude": ["config/baz/*", "config/fiz/*"], + }, + }, + }, + ] + ) + run_dotbot() + + assert not os.path.exists(os.path.join(home, ".config", "baz")) + assert not os.path.exists(os.path.join(home, ".config", "fiz")) + + assert not os.path.isfile(os.path.join(home, ".config")) + assert not os.path.isfile(os.path.join(home, ".config", "foo")) + assert not os.path.isfile(os.path.join(home, ".config", "bar")) + assert os.path.isfile(os.path.join(home, ".config", "foo", "a")) + with open(os.path.join(home, ".config", "foo", "a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".config", "bar", "b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, ".config", "bar", "c")) as file: + assert file.read() == "cherry" + +def test_copy_glob_with_prefix(home, dotfiles, run_dotbot): + """ + Verify globbing works with hidden ("period-prefixed") files. + (Lifted from test_link.py) + """ + + dotfiles.write("bin/.a", "dot-apple") + dotfiles.write("bin/.b", "dot-banana") + dotfiles.write("bin/.c", "dot-cherry") + dotfiles.write_config( + [ + {"defaults": {"copy": {"create": True}}}, + {"copy": {"~/bin/": "bin/.*"}}, + ] + ) + run_dotbot() + + with open(os.path.join(home, "bin", ".a")) as file: + assert file.read() == "dot-apple" + with open(os.path.join(home, "bin", ".b")) as file: + assert file.read() == "dot-banana" + with open(os.path.join(home, "bin", ".c")) as file: + assert file.read() == "dot-cherry" + +def test_copy_ignore_missing_true(home, dotfiles, run_dotbot): + """ + Test that ignore-missing does so. + """ + dotfiles.write("a", "apple") + # don't create b + dotfiles.write("c", "cherry") + + dotfiles.write_config( + [ + { + "defaults": { + "copy": { + "ignore-missing": True, + } + } + }, + { + "copy": { + "~/a": "a", + "~/b": "b", + "~/c": "c" + } + } + ]) + + run_dotbot() + + assert os.path.exists(os.path.join(home, "a")) == True + assert os.path.exists(os.path.join(home, "b")) == False + assert os.path.exists(os.path.join(home, "c")) == True + +def test_copy_prefix(home, dotfiles, run_dotbot): + """ + Verify copy prefixes are prepended. + (Lifted from test_link.py) + """ + + dotfiles.write("conf/a", "apple") + dotfiles.write("conf/b", "banana") + dotfiles.write("conf/c", "cherry") + dotfiles.write("conf/dir/one", "one") + + dotfiles.write_config( + [ + { + "copy": { + "~/": { + "path": "conf/**", + "prefix": ".", + "create": True, + }, + }, + } + ] + ) + run_dotbot() + with open(os.path.join(home, ".a")) as file: + assert file.read() == "apple" + with open(os.path.join(home, ".b")) as file: + assert file.read() == "banana" + with open(os.path.join(home, ".c")) as file: + assert file.read() == "cherry" + assert os.path.exists(os.path.join(home, ".dir/one")) == True