1
0
Fork 0
mirror of synced 2024-11-25 09:35:34 -05:00

Implement 'copy' plugin with tests and documentation changes.

This commit is contained in:
Walt Drummond 2024-08-26 13:38:11 -07:00
parent 720206578a
commit dda30d3913
5 changed files with 1187 additions and 3 deletions

View file

@ -6,7 +6,7 @@ Dotbot makes installing your dotfiles as easy as `git clone $url && cd dotfiles
- [Rationale](#rationale) - [Rationale](#rationale)
- [Getting Started](#getting-started) - [Getting Started](#getting-started)
- [Configuration](#configuration) - [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) - [Plugins](#plugins)
- [Command-line Arguments](#command-line-arguments) - [Command-line Arguments](#command-line-arguments)
- [Wiki][wiki] - [Wiki][wiki]
@ -278,6 +278,92 @@ Implicit sources:
relink: true 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
Create commands specify empty directories to be created. This can be useful Create commands specify empty directories to be created. This can be useful

View file

@ -9,7 +9,7 @@ import dotbot
from .config import ConfigReader, ReadingError from .config import ConfigReader, ReadingError
from .dispatcher import Dispatcher, DispatchError from .dispatcher import Dispatcher, DispatchError
from .messenger import Level, Messenger from .messenger import Level, Messenger
from .plugins import Clean, Create, Link, Shell from .plugins import Clean, Copy, Create, Link, Shell
from .util import module from .util import module
@ -121,7 +121,7 @@ def main():
plugins = [] plugins = []
plugin_directories = list(options.plugin_dirs) plugin_directories = list(options.plugin_dirs)
if not options.disable_built_in_plugins: if not options.disable_built_in_plugins:
plugins.extend([Clean, Create, Link, Shell]) plugins.extend([Clean, Copy, Create, Link, Shell])
plugin_paths = [] plugin_paths = []
for directory in plugin_directories: for directory in plugin_directories:
for plugin_path in glob.glob(os.path.join(directory, "*.py")): for plugin_path in glob.glob(os.path.join(directory, "*.py")):

View file

@ -1,4 +1,5 @@
from .clean import Clean from .clean import Clean
from .copy import Copy
from .create import Create from .create import Create
from .link import Link from .link import Link
from .shell import Shell from .shell import Shell

421
dotbot/plugins/copy.py Normal file
View file

@ -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)

676
tests/test_copy.py Normal file
View file

@ -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