<file_sep>import os
import subprocess as sp
from shlex import split
import shutil
from shutil import rmtree, copytree, copyfile
from typing import List, Union
from .printing import *


def run_cmd(command: Union[str, List]):
    """
    Wrapper on subprocess.run to handle shell commands as either a list of args
    or a single string.
    """
    if not isinstance(command, list):
        command = split(command)
    output = None
    try:
        while "|" in command:
            index = command.index("|")
            first_command, command = command[:index], command[index + 1 :]
            output = sp.Popen(
                first_command,
                stdin=output.stdout if output else None,
                stdout=sp.PIPE,
                stderr=sp.DEVNULL,
            )
        return sp.run(
            command,
            stdout=sp.PIPE,
            stdin=output.stdout if output else None,
            stderr=sp.DEVNULL,
        )
    except FileNotFoundError:  # If package manager is missing
        return None


def run_cmd_write_stdout(command: str, filepath: str) -> int:
    """
    Runs a command and then writes its stdout to a file.
    Returns 0 on success, and -1 on failure.
    :param: command str representing command to run
    :param: filepath str file to write command's stdout to
    """
    process = run_cmd(command)
    if process and process.returncode == 0:
        with open(filepath, "w+") as f:
            f.write(process.stdout.decode("utf-8"))
        return 0
    else:
        print_path_red("An error occurred while running: $", command)
        return -1


def run_cmd_return_bool(command: str) -> bool:
    """Run a bash command and return True if the exit code is 0, False otherwise"""
    return os.system(f"/bin/bash -c '{command}'") == 0


def evaluate_condition(
    condition: str, backup_or_reinstall: str, dotfile_path: str
) -> bool:
    """Evaluates the condition, if it exists, in bash and returns True or False, while providing output
    detailing what is going on.
    :param condition: A string that will be evaluated by bash.
    :param backup_or_reinstall: The only valid inputs are: "backup" or "reinstall"
    :param dotfile_path: Path to dotfile (relative to $HOME, or absolute) for which the condition is being evaluated
    """
    if condition:
        print_blue(
            f"\n{backup_or_reinstall.capitalize()} condition detected for {dotfile_path}."
        )
        condition_success = run_cmd_return_bool(condition)
        if not condition_success:
            print_blue(f"SKIPPING {backup_or_reinstall.lower()} based on <{condition}>")
            return False
        else:
            print_blue(
                f"NOT skipping {backup_or_reinstall.lower()} based on <{condition}>"
            )
            return True
    else:
        return True


def check_if_path_is_valid_dir(abs_path):
    """Returns False is the path leads to a file, True otherwise."""
    if os.path.isfile(abs_path):
        print_path_red("New path is a file:", abs_path)
        print_red_bold("Please enter a directory.\n")
        return False
    return True


def safe_mkdir(directory):
    """Makes dir if it doesn't already exist, creating all intermediate directories."""
    os.makedirs(directory, exist_ok=True)


def mkdir_overwrite(path):
    """
    Makes a new directory, destroying the contents of the dir at path, if it exits.
    Ensures .git and .gitignore files inside of directory are not delected.
    """
    if os.path.isdir(path):
        dirs = []
        files = []
        for file in os.listdir(path):
            full_path = os.path.join(path, file)
            # Allow dotfiles to be a sub-repo, and protect img folder.
            if (
                full_path.endswith(".git")
                or full_path.endswith(".gitignore")
                or full_path.endswith("README.md")
                or full_path.endswith("img")
            ):
                continue

            if os.path.isdir(full_path):
                dirs.append(full_path)
            else:
                files.append(full_path)

        for file in files:
            os.remove(file)

        for directory in dirs:
            rmtree(directory)
    else:
        os.makedirs(path)


def mkdir_warn_overwrite(path):
    """
    Make destination dir if path doesn't exist, confirm before overwriting if it does.
    """
    subdirs = ["dotfiles", "packages", "fonts", "configs"]
    if os.path.exists(path) and path.split("/")[-1] in subdirs:
        print_path_red("Directory already exists:", path)
        if prompt_yes_no("Erase directory and make new back up?", Fore.RED):
            mkdir_overwrite(path)
        else:
            print_red_bold("Exiting to prevent accidental deletion of data.")
            sys.exit()
    elif not os.path.exists(path):
        os.makedirs(path)
        print_path_blue("Created directory:", path)


def overwrite_dir_prompt_if_needed(path: str, no_confirm: bool):
    """
    Prompts the user before deleting the directory if needed.
    This function lets the CLI args silence the prompts.
    :param path: absolute path
    :param no_confirm: Flag that determines if user confirmation is needed.
    """
    if no_confirm:
        mkdir_overwrite(path)
    else:
        mkdir_warn_overwrite(path)


def exit_if_dir_is_empty(backup_path: str, backup_type: str):
    """Exit if the backup_path is not a directory or contains no files."""
    if not os.path.isdir(backup_path) or not os.listdir(backup_path):
        print_red_bold("No {} backup found.".format(backup_type))
        sys.exit(1)


def destroy_backup_dir(backup_path):
    """Deletes the backup directory and its content"""
    try:
        print_path_red("Deleting backup directory:", backup_path)
        rmtree(backup_path)
    except OSError as e:
        print_red_bold("Error: {} - {}".format(e.filename, e.strerror))


def get_abs_path_subfiles(directory: str) -> list:
    """Returns list of absolute paths of files and folders contained in a directory,
    excluding the .git directory, .gitignore, img/ and README.md in the root dir.
    :param directory: Absolute path to directory to search
    """
    invalid = []
    for x in [".git", ".gitignore", "img", "README.md"]:
        invalid.append(os.path.join(directory, x))

    file_paths = []
    for path, _, files in os.walk(directory):
        for name in files:
            full_path = os.path.join(path, name)

            if full_path in invalid:
                print_path_red("Excluded:", full_path)
            else:
                file_paths.append(full_path)
    return file_paths


def copyfile_with_exception_handler(src, dst):
    try:
        copyfile(src, dst)
    except Exception as e:
        print_path_red("Error copying:", src)
        print_red(" -> This may mean you have an error in your config.")


def copy_dir_if_valid(source_dir, backup_path):
    """
    Copy dir from source_dir to backup_path. Skips copying if any of the
    'invalid' directories appear anywhere in the source_dir path.
    """
    invalid = {".Trash", ".npm", ".cache", ".rvm"}
    if invalid.intersection(set(os.path.split(source_dir))) != set():
        return
    try:
        copytree(source_dir, backup_path, symlinks=False)
    except shutil.Error:
        print_path_red("Error copying:", source_dir)


def home_prefix(path):
    """
    Appends the path to the user's home path.
    :param path: Path to be appended.
    :return: (str) ~/path
    """
    home_path = os.path.expanduser("~")
    return os.path.join(home_path, path)


def expand_to_abs_path(path):
    """
    Expands relative and user's home paths to the respective absolute path. Environment
    variables found on the input path will also be expanded.
    :param path: Path to be expanded.
    :return: (str) The absolute path.
    """
    expanded_path = os.path.expanduser(path)
    expanded_path = os.path.expandvars(expanded_path)
    return os.path.abspath(expanded_path)


def strip_home(full_path):
    """Removes the path to $HOME from the front of the absolute path, if it's there"""
    home_path = os.path.expanduser("~")
    if full_path.startswith(home_path):
        return full_path.replace(home_path + "/", "")
    else:
        return full_path
<file_sep>[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]
click = ">=7.0"
colorama = ">=0.4.3"
gitpython = ">=3.1.29"
inquirer = ">=2.6.3"
black = "*"

[dev-packages]
pytest = ">=5.4.1"
pytest-cov = ">=2.8.1"
twine = "*"
black = "*"

[requires]
python_version = "3.10"
<file_sep>## Development Guide

### Running the code

```bash
$ pipenv shell
$ pipenv install --dev
$ python3 -m shallow_backup
```

### Testing

```bash
$ pytest

####
# Code Coverage
# NOTE: This makes some tests fail -- not sure why. Just ignore those for now.
####

$ py.test --cov --cov-report html:code_coverage
$ open code_coverage/index.html
```

Make sure all existing tests pass before opening a PR!
Also, add any necessary tests for new code.


### Deployment

Make a version bump commit, like:

```diff
From d71b903dacd5eeea9d0be68ef3022817f9bac601 Mon Sep 17 00:00:00 2001
From: <NAME> <<EMAIL>>
Date: Sun, 19 Jun 2022 05:46:06 -0500
Subject: [PATCH] Version bump to v5.2

---
 shallow_backup/constants.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/shallow_backup/constants.py b/shallow_backup/constants.py
index 7edcb5c3..13b949c4 100644
--- a/shallow_backup/constants.py
+++ b/shallow_backup/constants.py
@@ -1,6 +1,6 @@
 class ProjInfo:
 	PROJECT_NAME = 'shallow-backup'
-	VERSION = '5.1'
+	VERSION = '5.2'
 	AUTHOR_GITHUB = 'alichtman'
 	AUTHOR_FULL_NAME = '<NAME>'
 	DESCRIPTION = "Easily create lightweight backups of installed packages, dotfiles, and more."
```

And then run `scripts/release.sh` from the project root.

### Code Style

You should follow the code style already established in the code base.

PEP8 is generally to be followed, but I think that prettier code to look at in an editor is more important that strictly following PEP8.

All files should end with a new line.

PRs with changes in indentation style _will not be merged._ Tabs (width of 4 spaces) should be used.

### Continuous Integration Testing and Static Analysis

