Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Uninstall #407

Merged
merged 22 commits into from
Oct 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions docs/config/config-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ The **config** entry (mandatory) contains global settings.

Entry | Description | Default
-------- | ------------- | ------------
`backup` | Create a backup of the dotfile in case it differs from the one that will be installed by dotdrop | true
`backup` | Create a backup of the existing destination; see [backup entry](config-config.md#backup-entry)) | true
`banner` | Display the banner | true
`check_version` | Check if a new version of dotdrop is available on github | false
`chmod_on_import` | Always add a chmod entry on newly imported dotfiles (see `--preserve-mode`) | false
Expand Down Expand Up @@ -212,4 +212,16 @@ profiles:
hostname:
dotfiles:
- f_vimrc
```
```

## backup entry

When set to `true`, existing files that would be replaced
by a dotdrop `install`, are backed up with the
extension `.dotdropbak` if their content differ.

Note:
* directories will **not** be backed up, only files
* when using a different `link` value than `nolink` with directories,
the files under the directory will **not** be backed up
(See [Symlinking dotfiles](config-file.md#symlinking-dotfiles)),
15 changes: 15 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,21 @@ dotdrop. It will:

For more options, see the usage with `dotdrop --help`.

## Uninstall dotfiles

The `uninstall` command removes dotfiles installed by dotdrop
```bash
$ dotdrop uninstall
```

It will remove the installed dotfiles related to the provided key
(or all dotfiles if not provided) of the selected profile.

If a backup exists ([backup entry](config/config-config.md#backup-entry)),
the file will be restored.

For more options, see the usage with `dotdrop --help`.

## Concurrency

The command line switch `-w`/`--workers`, if set to a value greater than one, enables the use
Expand Down
48 changes: 48 additions & 0 deletions dotdrop/dotdrop.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from dotdrop.logger import Logger
from dotdrop.templategen import Templategen
from dotdrop.installer import Installer
from dotdrop.uninstaller import Uninstaller
from dotdrop.updater import Updater
from dotdrop.comparator import Comparator
from dotdrop.importer import Importer
Expand Down Expand Up @@ -618,6 +619,47 @@ def cmd_detail(opts):
LOG.log('')


def cmd_uninstall(opts):
"""uninstall"""
dotfiles = opts.dotfiles
keys = opts.uninstall_key

if keys:
# update only specific keys for this profile
dotfiles = []
for key in uniq_list(keys):
dotfile = opts.conf.get_dotfile(key)
if dotfile:
dotfiles.append(dotfile)

if not dotfiles:
msg = f'no dotfile to uninstall for this profile (\"{opts.profile}\")'
LOG.warn(msg)
return False

if opts.debug:
lfs = [k.key for k in dotfiles]
LOG.dbg(f'dotfiles registered for uninstall: {lfs}')

uninst = Uninstaller(base=opts.dotpath,
workdir=opts.workdir,
dry=opts.dry,
safe=opts.safe,
debug=opts.debug,
backup_suffix=opts.install_backup_suffix)
uninstalled = 0
for dotf in dotfiles:
res, msg = uninst.uninstall(dotf.src,
dotf.dst,
dotf.link)
if not res:
LOG.err(msg)
continue
uninstalled += 1
LOG.log(f'\n{uninstalled} dotfile(s) uninstalled.')
return True


def cmd_remove(opts):
"""remove dotfile from dotpath and from config"""
paths = opts.remove_path
Expand Down Expand Up @@ -853,6 +895,12 @@ def _exec_command(opts):
LOG.dbg(f'running cmd: {command}')
cmd_remove(opts)

elif opts.cmd_uninstall:
# uninstall dotfile
command = 'uninstall'
LOG.dbg(f'running cmd: {command}')
cmd_uninstall(opts)

except UndefinedException as exc:
LOG.err(exc)
ret = False
Expand Down
6 changes: 6 additions & 0 deletions dotdrop/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,8 @@ def _symlink(self, src, dst, actionexec=None, absolute=True):
return False, 'aborted'

# remove symlink
if self.backup and not os.path.isdir(dst):
self._backup(dst)
overwrite = True
try:
removepath(dst)
Expand Down Expand Up @@ -545,6 +547,7 @@ def _copy_file(self, templater, src, dst,
content = None
if is_template:
# template the file
self.log.dbg(f'it is a template: {src}')
saved = templater.add_tmp_vars(self._get_tmp_file_vars(src, dst))
try:
content = templater.generate(src)
Expand Down Expand Up @@ -666,6 +669,7 @@ def _write(self, src, dst, content=None,

if os.path.lexists(dst):
# file/symlink exists
self.log.dbg(f'file already exists on filesystem: {dst}')
try:
os.stat(dst)
except OSError as exc:
Expand All @@ -691,6 +695,8 @@ def _write(self, src, dst, content=None,

if self.backup:
self._backup(dst)
else:
self.log.dbg(f'file does not exist on filesystem: {dst}')

# create hierarchy
base = os.path.dirname(dst)
Expand Down
9 changes: 9 additions & 0 deletions dotdrop/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
dotdrop update [-VbfdkPz] [-c <path>] [-p <profile>]
[-w <nb>] [-i <pattern>...] [<path>...]
dotdrop remove [-Vbfdk] [-c <path>] [-p <profile>] [<path>...]
dotdrop uninstall [-Vbfd] [-c <path>] [-p <profile>] [<key>...]
dotdrop files [-VbTG] [-c <path>] [-p <profile>]
dotdrop detail [-Vb] [-c <path>] [-p <profile>] [<key>...]
dotdrop profiles [-VbG] [-c <path>]
Expand Down Expand Up @@ -340,6 +341,10 @@ def _apply_args_remove(self):
self.remove_path = self.args['<path>']
self.remove_iskey = self.args['--key']

def _apply_args_uninstall(self):
"""uninstall specifics"""
self.uninstall_key = self.args['<key>']

def _apply_args_detail(self):
"""detail specifics"""
self.detail_keys = self.args['<key>']
Expand All @@ -355,6 +360,7 @@ def _apply_args(self):
self.cmd_update = self.args['update']
self.cmd_detail = self.args['detail']
self.cmd_remove = self.args['remove']
self.cmd_uninstall = self.args['uninstall']

# adapt attributes based on arguments
self.safe = not self.args['--force']
Expand Down Expand Up @@ -403,6 +409,9 @@ def _apply_args(self):
# "remove" specifics
self._apply_args_remove()

# "uninstall" specifics
self._apply_args_uninstall()

def _fill_attr(self):
"""create attributes from conf"""
# defined variables
Expand Down
150 changes: 150 additions & 0 deletions dotdrop/uninstaller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""
author: deadc0de6 (https://github.com/deadc0de6)
Copyright (c) 2023, deadc0de6

handle the un-installation of dotfiles
"""

import os
from dotdrop.logger import Logger
from dotdrop.utils import removepath


class Uninstaller:
"""dotfile uninstaller"""

def __init__(self, base='.', workdir='~/.config/dotdrop',
dry=False, safe=True, debug=False,
backup_suffix='.dotdropbak'):
"""
@base: directory path where to search for templates
@workdir: where to install template before symlinking
@dry: just simulate
@debug: enable debug
@backup_suffix: suffix for dotfile backup file
@safe: ask for any action
"""
base = os.path.expanduser(base)
base = os.path.normpath(base)
self.base = base
workdir = os.path.expanduser(workdir)
workdir = os.path.normpath(workdir)
self.workdir = workdir
self.dry = dry
self.safe = safe
self.debug = debug
self.backup_suffix = backup_suffix
self.log = Logger(debug=self.debug)

def uninstall(self, src, dst, linktype):
"""
uninstall dst
@src: dotfile source path in dotpath
@dst: dotfile destination path in the FS
@linktype: linktypes.LinkTypes

return
- True, None : success
- False, error_msg : error
"""
if not src or not dst:
self.log.dbg(f'cannot uninstall empty {src} or {dst}')
return True, None

# ensure exists
path = os.path.expanduser(dst)
path = os.path.normpath(path)
path = path.rstrip(os.sep)

if not os.path.isfile(path) and not os.path.isdir(path):
msg = f'cannot uninstall special file {path}'
return False, msg

if not os.path.exists(path):
self.log.dbg(f'cannot uninstall non existing {path}')
return True, None

msg = f'uninstalling \"{path}\" (link: {linktype})'
self.log.dbg(msg)
ret, msg = self._remove(path)
if ret:
if not self.dry:
self.log.sub(f'uninstall {dst}')
return ret, msg

def _descend(self, dirpath):
ret = True
self.log.dbg(f'recursively uninstall {dirpath}')
for sub in os.listdir(dirpath):
subpath = os.path.join(dirpath, sub)
if os.path.isdir(subpath):
self.log.dbg(f'under {dirpath} uninstall dir {subpath}')
self._descend(subpath)
else:
self.log.dbg(f'under {dirpath} uninstall file {subpath}')
subret, _ = self._remove(subpath)
if not subret:
ret = False

if not os.listdir(dirpath):
# empty
self.log.dbg(f'remove empty dir {dirpath}')
if self.dry:
self.log.dry(f'would \"rm -r {dirpath}\"')
return True, ''
return self._remove_path(dirpath)
self.log.dbg(f'not removing non-empty dir {dirpath}')
return ret, ''

def _remove_path(self, path):
"""remove a file"""
try:
removepath(path, self.log)
except OSError as exc:
err = f'removing \"{path}\" failed: {exc}'
return False, err
return True, ''

def _remove(self, path):
"""remove path"""
self.log.dbg(f'handling uninstall of {path}')
if path.endswith(self.backup_suffix):
self.log.dbg(f'skip {path} ignored')
return True, ''
backup = f'{path}{self.backup_suffix}'
if os.path.exists(backup):
self.log.dbg(f'backup exists for {path}: {backup}')
return self._replace(path, backup)
self.log.dbg(f'no backup file for {path}')

if os.path.isdir(path):
self.log.dbg(f'{path} is a directory')
return self._descend(path)

if self.dry:
self.log.dry(f'would \"rm {path}\"')
return True, ''

msg = f'Remove {path}?'
if self.safe and not self.log.ask(msg):
return False, 'user refused'
self.log.dbg(f'removing {path}')
return self._remove_path(path)

def _replace(self, path, backup):
"""replace path by backup"""
if self.dry:
self.log.dry(f'would \"mv {backup} {path}\"')
return True, ''

msg = f'Restore {path} from {backup}?'
if self.safe and not self.log.ask(msg):
return False, 'user refused'

try:
self.log.dbg(f'mv {backup} {path}')
os.replace(backup, path)
except OSError as exc:
err = f'replacing \"{path}\" by \"{backup}\" failed: {exc}'
return False, err
return True, ''
2 changes: 2 additions & 0 deletions dotdrop/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ def removepath(path, logger=None):
return
LOG.err(err)
raise OSError(err)
if logger:
logger.dbg(f'removing {path}')
try:
if os.path.islink(path) or os.path.isfile(path):
os.unlink(path)
Expand Down
10 changes: 6 additions & 4 deletions scripts/check-syntax.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ pyflakes --version
# checking for TODO/FIXME
echo "--------------------------------------"
echo "checking for TODO/FIXME"
grep -rv 'TODO\|FIXME' dotdrop/ >/dev/null 2>&1
grep -rv 'TODO\|FIXME' tests/ >/dev/null 2>&1
grep -rv 'TODO\|FIXME' tests-ng/ >/dev/null 2>&1
grep -rv 'TODO\|FIXME' scripts/ >/dev/null 2>&1
set +e
grep -r 'TODO\|FIXME' dotdrop/ && exit 1
grep -r 'TODO\|FIXME' tests/ && exit 1
grep -r 'TODO\|FIXME' tests-ng/ && exit 1
#grep -r 'TODO\|FIXME' scripts/ && exit 1
set -e

# PEP8 tests
# W503: Line break occurred before a binary operator
Expand Down
10 changes: 9 additions & 1 deletion scripts/check_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@
302,
]
IGNORES = [
'badgen.net',
'badgen.net',
]
OK_WHEN_FORBIDDEN = [
'linux.die.net'
]
IGNORE_GENERIC = []
USER_AGENT = (
Expand Down Expand Up @@ -103,6 +106,11 @@ def check_links(urls):
# pylint: disable=W0703
except Exception:
ret = 404
if ret == 403 and hostname in OK_WHEN_FORBIDDEN:
msg = f' [{GREEN}OK-although-{ret}{RESET}]'
msg += f' {MAGENTA}{url}{RESET}'
print(msg)
continue
if ret not in VALID_RET:
msg = (
f' {YELLOW}[WARN]{RESET} HEAD {url} returned {ret}'
Expand Down
Loading