+ [Travis CI](https://travis-ci.com/alichtman/shallow-backup)
+ [Codacy](https://app.codacy.com/project/alichtman/shallow-backup/dashboard)
+ [CodeClimate](https://codeclimate.com/github/alichtman/shallow-backup)
+ [Coveralls](https://coveralls.io/github/alichtman/shallow-backup)
<file_sep>import click
from .backup import *
from .prompts import *
from .reinstall import *
from .git_wrapper import *
from .utils import (
    mkdir_warn_overwrite,
    destroy_backup_dir,
    expand_to_abs_path,
    check_if_path_is_valid_dir,
)
from .config import *
from .upgrade import check_if_config_upgrade_needed


# custom help options
@click.command(context_settings=dict(help_option_names=["-h", "-help", "--help"]))
@click.option(
    "--add-dot", default=None, help="Add a dotfile or dotfolder to config by path."
)
@click.option(
    "-backup-all", "backup_all_flag", is_flag=True, default=False, help="Full back up."
)
@click.option(
    "-backup-configs",
    "backup_configs_flag",
    is_flag=True,
    default=False,
    help="Back up app config files.",
)
@click.option(
    "-backup-dots",
    "backup_dots_flag",
    is_flag=True,
    default=False,
    help="Back up dotfiles.",
)
@click.option(
    "-backup-fonts",
    "backup_fonts_flag",
    is_flag=True,
    default=False,
    help="Back up installed fonts.",
)
@click.option(
    "-backup-packages",
    "backup_packages_flag",
    is_flag=True,
    default=False,
    help="Back up package libraries.",
)
@click.option("-delete-config", is_flag=True, default=False, help="Delete config file.")
@click.option(
    "-destroy-backup", is_flag=True, default=False, help="Delete backup directory."
)
@click.option(
    "-dry-run",
    is_flag=True,
    default=False,
    help="Don't backup or reinstall any files, just give verbose output.",
)
@click.option("--new-path", default=None, help="Input a new back up directory path.")
@click.option(
    "-no-new-backup-path-prompt",
    is_flag=True,
    default=False,
    help="Skip setting new back up directory path prompt.",
)
@click.option(
    "-no-splash", is_flag=True, default=False, help="Don't display splash screen."
)
@click.option(
    "-reinstall-all", is_flag=True, default=False, help="Full reinstallation."
)
@click.option(
    "-reinstall-configs", is_flag=True, default=False, help="Reinstall configs."
)
@click.option(
    "-reinstall-dots",
    is_flag=True,
    default=False,
    help="Reinstall dotfiles and dotfolders.",
)
@click.option("-reinstall-fonts", is_flag=True, default=False, help="Reinstall fonts.")
@click.option(
    "-reinstall-packages", is_flag=True, default=False, help="Reinstall packages."
)
@click.option("--remote", default=None, help="Set remote URL for the git repo.")
@click.option(
    "-separate-dotfiles-repo",
    is_flag=True,
    default=False,
    help="Use if you are trying to maintain a separate dotfiles repo and running into issue #229.",
)
@click.option("-show", is_flag=True, default=False, help="Display config file.")
@click.option(
    "--version",
    "-v",
    is_flag=True,
    default=False,
    help="Display version and author info.",
)
def cli(
    add_dot,
    backup_configs_flag,
    delete_config,
    destroy_backup,
    backup_dots_flag,
    dry_run,
    backup_fonts_flag,
    backup_all_flag,
    new_path,
    no_splash,
    no_new_backup_path_prompt,
    backup_packages_flag,
    reinstall_all,
    reinstall_configs,
    reinstall_dots,
    reinstall_fonts,
    reinstall_packages,
    remote,
    separate_dotfiles_repo,
    show,
    version,
):
    """
    \b
    Easily back up installed packages, dotfiles, and more.
    You can edit which files are backed up in ~/.shallow-backup.

    Written by <NAME> (@alichtman).
    """
    safe_create_config()
    check_if_config_upgrade_needed()
    check_insecure_config_permissions()

    # Process CLI args
    admin_action = any([add_dot, delete_config, destroy_backup, show, version])
    has_cli_arg = any(
        [
            no_new_backup_path_prompt,
            backup_all_flag,
            backup_dots_flag,
            backup_packages_flag,
            backup_fonts_flag,
            backup_configs_flag,
            reinstall_dots,
            reinstall_fonts,
            reinstall_all,
            reinstall_configs,
            reinstall_packages,
        ]
    )
    skip_prompt = any(
        [
            backup_all_flag,
            backup_dots_flag,
            backup_configs_flag,
            backup_packages_flag,
            backup_fonts_flag,
            reinstall_packages,
            reinstall_configs,
            reinstall_dots,
            reinstall_fonts,
        ]
    )

    backup_config = get_config()

    # Perform administrative action and exit.
    if admin_action:
        if version:
            print_version_info()
        elif delete_config:
            delete_config_file()
        elif destroy_backup:
            backup_home_path = expand_to_abs_path(get_config()["backup_path"])
            destroy_backup_dir(backup_home_path)
        elif show:
            show_config()
        elif add_dot:
            new_config = add_dot_path_to_config(backup_config, add_dot)
            write_config(new_config)
        sys.exit()

    # Start CLI
    if not no_splash:
        splash_screen()

    # User entered a new path, so update the config
    if new_path:
        abs_path = os.path.abspath(new_path)

        if not check_if_path_is_valid_dir(abs_path):
            sys.exit(1)

        print_path_blue("\nUpdating shallow-backup path to:", abs_path)
        backup_config["backup_path"] = abs_path
        write_config(backup_config)

    # User didn't enter any CLI args so prompt for path update before showing menu
    elif not has_cli_arg:
        path_update_prompt(backup_config)

    # Create backup directory and do git setup
    backup_home_path = expand_to_abs_path(get_config()["backup_path"])
    mkdir_warn_overwrite(backup_home_path)
    repo, new_git_repo_created = safe_git_init(backup_home_path)
    create_gitignore(backup_home_path, "root-gitignore")

    # Prompt user for remote URL if needed
    if new_git_repo_created and not remote:
        git_url_prompt(repo)

    # Set remote URL from CLI arg
    if remote:
        git_set_remote(repo, remote)

    dotfiles_path = os.path.join(backup_home_path, "dotfiles")
    create_gitignore(dotfiles_path, "dotfiles-gitignore")

    configs_path = os.path.join(backup_home_path, "configs")
    packages_path = os.path.join(backup_home_path, "packages")
    fonts_path = os.path.join(backup_home_path, "fonts")

    # Command line options
    if skip_prompt:
        if reinstall_packages:
            reinstall_packages_sb(packages_path, dry_run=dry_run)
        elif reinstall_configs:
            reinstall_configs_sb(configs_path, dry_run=dry_run)
        elif reinstall_fonts:
            reinstall_fonts_sb(fonts_path, dry_run=dry_run)
        elif reinstall_dots:
            reinstall_dots_sb(dotfiles_path, dry_run=dry_run)
        elif reinstall_all:
            reinstall_all_sb(
                dotfiles_path, packages_path, fonts_path, configs_path, dry_run=dry_run
            )
        elif backup_all_flag:
            backup_all(
                dotfiles_path,
                packages_path,
                fonts_path,
                configs_path,
                dry_run=dry_run,
                skip=True,
            )
            if not dry_run:
                git_add_all_commit_push(repo, "full_backup")
        elif backup_dots_flag:
            backup_dotfiles(dotfiles_path, dry_run=dry_run, skip=True)
            if not dry_run:
                git_add_all_commit_push(repo, "dotfiles", separate_dotfiles_repo)
        elif backup_configs_flag:
            backup_configs(configs_path, dry_run=dry_run, skip=True)
            if not dry_run:
                git_add_all_commit_push(repo, "configs")
        elif backup_packages_flag:
            backup_packages(packages_path, dry_run=dry_run, skip=True)
            if not dry_run:
                git_add_all_commit_push(repo, "packages")
        elif backup_fonts_flag:
            backup_fonts(fonts_path, dry_run=dry_run, skip=True)
            if not dry_run:
                git_add_all_commit_push(repo, "fonts")
    # No CL options, show action menu and process selected option.
    else:
        selection = main_menu_prompt()
        action, _, target = selection.rpartition(" ")
        if action == "back up":
            if target == "all":
                backup_all(dotfiles_path, packages_path, fonts_path, configs_path)
                git_add_all_commit_push(repo, target)
            elif target == "dotfiles":
                backup_dotfiles(dotfiles_path)
                git_add_all_commit_push(repo, target, separate_dotfiles_repo)
            elif target == "configs":
                backup_configs(configs_path)
                git_add_all_commit_push(repo, target)
            elif target == "packages":
                backup_packages(packages_path)
                git_add_all_commit_push(repo, target)
            elif target == "fonts":
                backup_fonts(fonts_path)
                git_add_all_commit_push(repo, target)
        elif action == "reinstall":
            if target == "packages":
                reinstall_packages_sb(packages_path)
            elif target == "configs":
                reinstall_configs_sb(configs_path)
            elif target == "fonts":
                reinstall_fonts_sb(fonts_path)
            elif target == "dotfiles":
                reinstall_dots_sb(dotfiles_path)
            elif target == "all":
                reinstall_all_sb(dotfiles_path, packages_path, fonts_path, configs_path)
        elif target == "config":
            if action.startswith("show"):
                show_config()
            elif action.startswith("add"):
                add_to_config_prompt()
            elif action.startswith("remove"):
                remove_from_config_prompt()
        elif action == "destroy":
            if prompt_yes_no(
                "Erase backup directory: {}?".format(backup_home_path), Fore.RED
            ):
                destroy_backup_dir(backup_home_path)
            else:
                print_red_bold(
                    "Exiting to prevent accidental deletion of backup directory."
                )

    sys.exit()


if __name__ == "__main__":
    cli()
<file_sep># shallow-backup

[![Downloads](http://pepy.tech/badge/shallow-backup)](http://pepy.tech/count/shallow-backup)
[![Build Status](https://travis-ci.com/alichtman/shallow-backup.svg?branch=master)](https://travis-ci.com/alichtman/shallow-backup)
[![Codacy Badge](https://api.codacy.com/project/badge/Grade/1719da4d7df5455d8dbb4340c428f851)](https://www.codacy.com/app/alichtman/shallow-backup?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=alichtman/shallow-backup&amp;utm_campaign=Badge_Grade)
<!-- [![Coverage Status](https://coveralls.io/repos/github/alichtman/shallow-backup/badge.svg?branch=master)](https://coveralls.io/github/alichtman/shallow-backup?branch=master) -->

`shallow-backup` lets you easily create lightweight backups of installed packages, applications, fonts and dotfiles, and automatically push them to a remote Git repository.

![Shallow Backup GIF Demo](img/shallow-backup-demo.gif)

Contents
========

 * [Why?](#why)
 * [Installation](#installation)
 * [Usage](#usage)
 * [Git Integration](#git-integration)
 * [What can I back up?](#what-can-i-back-up)
 * [Configuration](#configuration)
 * [Output Structure](#output-structure)
 * [Reinstalling Dotfiles](#reinstalling-dotfiles)
 * [Want to contribute?](#want-to-contribute)

### Why?

I wanted a tool that allows you to:

+ Back up dotfiles _from where they live on the system_.
+ Back up files from _any_ path on the system, not just `$HOME`.
+ Reinstall them from the backup directory idempotently.
+ Backup and reinstall files conditionally, so you can easily manage dotfiles across multiple systems.
+ Copy files on installation and backup, as opposed to symlinking them.
+ Backup package installations in a highly compressed manner

And is incredibly fault tolerant and user-protective.

`shallow-backup` is the only tool that checks all of those boxes.

### Installation
---

> **Warning**
> Be careful running this with elevated privileges. Code execution can be achieved with write permissions on the config file.

#### Method 1: [`pip3`](https://pypi.org/project/shallow-backup/)

```bash
$ pip3 install shallow-backup
```

#### Method 2: Install From Source

```bash
$ git clone https://www.github.com/alichtman/shallow-backup.git
$ cd shallow-backup
$ pip3 install .
```

### Usage
---

To start the interactive program, simply run `$ shallow-backup`.

`shallow-backup` was built with scripting in mind. Every feature that's supported in the interactive program is supported with command line arguments.

```shell
Usage: shallow-backup [OPTIONS]

  Easily back up installed packages, dotfiles, and more.
  You can edit which files are backed up in ~/.shallow-backup.

  Written by <NAME> (@alichtman).

Options:
  --add-dot TEXT              Add a dotfile or dotfolder to config by path.
  -backup-all                 Full back up.
  -backup-configs             Back up app config files.
  -backup-dots                Back up dotfiles.
  -backup-packages            Back up package libraries.
  -delete-config              Delete config file.
  -destroy-backup             Delete backup directory.
  -dry-run                    Don't backup or reinstall any files, just give
                              verbose output.

  -backup-fonts               Back up installed fonts.
  --new-path TEXT             Input a new back up directory path.
  -no-new-backup-path-prompt  Skip setting new back up directory path prompt.
  -no-splash                  Don't display splash screen.
  -reinstall-all              Full reinstallation.
  -reinstall-configs          Reinstall configs.
  -reinstall-dots             Reinstall dotfiles and dotfolders.
  -reinstall-fonts            Reinstall fonts.
  -reinstall-packages         Reinstall packages.
  --remote TEXT               Set remote URL for the git repo.
  -separate-dotfiles-repo     Use if you are trying to maintain a separate
                              dotfiles repo and running into issue #229.

  -show                       Display config file.
  -v, --version               Display version and author info.
  -h, -help, --help           Show this message and exit.
```


### Git Integration
---

**A Word of Caution**

This backup tool is git-integrated, meaning that you can easily store your backups remotely (on GitHub, for example.) Dotfiles and configuration files may contain sensitive information like API keys and ssh keys, and you don't want to make those public. To make sure no sensitive files are uploaded accidentally, `shallow-backup` creates a `.gitignore` file if it can't find one in the directory. It excludes `.ssh/` and `.pypirc` by default. It's safe to remove these restrictions if you're pushing to a remote private repository, or you're only backing up locally. To do this, you should clear the `.gitignore` file without deleting it.

_If you choose to back up to a public repository, look at every file you're backing up to make sure you want it to be public._

**What if I'd like to maintain a separate repo for my dotfiles?**

`shallow-backup` makes this easy! After making your first backup, `cd` into the `dotfiles/` directory and run `$ git init`. Create a `.gitignore` and a new repo on your favorite version control platform. This repo will be maintained independently (manually) of the base `shallow-backup` repo. Note that you may need to use the `-separate_dotfiles_repo` flag to get this to work, and it may [break some other functionality of the tool](https://github.com/alichtman/shallow-backup/issues/229). It's ok for my use case, though.

Here's a `bash` script that I wrote to [automate my dotfile backup workflow](https://github.com/alichtman/scripts/blob/master/backup-and-update-dotfiles.sh). You can use this by placing it in your `$PATH`, making it executable, and running it.

### What can I back up?
---

By default, `shallow-backup` backs these up.

1. Dotfiles and dotfolders
    * `.bashrc`
    * `.bash_profile`
    * `.gitconfig`
    * `.pypirc`
    * `.config/shallow-backup.conf`
    * `.ssh/`
    * `.vim/`
    * `.zshrc`

2. App Config Files
    * Atom
    * VSCode
    * Sublime Text 2/3
    * Terminal.app

3. Installed Packages
    * `brew` and `cask`
    * `cargo`
    * `gem`
    * `pip`
    * `pip3`
    * `npm`
    * `macports`
    * `VSCode` Extensions
    * `Sublime Text 2/3` Packages
    * System Applications

4. User installed `fonts`.

### Configuration

If you'd like to modify which files are backed up, you have to edit the `JSON` config file, located at `~/.config/shallow-backup.conf`. There are two ways to do this.

1. Select the appropriate option in the CLI and follow the prompts.
2. Open the file in a text editor and make your changes.

Editing the file in a text editor will give you more control and be faster.

#### Conditional Backup and Reinstallation

> **Warning**
> This feature allows code execution (by design). If untrusted users can write to your config, they can achieve code execution next time you invoke `shallow-backup` _backup_ or _reinstall_ functions. Starting in `v5.2`, the config file will have default permissions of `644`, and a warning will be printed if others can write to the config.

Every key under dotfiles has two optional subkeys: `backup_condition` and `reinstall_condition`. Both of these accept expressions that will be evaluated with `bash`. An empty string (`""`) is the default value, and is considered to be `True`. If the return value of the expression is `0`, this is considered `True`. Otherwise, it is `False`. This lets you do simple things like preventing backup with:

```javascript
// Because `$ false` returns 1
"backup_condition": "false"
```

And also more complicated things like only backing up certain files if an environment variable is set:

```javascript
"backup_condition": "[[ -n \"$ENV_VAR\" ]]"
```

Here's an example config based on my [dotfiles](https://www.github.com/alichtman/dotfiles):

```json
{
	"backup_path": "~/shallow-backup",
	"lowest_supported_version": "5.0.0a",
	"dotfiles": {
		".config/agignore": {
			"backup_condition": "uname -a | grep Darwin",
			"reinstall_conditon": "uname -a | grep Darwin"
		},
		".config/git/gitignore_global": { },
		".config/jrnl/jrnl.yaml": { },
		".config/kitty": { },
		".config/nvim": { },
		".config/pycodestyle": { },
		...
		".zshenv": { }
	},
	"root-gitignore": [
		".DS_Store",
		"dotfiles/.config/nvim/.netrwhist",
		"dotfiles/.config/nvim/spell/en.utf-8.add",
		"dotfiles/.config/ranger/plugins/ranger_devicons",
		"dotfiles/.config/zsh/.zcompdump*",
		"dotfiles/.pypirc",
		"dotfiles/.ssh"
	],
	"dotfiles-gitignore": [
		".DS_Store",
		".config/nvim/.netrwhist",
		".config/nvim/spell/en.utf-8.add*",
		".config/ranger/plugins/*",
		".config/zsh/.zcompdump*",
		".config/zsh/.zinit",
		".config/tmux/plugins",
		".config/tmux/resurrect",
		".pypirc",
		".ssh/*"
	],
	"config_mapping": {
		"/Users/alichtman/Library/Application Support/Sublime Text 2": "sublime2",
		"/Users/alichtman/Library/Application Support/Sublime Text 3": "sublime3",
		"/Users/alichtman/Library/Application Support/Code/User/settings.json": "vscode/settings",
		"/Users/alichtman/Library/Application Support/Code/User/Snippets": "vscode/Snippets",
		"/Users/alichtman/Library/Application Support/Code/User/keybindings.json": "vscode/keybindings",
		"/Users/alichtman/.atom": "atom",
		"/Users/alichtman/Library/Preferences/com.apple.Terminal.plist": "terminal_plist"
	}
}
```

#### .gitignore

As of `v4.0`, any `.gitignore` changes should be made in the `shallow-backup` config file. `.gitignore` changes that are meant to apply to all directories should be under the `root-gitignore` key. Dotfile specific gitignores should be placed under the `dotfiles-gitignore` key. The original `default-gitignore` key in the config is still supported for backwards compatibility, however, converting to the new config format is strongly encouraged.

#### Output Structure
---

```shell
backup_dir/
├── configs
│   ├── plist
│   │   └── com.apple.Terminal.plist
│   ├── sublime_2
│   │   └── ...
│   └── sublime_3
│       └── ...
├── dotfiles
│   ├── .bash_profile
│   ├── .bashrc
│   ├── .gitconfig
│   ├── .pypirc
│   ├── ...
│   ├── .shallow-backup
│   ├── .ssh/
│   │   └── known_hosts
│   ├── .vim/
│   └── .zshrc
├── fonts
│   ├── AllerDisplay.ttf
│   ├── Aller_Bd.ttf
│   ├── ...
│   ├── Ubuntu Mono derivative Powerline Italic.ttf
│   └── Ubuntu Mono derivative Powerline.ttf
└── packages
    ├── brew-cask_list.txt
    ├── brew_list.txt
    ├── cargo_list.txt
    ├── gem_list.txt
    ├── installed_apps_list.txt
    ├── npm_list.txt
    ├── macports_list.txt
    ├── pip_list.txt
    └── sublime3_list.txt
```

### Reinstalling Dotfiles
----

To reinstall your dotfiles, clone your dotfiles repo and make sure your shallow-backup config path can be found at either `~/.config/shallow-backup.conf` or `$XDG_CONFIG_HOME/.shallow_backup.conf`. Set the `backup-path` key in the config to the path of your cloned dotfiles. Then run `$ shallow-backup -reinstall-dots`.

When reinstalling your dotfiles, the top level `.git/`, `.gitignore`, `img/` and `README.md` files and directories are ignored.

### Want to Contribute?
---

Check out `CONTRIBUTING.md` and the `docs` directory.
<file_sep>import os
import sys
from pathlib import Path
from .testing_utility_functions import (
    BACKUP_DEST_DIR,
    FAKE_HOME_DIR,
    DOTFILES,
    setup_dirs_and_env_vars_and_create_config,
    clean_up_dirs_and_env_vars,
)

sys.path.insert(0, "../shallow_backup")
from shallow_backup.backup import backup_dotfiles
from shallow_backup.utils import safe_mkdir
from shallow_backup.config import get_config, write_config

TEST_TEXT_CONTENT = "THIS IS TEST CONTENT FOR THE DOTFILES"


class TestBackupMethods:
    """Test the backup methods."""

    @staticmethod
    def setup_method():
        setup_dirs_and_env_vars_and_create_config()

        # Create all dotfiles and dotfolders
        for file in DOTFILES:
            if not file.endswith("/"):
                print(f"Creating file: {file}")
                os.makedirs(Path(file).parent, exist_ok=True)
                with open(file, "w+") as f:
                    f.write(TEST_TEXT_CONTENT)
            else:
                directory = file
                print(f"Creating dir: {directory}")
                safe_mkdir(directory)
                for file_2 in ["test1", "test2"]:
                    with open(os.path.join(directory, file_2), "w+") as f:
                        f.write(TEST_TEXT_CONTENT)

    @staticmethod
    def teardown_method():
        clean_up_dirs_and_env_vars()

    def test_backup_dotfiles(self):
        """Test backing up dotfiles and dotfolders."""
        backup_dest_path = os.path.join(BACKUP_DEST_DIR, "dotfiles")
        backup_dotfiles(
            backup_dest_path, dry_run=False, home_path=FAKE_HOME_DIR, skip=True
        )
        assert os.path.isdir(backup_dest_path)
        for path in DOTFILES:
            print(
                f"\nBACKUP DESTINATION DIRECTORY: ({backup_dest_path}) CONTENTS:",
                os.listdir(backup_dest_path),
                "",
            )
            print(path + " should already be backed up.")
            backed_up_dot = os.path.join(
                backup_dest_path, path.replace(FAKE_HOME_DIR + "/", "")
            )
            print(f"Backed up dot: {backed_up_dot}\n")
            assert os.path.isfile(backed_up_dot) or os.path.isdir(backed_up_dot)

    def test_conditions(self):
        """Test backing up files based on conditions"""
        # Set false backup condition of all files.
        config = get_config()
        print(config["dotfiles"])
        for dot, _ in config["dotfiles"].items():
            config["dotfiles"][dot][
                "backup_condition"
            ] = "[[ $(uname -s) == 'Made Up OS' ]]"
        write_config(config)

        backup_dest_path = os.path.join(BACKUP_DEST_DIR, "dotfiles")
        backup_dotfiles(
            backup_dest_path, dry_run=False, home_path=FAKE_HOME_DIR, skip=True
        )
        assert os.path.isdir(backup_dest_path)
        for path in DOTFILES:
            print(
                f"\nBACKUP DESTINATION DIRECTORY: ({backup_dest_path}) CONTENTS:",
                os.listdir(backup_dest_path),
                "",
            )
            print(path + " should not be backed up.")
            backed_up_dot = os.path.join(
                backup_dest_path, path.replace(FAKE_HOME_DIR + "/", "")
            )
            assert not (os.path.isfile(backed_up_dot) or os.path.isdir(backed_up_dot))
<file_sep>import os
import sys
import json
import stat
from os import path, environ, chmod
from .printing import *
from .compatibility import *
from .utils import safe_mkdir, strip_home
from .constants import ProjInfo


def get_xdg_config_path() -> str:
    """Returns path to $XDG_CONFIG_HOME, or ~/.config, if it doesn't exist."""
    return environ.get("XDG_CONFIG_HOME") or path.join(path.expanduser("~"), ".config")


def get_config_path() -> str:
    """
    Detects if in testing or prod env, and returns the right config path.
    :return: Path to config.
    """
    test_config_path = environ.get("SHALLOW_BACKUP_TEST_CONFIG_PATH", None)
    if test_config_path:
        return test_config_path
    else:
        return path.join(get_xdg_config_path(), "shallow-backup.conf")


def get_config() -> dict:
    """
    :return Config.
    """
    config_path = get_config_path()
    with open(config_path) as file:
        try:
            config = json.load(file)
        except json.decoder.JSONDecodeError:
            print_red_bold(f"ERROR: Invalid syntax in {config_path}")
            sys.exit(1)
    return config


def write_config(config) -> None:
    """
    Write to config file
    """
    with open(get_config_path(), "w") as file:
        json.dump(config, file, indent=4)


def get_default_config() -> dict:
    """Returns a default, platform specific config."""
    return {
        "backup_path": "~/shallow-backup",
        "dotfiles": {
            ".bash_profile": {
                "backup_condition": "",
                "reinstall_condition": "",
            },
            ".bashrc": {},
            ".config/git": {},
            ".config/nvim/init.vim": {},
            ".config/tmux": {},
            ".config/zsh": {},
            ".profile": {},
            ".pypirc": {},
            ".ssh": {},
            ".zshenv": {},
            f"{strip_home(get_config_path())}": {},
        },
        "root-gitignore": ["dotfiles/.ssh", "dotfiles/.pypirc", ".DS_Store"],
        "dotfiles-gitignore": [
            ".ssh",
            ".pypirc",
            ".DS_Store",
        ],
        "config_mapping": get_config_paths(),
        "lowest_supported_version": ProjInfo.VERSION,
    }


def safe_create_config() -> None:
    """
    Creates config file (with 644 permissions) if it doesn't exist already. Prompts to update
    it if an outdated version is detected.
    """
    backup_config_path = get_config_path()
    # If it doesn't exist, create it.
    if not os.path.exists(backup_config_path):
        print_path_blue("Creating config file at:", backup_config_path)
        backup_config = get_default_config()
        safe_mkdir(os.path.split(backup_config_path)[0])
        write_config(backup_config)
        # $ chmod 644 config_file
        chmod(
            get_config_path(), stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH
        )


def check_insecure_config_permissions() -> bool:
    """Checks to see if group/others can write to config file.
    Returns: True if they can, False otherwise."""
    config_path = get_config_path()
    mode = os.stat(config_path).st_mode
    if mode & stat.S_IWOTH or mode & stat.S_IWGRP:
        print_red_bold(
            f"WARNING: {config_path} is writable by group/others and vulnerable to attack. To resolve, run: \n\t$ chmod 644 {config_path}"
        )
        return True
    else:
        return False


def delete_config_file() -> None:
    """Delete config file."""
    config_path = get_config_path()
    if os.path.isfile(config_path):
        print_red_bold("Deleting config file.")
        os.remove(config_path)
    else:
        print_red_bold("ERROR: No config file found.")


def add_dot_path_to_config(backup_config: dict, file_path: str) -> dict:
    """
    Add dotfile to config with default reinstall and backup conditions.
    Exit if the file_path parameter is invalid.
    :backup_config: dict representing current config
    :file_path:		str  relative or absolute path of file to add to config
    :return new backup config
    """
    abs_path = path.abspath(file_path)
    if not path.exists(abs_path):
        print_path_red("Invalid file path:", abs_path)
        return backup_config
    else:
        stripped_home_path = strip_home(abs_path)
        print_path_blue("Added:", stripped_home_path)
        backup_config["dotfiles"][stripped_home_path] = {}
    return backup_config


def show_config():
    """
    Print the config. Colorize section titles and indent contents.
    """
    print_section_header("SHALLOW BACKUP CONFIG", Fore.RED)
    for section, contents in get_config().items():
        # Print backup path on same line
        if section == "backup_path":
            print_path_red("Backup Path:", contents)
        elif section == "config_mapping":
            print_red_bold("\nConfigs:")
            for config_path, dest in contents.items():
                print(f"	{config_path} -> {dest}")
        # Print section header and contents. (Dotfiles)
        elif section == "dotfiles":
            print_path_red(
                "\nDotfiles:",
                "(Backup and Reinstall conditions will be shown if they exist)",
            )
            for dotfile, options in contents.items():
                backup_condition = options.get("backup_condition", "")
                reinstall_condition = options.get("reinstall_condition", "")
                if backup_condition or reinstall_condition:
                    print(f"	{dotfile} ->")
                    print(f'\t\tbackup_condition: "{backup_condition}"')
                    print(f'\t\treinstall_condition: "{reinstall_condition}"')
                else:
                    print(f"	{dotfile}")
        elif section == "lowest_supported_version":
            print_path_red(f"{section.replace('_', ' ').capitalize()}:", contents)
        else:
            print_red_bold(f"\n{section.replace('-', ' ').capitalize()}: ")
            for item in contents:
                print(f"	{item}")
<file_sep>import os
import json
from shlex import quote
from colorama import Fore
import multiprocessing as mp
from pathlib import Path
from shutil import copyfile
from .utils import *
from .printing import *
from .compatibility import *
from .config import get_config


def backup_dotfiles(
    backup_dest_path, dry_run=False, home_path=os.path.expanduser("~"), skip=False
):
    """
    Create `dotfiles` dir and makes copies of dotfiles and dotfolders.
    Assumes that dotfiles are stored in the home directory.
    :param skip: Boolean flag to skip prompting for overwrite. Used for scripting.
    :param backup_dest_path: Destination path for dotfiles. Like, ~/shallow-backup/dotfiles. Used in tests.
    :param home_path: Path where dotfiles will be found. $HOME by default.
    :param dry_run: Flag for determining if debug info should be shown or copying should occur.
    """
    print_section_header("DOTFILES", Fore.BLUE)
    if not dry_run:
        overwrite_dir_prompt_if_needed(backup_dest_path, skip)

    # get dotfolders and dotfiles
    config = get_config()["dotfiles"]

    # Aggregate pairs of [(Installed dotfile path, backup dest path)] in a list to be sorted into
    # dotfiles and dotfolders later
    dot_path_pairs = []
    for dotfile_path_from_config, options in config.items():
        # Evaluate condition, if specified. Skip if the command doesn't return true.
        condition_success = evaluate_condition(
            condition=options.get("backup_condition", ""),
            backup_or_reinstall="backup",
            dotfile_path=dotfile_path_from_config,
        )
        if not condition_success:
            continue

        # If a file path in the config starts with /, it's a full path like /etc/ssh/
        if dotfile_path_from_config.startswith("/"):
            installed_dotfile_path = dotfile_path_from_config
            installed_dotfile_path = quote(":" + installed_dotfile_path[1:])
            backup_dotfile_path = quote(
                os.path.join(backup_dest_path, installed_dotfile_path)
            )
            dot_path_pairs.append((dotfile_path_from_config, backup_dotfile_path))

        else:  # Dotfile living in $HOME
            installed_dotfile_path = quote(
                os.path.join(home_path, dotfile_path_from_config)
            )
            backup_dotfile_path = quote(
                os.path.join(backup_dest_path, dotfile_path_from_config)
            )
            dot_path_pairs.append((installed_dotfile_path, backup_dotfile_path))

    # Separate dotfiles and dotfolders
    dotfolders_mp_in = []
    dotfiles_mp_in = []
    for path_pair in dot_path_pairs:
        installed_path = path_pair[0]
        if os.path.isdir(installed_path):
            dotfolders_mp_in.append(path_pair)
        else:
            dotfiles_mp_in.append(path_pair)

    # Print source -> dest and skip the copying step
    if dry_run:
        print_yellow_bold("Dotfiles:")
        for source, dest in dotfiles_mp_in:
            print_dry_run_copy_info(source, dest)

        print_yellow_bold("\nDotfolders:")
        for source, dest in dotfolders_mp_in:
            print_dry_run_copy_info(source, dest)

        return

    # Fix https://github.com/alichtman/shallow-backup/issues/230
    for dest_path in [path_pair[1] for path_pair in dotfiles_mp_in + dotfolders_mp_in]:
        print(f"Creating: {os.path.split(dest_path)[0]}")
        safe_mkdir(os.path.split(dest_path)[0])

    with mp.Pool(mp.cpu_count()):
        print_blue_bold("Backing up dotfolders...")
        for x in dotfolders_mp_in:
            p = mp.Process(
                target=copy_dir_if_valid,
                args=(
                    x[0],
                    x[1],
                ),
            )
            p.start()
            p.join()

        print_blue_bold("Backing up dotfiles...")
        for x in dotfiles_mp_in:
            p = mp.Process(
                target=copyfile_with_exception_handler,
                args=(
                    x[0],
                    x[1],
                ),
            )
            p.start()
            p.join()


def backup_configs(backup_path, dry_run: bool = False, skip=False):
    """
    Creates `configs` directory and places config backups there.
    Configs are application settings, generally. .plist files count.
    In the config file, the value of the configs dictionary is the dest
    path relative to the configs/ directory.
    """
    print_section_header("CONFIGS", Fore.BLUE)
    # Don't clear any directories if this is a dry run
    if not dry_run:
        overwrite_dir_prompt_if_needed(backup_path, skip)
    config = get_config()

    print_blue_bold("Backing up configs...")

    # backup config files + dirs in backup_path/<target>/
    for config_path, target in config["config_mapping"].items():
        dest = os.path.join(backup_path, target)

        if dry_run:
            print_dry_run_copy_info(config_path, dest)
            continue

        quoted_dest = quote(dest)
        if os.path.isdir(config_path):
            copytree(config_path, quoted_dest, symlinks=True)
        elif os.path.isfile(config_path):
            parent_dir = Path(dest).parent
            safe_mkdir(parent_dir)
            copyfile(config_path, quoted_dest)


def backup_packages(backup_path, dry_run: bool = False, skip=False):
    """
    Creates `packages` directory and places install list text files there.
    """

    def run_cmd_if_no_dry_run(command, dest, dry_run) -> int:
        if dry_run:
            print_dry_run_copy_info(f"$ {command}", dest)
            # Return -1 for any processes depending on chained successful commands (npm)
            return -1
        else:
            return run_cmd_write_stdout(command, dest)

    print_section_header("PACKAGES", Fore.BLUE)
    if not dry_run:
        overwrite_dir_prompt_if_needed(backup_path, skip)

    # brew
    print_pkg_mgr_backup("brew")
    command = f"brew bundle dump --file {backup_path}/brew_list.txt"
    dest = f"{backup_path}/brew_list.txt"
    if not dry_run:
        if not run_cmd(command):
            print_yellow("brew package manager not found.")

    # ruby
    print_pkg_mgr_backup("gem")
    command = r"gem list | tail -n+1 | sed -E 's/\((default: )?(.*)\)/--version \2/'"
    dest = f"{backup_path}/gem_list.txt"
    run_cmd_if_no_dry_run(command, dest, dry_run)

    # cargo
    print_pkg_mgr_backup("cargo")
    command = (
        r"cargo install --list | grep '^\w.*:$' | sed -E 's/ v(.*):$/ --version \1/'"
    )
    dest = f"{backup_path}/cargo_list.txt"
    run_cmd_if_no_dry_run(command, dest, dry_run)

    # pip
    print_pkg_mgr_backup("pip")
    command = "pip list --format=freeze"
    dest = f"{backup_path}/pip_list.txt"
    run_cmd_if_no_dry_run(command, dest, dry_run)

    # pip3
    print_pkg_mgr_backup("pip3")
    command = "pip3 list --format=freeze"
    dest = f"{backup_path}/pip3_list.txt"
    run_cmd_if_no_dry_run(command, dest, dry_run)

    # npm
    print_pkg_mgr_backup("npm")
    command = "npm ls --global --json=true --depth=0"
    temp_file_path = f"{backup_path}/npm_temp_list.json"
    # If command is successful, go to the next parsing step.
    npm_backup_cmd_success = (
        run_cmd_if_no_dry_run(command, temp_file_path, dry_run) == 0
    )
    if npm_backup_cmd_success:
        npm_dest_file = f"{backup_path}/npm_list.txt"
        with open(temp_file_path, "r") as temp_file:
            npm_packages = json.load(temp_file).get("dependencies").keys()
            if len(npm_packages) >= 1:
                with open(npm_dest_file, "w") as dest:
                    [dest.write(f"{package}\n") for package in npm_packages]
        os.remove(temp_file_path)

    # vscode extensions
    print_pkg_mgr_backup("VSCode")
    command = "code --list-extensions --show-versions"
    dest = f"{backup_path}/vscode_list.txt"
    run_cmd_if_no_dry_run(command, dest, dry_run)

    # macports
    print_pkg_mgr_backup("macports")
    command = "port installed requested"
    dest = f"{backup_path}/macports_list.txt"
    run_cmd_if_no_dry_run(command, dest, dry_run)

    # system installs
    print_pkg_mgr_backup("System Applications")
    applications_path = get_applications_dir()
    command = "ls {}".format(applications_path)
    dest = f"{backup_path}/system_apps_list.txt"
    run_cmd_if_no_dry_run(command, dest, dry_run)


def backup_fonts(backup_path: str, dry_run: bool = False, skip: bool = False):
    """Copies all .ttf and .otf files in the  to backup/fonts/"""
    print_section_header("FONTS", Fore.BLUE)
    if not dry_run:
        overwrite_dir_prompt_if_needed(backup_path, skip)
    print_blue("Copying '.otf' and '.ttf' fonts...")
    fonts_path = get_fonts_dir()
    if os.path.isdir(fonts_path):
        fonts = [
            quote(os.path.join(fonts_path, font))
            for font in os.listdir(fonts_path)
            if font.endswith(".otf") or font.endswith(".ttf")
        ]

        for font in fonts:
            dest = os.path.join(backup_path, font.split("/")[-1])
            if os.path.exists(font):
                if dry_run:
                    print_dry_run_copy_info(font, dest)
                else:
                    copyfile(font, dest)
    else:
        print_red("Skipping fonts backup. No fonts directory found.")


def backup_all(
    dotfiles_path, packages_path, fonts_path, configs_path, dry_run=False, skip=False
):
    """Complete backup procedure."""
    backup_dotfiles(dotfiles_path, dry_run=dry_run, skip=skip)
    backup_packages(packages_path, dry_run=dry_run, skip=skip)
    backup_fonts(fonts_path, dry_run=dry_run, skip=skip)
    backup_configs(configs_path, dry_run=dry_run, skip=skip)


# vim: autoindent noexpandtab tabstop=4 shiftwidth=4
<file_sep>import os
import sys
from .testing_utility_functions import (
    setup_dirs_and_env_vars_and_create_config,
    clean_up_dirs_and_env_vars,
    FAKE_HOME_DIR,
)

sys.path.insert(0, "../shallow_backup")
from shallow_backup.config import (
    get_config,
    get_config_path,
    add_dot_path_to_config,
    check_insecure_config_permissions,
)
from shallow_backup.utils import strip_home


class TestConfigMethods:
    """Test the config methods."""

    @staticmethod
    def setup_method():
        setup_dirs_and_env_vars_and_create_config()

    @staticmethod
    def teardown_method():
        clean_up_dirs_and_env_vars()

    def test_add_path(self):
        config = get_config()
        home_path = os.path.expanduser("~")
        invalid_path = "some_random_nonexistent_path"
        path_to_add = os.path.join(home_path, invalid_path)
        new_config = add_dot_path_to_config(config, path_to_add)
        assert strip_home(invalid_path) not in new_config["dotfiles"]

        valid_path = "valid"
        path_to_add = os.path.join(FAKE_HOME_DIR, valid_path)
        os.mkdir(path_to_add)
        new_config = add_dot_path_to_config(config, path_to_add)
        from pprint import pprint

        pprint(new_config)
        stripped_home_path = strip_home(path_to_add)
        assert stripped_home_path in new_config["dotfiles"]
        assert isinstance(new_config["dotfiles"][stripped_home_path], dict)

    def test_detect_insecure_config_permissions(self):
        print(f"Testing config path: {get_config_path()}")
        os.chmod(get_config_path(), 0o777)
        assert check_insecure_config_permissions() == True

    def test_secure_config_created_by_default(self):
        print(f"Testing config path: {get_config_path()}")
        assert check_insecure_config_permissions() == False
<file_sep>import os
import sys
import inquirer
from colorama import Fore, Style
from .constants import ProjInfo


def print_blue(text):
    print(Fore.BLUE + text + Style.RESET_ALL)


def print_red(text):
    print(Fore.RED + text + Style.RESET_ALL)


def print_yellow(text):
    print(Fore.YELLOW + text + Style.RESET_ALL)


def print_green(text):
    print(Fore.GREEN + text + Style.RESET_ALL)


def print_blue_bold(text):
    print(Fore.BLUE + Style.BRIGHT + text + Style.RESET_ALL)


def print_red_bold(text):
    print(Fore.RED + Style.BRIGHT + text + Style.RESET_ALL)


def print_yellow_bold(text):
    print(Fore.YELLOW + Style.BRIGHT + text + Style.RESET_ALL)


def print_green_bold(text):
    print(Fore.GREEN + Style.BRIGHT + text + Style.RESET_ALL)


def print_path_blue(text, path):
    print(Fore.BLUE + Style.BRIGHT + text, Style.NORMAL + path + Style.RESET_ALL)


def print_path_red(text, path):
    print(Fore.RED + Style.BRIGHT + text, Style.NORMAL + path + Style.RESET_ALL)


def print_path_yellow(text, path):
    print(Fore.YELLOW + Style.BRIGHT + text, Style.NORMAL + path + Style.RESET_ALL)


def print_path_green(text, path):
    print(Fore.GREEN + Style.BRIGHT + text, Style.NORMAL + path + Style.RESET_ALL)


def print_dry_run_copy_info(source, dest):
    """Show source -> dest copy. Replaces expanded ~ with ~ if it's at the beginning of paths.
    source and dest are trimmed in the middle if needed. Removed characters will be replaced by ...
    :param source: Can be of type str or Path
    :param dest: Can be of type str or Path
    """

    def shorten_home(path):
        expanded_home = os.path.expanduser("~")
        path = str(path)
        if path.startswith(expanded_home):
            return path.replace(expanded_home, "~")
        return path

    def truncate_middle(path: str, acceptable_len: int):
        """Middle truncate a string
        https://www.xormedia.com/string-truncate-middle-with-ellipsis/
        """
        if len(path) <= acceptable_len:
            return path
        # half of the size, minus the 3 .'s
        n_2 = int(acceptable_len / 2 - 3)
        # whatever's left
        n_1 = int(acceptable_len - n_2 - 3)
        return f"{path[:n_1]}...{path[-n_2:]}"

    trimmed_source = shorten_home(source)
    trimmed_dest = shorten_home(dest)
    longest_allowed_path_len = 87
    if len(trimmed_source) + len(trimmed_dest) > longest_allowed_path_len:
        trimmed_source = truncate_middle(trimmed_source, longest_allowed_path_len)
        trimmed_dest = truncate_middle(trimmed_dest, longest_allowed_path_len)
    print(
        Fore.YELLOW + Style.BRIGHT + trimmed_source + Style.NORMAL,
        "->",
        Style.BRIGHT + trimmed_dest + Style.RESET_ALL,
    )


def print_version_info(cli=True):
    """
    Formats version differently for CLI and splash screen.
    """
    version = "v{} by {} (@{})".format(
        ProjInfo.VERSION, ProjInfo.AUTHOR_FULL_NAME, ProjInfo.AUTHOR_GITHUB
    )
    if not cli:
        print(Fore.RED + Style.BRIGHT + "\t{}\n".format(version) + Style.RESET_ALL)
    else:
        print(version)


def splash_screen():
    """Display splash graphic, and then stylized version and author info."""
    print(Fore.YELLOW + Style.BRIGHT + "\n" + ProjInfo.LOGO + Style.RESET_ALL)
    print_version_info(False)


def print_section_header(title, color):
    """Prints variable sized section header."""
    block = "#" * (len(title) + 2)
    print("\n" + color + Style.BRIGHT + block)
    print("#", title)
    print(block + "\n" + Style.RESET_ALL)


def print_pkg_mgr_backup(mgr):
    print(
        "{}Backing up {}{}{}{}{} packages list...{}".format(
            Fore.BLUE,
            Style.BRIGHT,
            Fore.YELLOW,
            mgr,
            Fore.BLUE,
            Style.NORMAL,
            Style.RESET_ALL,
        )
    )


def print_pkg_mgr_reinstall(mgr):
    print(
        "{}Reinstalling {}{}{}{}{}...{}".format(
            Fore.BLUE,
            Style.BRIGHT,
            Fore.YELLOW,
            mgr,
            Fore.BLUE,
            Style.NORMAL,
            Style.RESET_ALL,
        )
    )


# TODO: BUG: Why does moving this to prompts.py cause circular imports?
def prompt_yes_no(message, color, invert=False):
    """
    Print question and return True or False depending on user selection from list.
    """
    questions = [
        inquirer.List(
            "choice",
            message=color + Style.BRIGHT + message + Fore.BLUE,
            choices=(" No", " Yes") if invert else (" Yes", " No"),
        )
    ]

    answers = inquirer.prompt(questions)
    if answers:
        return answers.get("choice").strip().lower() == "yes"
    else:
        sys.exit(1)
<file_sep>import os
import sys
import pytest
from .testing_utility_functions import (
    clean_up_dirs_and_env_vars,
    BACKUP_DEST_DIR,
    FAKE_HOME_DIR,
    create_dir_overwrite,
    setup_env_vars,
    create_config_for_test,
)

sys.path.insert(0, "../shallow_backup")
from shallow_backup.utils import copy_dir_if_valid


class TestCopyMethods:
    """
    Test the functionality of copying
    """

    @staticmethod
    def setup_method():
        setup_env_vars()
        create_config_for_test()
        create_dir_overwrite(FAKE_HOME_DIR)

    @staticmethod
    def teardown_method():
        clean_up_dirs_and_env_vars()

    def test_copy_dir(self):
        """
        Test that copying a directory works as expected
        """
        # TODO: Test that all subfiles and folders are copied.
        test_file_name = "test-file.txt"
        test_dir_name = "subdir-to-copy"
        os.mkdir(os.path.join(FAKE_HOME_DIR, test_dir_name))
        with open(os.path.join(FAKE_HOME_DIR, test_file_name), "w+") as file:
            file.write("TRASH")

        copy_dir_if_valid(FAKE_HOME_DIR, BACKUP_DEST_DIR)
        assert os.path.isfile(os.path.join(BACKUP_DEST_DIR, test_file_name))
        assert os.path.isdir(os.path.join(BACKUP_DEST_DIR, test_dir_name))

    @pytest.mark.parametrize("invalid", {".Trash", ".npm", ".cache", ".rvm"})
    def test_copy_dir_invalid(self, invalid):
        """
        Test that attempting to copy an invalid directory fails
        """
        copy_dir_if_valid(invalid, FAKE_HOME_DIR)
        assert not os.path.isdir(os.path.join(BACKUP_DEST_DIR, invalid))
<file_sep>import os
import sys
from .testing_utility_functions import (
    FAKE_HOME_DIR,
    setup_dirs_and_env_vars_and_create_config,
    clean_up_dirs_and_env_vars,
)

sys.path.insert(0, "../shallow_backup")
from shallow_backup.reinstall import reinstall_dots_sb

TEST_TEXT_CONTENT = "THIS IS TEST CONTENT FOR THE DOTFILES"
DOTFILES_PATH = os.path.join(FAKE_HOME_DIR, "dotfiles/")


class TestReinstallDotfiles:
    """
    Test the functionality of reinstalling dotfiles
    """

    @staticmethod
    def setup_method():
        def create_nested_dir(parent, name):
            new_dir = os.path.join(parent, name)
            print(f"Creating {new_dir}")
            if not os.path.isdir(new_dir):
                os.makedirs(new_dir)
            return new_dir

        def create_file(parent, name):
            file = os.path.join(parent, name)
            print(f"Creating {file}")
            with open(file, "w+") as f:
                f.write(TEST_TEXT_CONTENT)

        def create_git_dir(parent):
            git_dir = create_nested_dir(parent, ".git/")
            git_objects = create_nested_dir(git_dir, "objects/")
            create_file(git_dir, "config")
            create_file(git_objects, "obj1")
            return git_dir

        setup_dirs_and_env_vars_and_create_config()

        # Dotfiles / dirs to NOT reinstall
        create_git_dir(DOTFILES_PATH)  # Should NOT reinstall DOTFILES_PATH/.git
        img_dir_should_not_reinstall = create_nested_dir(DOTFILES_PATH, "img")
        create_file(img_dir_should_not_reinstall, "test.png")
        create_file(DOTFILES_PATH, "README.md")
        create_file(DOTFILES_PATH, ".gitignore")

        # Dotfiles / dirs to reinstall
        testfolder = create_nested_dir(DOTFILES_PATH, ".config/tmux/")
        testfolder2 = create_nested_dir(testfolder, "testfolder2/")
        create_file(testfolder2, "test.sh")
        create_git_dir(testfolder2)
        git_config = create_nested_dir(DOTFILES_PATH, ".config/git")
        create_file(git_config, "test")
        create_file(testfolder2, ".gitignore")
        create_file(DOTFILES_PATH, ".zshenv")

    @staticmethod
    def teardown_method():
        clean_up_dirs_and_env_vars()

    def test_reinstall_dotfiles(self):
        """
        Test reinstalling dotfiles to fake home dir
        """
        reinstall_dots_sb(
            dots_path=DOTFILES_PATH, home_path=FAKE_HOME_DIR, dry_run=False
        )
        assert os.path.isfile(os.path.join(FAKE_HOME_DIR, ".zshenv"))
        testfolder2 = os.path.join(
            os.path.join(FAKE_HOME_DIR, ".config/tmux/"), "testfolder2"
        )
        assert os.path.isdir(testfolder2)
        assert os.path.isfile(os.path.join(testfolder2, "test.sh"))
        assert os.path.isdir(os.path.join(FAKE_HOME_DIR, ".config/git/"))

        # Do reinstall other git files
        assert os.path.isdir(os.path.join(testfolder2, ".git"))
        assert os.path.isfile(os.path.join(testfolder2, ".gitignore"))

        # Don't reinstall root-level git files
        assert not os.path.isdir(os.path.join(FAKE_HOME_DIR, ".git"))
        assert not os.path.isfile(os.path.join(FAKE_HOME_DIR, ".gitignore"))

        # Don't reinstall img or README.md
        assert not os.path.isdir(os.path.join(FAKE_HOME_DIR, "img"))
        assert not os.path.isfile(os.path.join(FAKE_HOME_DIR, "README.md"))
<file_sep>import os
import shutil
import sys

sys.path.insert(0, "../shallow_backup")
from shallow_backup.config import safe_create_config


def setup_env_vars():
    os.environ["SHALLOW_BACKUP_TEST_BACKUP_DIR"] = (
        os.path.abspath(BASE_TEST_DIR) + "/backup"
    )
    os.environ["SHALLOW_BACKUP_TEST_HOME_DIR"] = (
        os.path.abspath(BASE_TEST_DIR) + "/home"
    )
    # This env var is referenced in shallow_backup/config.py
    os.environ["SHALLOW_BACKUP_TEST_CONFIG_PATH"] = (
        os.path.abspath(BASE_TEST_DIR) + "/shallow-backup.conf"
    )


def unset_env_vars():
    del os.environ["SHALLOW_BACKUP_TEST_BACKUP_DIR"]
    del os.environ["SHALLOW_BACKUP_TEST_HOME_DIR"]
    del os.environ["SHALLOW_BACKUP_TEST_CONFIG_PATH"]


def create_config_for_test():
    config_file = os.environ["SHALLOW_BACKUP_TEST_CONFIG_PATH"]
    if os.path.isfile(config_file):
        os.remove(config_file)
    safe_create_config()


def create_dir_overwrite(directory):
    if os.path.isdir(directory):
        shutil.rmtree(directory)
    os.makedirs(directory)


def setup_dirs_and_env_vars_and_create_config():
    setup_env_vars()
    create_config_for_test()
    for directory in DIRS:
        create_dir_overwrite(directory)


def clean_up_dirs_and_env_vars():
    shutil.rmtree(BASE_TEST_DIR)
    unset_env_vars()


# TODO: Update to tempfile and tempdir because testing in the home directory is so stupid.

# These globals must remain at the bottom of this file for some reason
# This global is required to be set for the setup_env_vars call to work properly.
BASE_TEST_DIR = os.path.expanduser("~") + "/SHALLOW-BACKUP-TEST-DIRECTORY"
setup_env_vars()
BACKUP_DEST_DIR = os.environ.get("SHALLOW_BACKUP_TEST_BACKUP_DIR")
FAKE_HOME_DIR = os.environ.get("SHALLOW_BACKUP_TEST_HOME_DIR")
DIRS = [BACKUP_DEST_DIR, FAKE_HOME_DIR]

DOTFILES = [
    os.path.join(FAKE_HOME_DIR, ".ssh/"),
    os.path.join(FAKE_HOME_DIR, ".config/git/"),
    os.path.join(FAKE_HOME_DIR, ".zshenv"),
    os.path.join(FAKE_HOME_DIR, ".pypirc"),
    os.path.join(FAKE_HOME_DIR, ".config/nvim/init.vim"),
    os.path.join(FAKE_HOME_DIR, ".config/zsh/.zshrc"),
]
<file_sep>import sys
from .testing_utility_functions import setup_env_vars, unset_env_vars

sys.path.insert(0, "../shallow_backup")
from shallow_backup.utils import run_cmd_return_bool


class TestUtilMethods:
    """
    Test the functionality of utils
    """

    @staticmethod
    def setup_method():
        setup_env_vars()

    @staticmethod
    def teardown_method():
        unset_env_vars()

    def test_run_cmd_return_bool(self):
        """Test that evaluating bash commands to get booleans works as expected"""

        # Basic bash conditionals with command substitution
        should_fail = "[[ -z $(uname -s) ]]"
        assert run_cmd_return_bool(should_fail) is False

        should_pass = "[[ -n $(uname -s) ]]"
        assert run_cmd_return_bool(should_pass) is True

        # Using env vars
        should_pass = '[[ -n "$SHALLOW_BACKUP_TEST_BACKUP_DIR" ]]'
        assert run_cmd_return_bool(should_pass) is True

        should_pass = "[[ -n $SHALLOW_BACKUP_TEST_BACKUP_DIR ]]"
        assert run_cmd_return_bool(should_pass) is True
<file_sep>import os
from shlex import quote
from .utils import (
    run_cmd,
    get_abs_path_subfiles,
    exit_if_dir_is_empty,
    safe_mkdir,
    evaluate_condition,
)
from .printing import *
from .compatibility import *
from .config import get_config
from pathlib import Path
from shutil import copytree, copyfile, copy

# NOTE: Naming convention is like this since the CLI flags would otherwise
#       conflict with the function names.


def reinstall_dots_sb(
    dots_path: str, home_path: str = os.path.expanduser("~"), dry_run: bool = False
):
    """Reinstall all dotfiles and folders by copying them from dots_path
    to a path relative to home_path, or to an absolute path."""
    exit_if_dir_is_empty(dots_path, "dotfile")
    print_section_header("REINSTALLING DOTFILES", Fore.BLUE)

    # Get paths of ALL files that we will be reinstalling from config.
    # 	If .ssh is in the config, full paths of all dots_path/.ssh/* files
    # 	will be in dotfiles_to_reinstall
    config = get_config()["dotfiles"]

    dotfiles_to_reinstall = []
    for dotfile_path_from_config, options in config.items():
        # Evaluate condition, if specified. Skip if the command doesn't return true.
        condition_success = evaluate_condition(
            condition=options.get("reinstall_condition", ""),
            backup_or_reinstall="reinstall",
            dotfile_path=dotfile_path_from_config,
        )
        if not condition_success:
            continue

        real_path_dotfile = os.path.join(dots_path, dotfile_path_from_config)
        if os.path.isfile(real_path_dotfile):
            dotfiles_to_reinstall.append(real_path_dotfile)
        else:
            subfiles_to_add = get_abs_path_subfiles(real_path_dotfile)
            dotfiles_to_reinstall.extend(subfiles_to_add)

    # Create list of tuples containing source and dest paths for dotfile reinstallation
    # The absolute file paths prepended with ':' are converted back to valid paths
    # Format: [(source, dest), ... ]
    full_path_dotfiles_to_reinstall = []
    for source in dotfiles_to_reinstall:
        # If it's an absolute path, dest is the corrected path
        if source.startswith(":"):
            dest = "/" + source[1:]
        else:
            # Otherwise, it should go in a path relative to the home path
            dest = source.replace(dots_path, home_path + "/")
        full_path_dotfiles_to_reinstall.append((Path(source), Path(dest)))

    # Copy files from backup to system
    for dot_source, dot_dest in full_path_dotfiles_to_reinstall:
        if dry_run:
            print_dry_run_copy_info(dot_source, dot_dest)
            continue

        # Create dest parent dir if it doesn't exist
        safe_mkdir(dot_dest.parent)
        try:
            copy(dot_source, dot_dest)
        except PermissionError as err:
            print_red_bold(f"ERROR: {err}")
        except FileNotFoundError as err:
            print_red_bold(f"ERROR: {err}")

    print_section_header("DOTFILE REINSTALLATION COMPLETED", Fore.BLUE)


def reinstall_fonts_sb(fonts_path: str, dry_run: bool = False):
    """Reinstall all fonts."""
    exit_if_dir_is_empty(fonts_path, "font")
    print_section_header("REINSTALLING FONTS", Fore.BLUE)

    # Copy every file in fonts_path to ~/Library/Fonts
    for font in get_abs_path_subfiles(fonts_path):
        fonts_dir = get_fonts_dir()
        dest_path = quote(os.path.join(fonts_dir, font.split("/")[-1]))
        if dry_run:
            print_dry_run_copy_info(font, dest_path)
            continue
        copyfile(quote(font), quote(dest_path))
    print_section_header("FONT REINSTALLATION COMPLETED", Fore.BLUE)


def reinstall_configs_sb(configs_path: str, dry_run: bool = False):
    """Reinstall all configs from the backup."""
    exit_if_dir_is_empty(configs_path, "config")
    print_section_header("REINSTALLING CONFIG FILES", Fore.BLUE)

    config = get_config()
    for dest_path, backup_loc in config["config_mapping"].items():
        dest_path = quote(dest_path)
        source_path = quote(os.path.join(configs_path, backup_loc))

        if dry_run:
            print_dry_run_copy_info(source_path, dest_path)
            continue

        if os.path.isdir(source_path):
            copytree(source_path, dest_path)
        elif os.path.isfile(source_path):
            copyfile(source_path, dest_path)

    print_section_header("CONFIG REINSTALLATION COMPLETED", Fore.BLUE)


def reinstall_packages_sb(packages_path: str, dry_run: bool = False):
    """Reinstall all packages from the files in backup/installs."""

    def run_cmd_if_no_dry_run(command, dry_run) -> int:
        if dry_run:
            print_yellow_bold(f"$ {command}")
            # Return 0 for any processes depending on chained successful commands
            return 0
        else:
            return run_cmd(command)

    exit_if_dir_is_empty(packages_path, "package")
    print_section_header("REINSTALLING PACKAGES", Fore.BLUE)

    # Figure out which install lists they have saved
    package_mgrs = set()
    for file in os.listdir(packages_path):
        manager = file.split("_")[0].replace("-", " ")
        if manager in [
            "gem",
            "cargo",
            "npm",
            "pip",
            "pip3",
            "brew",
            "vscode",
            "macports",
        ]:
            package_mgrs.add(file.split("_")[0])

    print_blue_bold("Package Manager Backups Found:")
    for mgr in package_mgrs:
        print_yellow("\t{}".format(mgr))
    print()

    # TODO: Multithreading for reinstallation.
    # Construct reinstallation commands and execute them
    for pm in package_mgrs:
        if pm == "brew":
            print_pkg_mgr_reinstall(pm)
            cmd = f"brew bundle install --no-lock --file {packages_path}/brew_list.txt"
            run_cmd_if_no_dry_run(cmd, dry_run)
        elif pm == "npm":
            print_pkg_mgr_reinstall(pm)
            cmd = f"cat {packages_path}/npm_list.txt | xargs npm install -g"
            run_cmd_if_no_dry_run(cmd, dry_run)
        elif pm == "pip":
            print_pkg_mgr_reinstall(pm)
            cmd = f"pip install -r {packages_path}/pip_list.txt"
            run_cmd_if_no_dry_run(cmd, dry_run)
        elif pm == "pip3":
            print_pkg_mgr_reinstall(pm)
            cmd = f"pip3 install -r {packages_path}/pip3_list.txt"
            run_cmd_if_no_dry_run(cmd, dry_run)
        elif pm == "vscode":
            print_pkg_mgr_reinstall(pm)
            with open(f"{packages_path}/vscode_list.txt", "r") as file:
                for package in file:
                    cmd = f"code --install-extension {package}"
                    run_cmd_if_no_dry_run(cmd, dry_run)
        elif pm == "macports":
            print_red_bold("WARNING: Macports reinstallation is not supported.")
        elif pm == "gem":
            print_pkg_mgr_reinstall(pm)
            cmd = f"cat {packages_path}/gem_list.txt | xargs -L 1 gem install"
            run_cmd_if_no_dry_run(cmd, dry_run)
        elif pm == "cargo":
            print_pkg_mgr_reinstall(pm)
            cmd = f"cat {packages_path}/cargo_list.txt | xargs -L 1 cargo install"
            run_cmd_if_no_dry_run(cmd, dry_run)

    print_section_header("PACKAGE REINSTALLATION COMPLETED", Fore.BLUE)


def reinstall_all_sb(
    dotfiles_path: str,
    packages_path: str,
    fonts_path: str,
    configs_path: str,
    dry_run: bool = False,
):
    """Call all reinstallation methods."""
    reinstall_dots_sb(dotfiles_path, dry_run=dry_run)
    reinstall_packages_sb(packages_path, dry_run=dry_run)
    reinstall_fonts_sb(fonts_path, dry_run=dry_run)
    reinstall_configs_sb(configs_path, dry_run=dry_run)
<file_sep>#!/bin/bash
# Release script for shallow-backup

# NOTE: Must be run from project root directory

set -e

# Check if on master
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [[ "$BRANCH" != "master" ]]; then
  echo 'Must be on master branch to cut a release!';
  exit 1;
fi

# Check if master is dirty
if [[ -n $(git status -s) ]]; then
    echo 'Master branch dirty! Aborting.';
    exit 1;
fi

SB_VERSION="v$(python3 -c "from shallow_backup.constants import ProjInfo; print(ProjInfo.VERSION)")"
SB_VERSION_NO_V="$(python3 -c "from shallow_backup.constants import ProjInfo; print(ProjInfo.VERSION)")"

read -r -p "Release shallow-backup $SB_VERSION? Version bump should already be committed and pushed. [y/N] " response
case "$response" in
    [yY][eE][sS]|[yY])
        echo "Releasing."
        ;;
    *)
        echo "Aborting."
        exit;
        ;;
esac

git checkout master && git pull
git tag -a "$SB_VERSION" -m "shallow-backup $SB_VERSION" && git push
github_changelog_generator --user alichtman --project shallow-backup
git add CHANGELOG.md && git commit -m "Add CHANGELOG for $SB_VERSION" && git push
echo "Generating distribution files..."
rm -rf dist/* && python3 setup.py sdist
echo "Creating GH release..."
hub release create "$SB_VERSION" --file "dist/shallow-backup-$SB_VERSION_NO_V.tar.gz" -m "shallow-backup $SB_VERSION"
echo "Uploading to pypi..."
twine upload --repository pypi dist/*
<file_sep>import sys
from .config import get_config
from .printing import print_red_bold, print_red
from .constants import ProjInfo


def check_if_config_upgrade_needed():
    """Checks if a config is supported by the current version of shallow-backup"""
    config = get_config()
    # If this key is not in the config, that means the config was installed pre-v5.0.0a
    if "lowest_supported_version" not in config:
        print_red_bold(
            f"ERROR: Config version detected as incompatible with current shallow-backup version ({ProjInfo.VERSION})."
        )
        print_red("There are two possible fixes.")
        print_red(
            "1. Backup your config file to another location and remove the original config."
        )
        print_red("\tshallow-backup will recreate a compatible config on the next run.")
        print_red("\tYou can then add in your custom backup paths manually.")
        print_red("2. Manually upgrade the config.")
        print_red_bold(
            "Please downgrade to a version of shallow-backup before v5.0.0a if you do not want to upgrade your config."
        )
        sys.exit()
<file_sep>import os
import sys
from .testing_utility_functions import (
    FAKE_HOME_DIR,
    clean_up_dirs_and_env_vars,
    setup_dirs_and_env_vars_and_create_config,
)

sys.path.insert(0, "../shallow_backup")
from shallow_backup.utils import destroy_backup_dir

TEST_BACKUP_TEXT_FILE = os.path.join(FAKE_HOME_DIR, "test-file.txt")


class TestDeleteMethods:
    """
    Test the functionality of deleting
    """

    @staticmethod
    def setup_method():
        setup_dirs_and_env_vars_and_create_config()
        with open(TEST_BACKUP_TEXT_FILE, "w+") as file:
            file.write("RANDOM TEXT")

    @staticmethod
    def teardown_method():
        clean_up_dirs_and_env_vars()

    def test_clean_an_existing_backup_directory(self):
        """
        Test that deleting the backup directory works as expected
        """
        assert os.path.isdir(FAKE_HOME_DIR)
        assert os.path.isfile(TEST_BACKUP_TEXT_FILE)
        destroy_backup_dir(FAKE_HOME_DIR)
        assert not os.path.isdir(FAKE_HOME_DIR)
        assert not os.path.isfile(TEST_BACKUP_TEXT_FILE)

    def test_can_handle_cleaning_non_existing_backup_directory(self):
        """
        Test that we exit gracefully when cleaning an non existing backup directory
        """
        nonexist_backup_dir = os.path.join(FAKE_HOME_DIR, "NON-EXISTENT")
        assert not os.path.isdir(nonexist_backup_dir)
        destroy_backup_dir(nonexist_backup_dir)
        assert not os.path.isdir(nonexist_backup_dir)
