Skip to content

Instantly share code, notes, and snippets.

@PeterMinin
Last active May 20, 2020 19:56
Show Gist options
  • Save PeterMinin/4d564aee402cc522761acc2ac4df574d to your computer and use it in GitHub Desktop.
Save PeterMinin/4d564aee402cc522761acc2ac4df574d to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
"""
This is an installation/uninstallation utility for integrating
unpackaged software, i.e. that distributed as an archive rather than
a deb-package or an installation script, into the user's ~/.local/
directory structure, provided that the application still follows
the standard directory structure (with "bin", "share", etc.).
Installation is performed in the form of symbolic links, i.e.
the application's files remain where the user has placed them, but
links to them are placed in the respective subdirectories of ~/.local/.
This provides full integration (things like icons, .desktop files,
etc., placed in the "share" directory), while also allowing for easy
uninstallation of such applications by keeping track of all the files
that the application provides.
Copyright (C) 2019 by Peter Minin <[email protected]>
Permission to use, copy, modify, and/or distribute this software for
any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
"""
import argparse
import os
from os import path
from collections import defaultdict
# List of systemd standard directories under ~/.local/, see
# https://www.freedesktop.org/software/systemd/man/file-hierarchy.html#Home%20Directory
standard_dirs = ['bin', 'lib', 'share']
def plan_install_recursive(src_path, dst_path, app_dir_name, commands, stats):
# Each subdirectory can be installed either as a symlink to
# the source directory itself or recreated by symlinking
# its contents individually.
# The former is preferable, but it's only safe if this directory
# is application-specific, i.e. no other application will ever
# try to install anything into it. This is usually done by creating
# a directory like `include/app_name/` or even `share/app_name/`.
# So we try to detect this pattern, assuming that the source
# application directory is called something like `app_name-1.3`
# or `vendor-app_name`.
if path.isdir(src_path) and path.basename(src_path) not in app_dir_name:
# Recreate the directory file-by-file
if path.isfile(dst_path):
print('Error: {} already exists and is not a directory'.format(dst_path))
exit(1)
if not path.isdir(dst_path):
commands.append('mkdir {}'.format(dst_path))
stats['dirs_created'] += 1
files = []
dirs = []
for child in os.listdir(src_path):
if path.isfile(path.join(src_path, child)):
files.append(child)
else:
assert path.isdir(path.join(src_path, child))
dirs.append(child)
files.sort()
dirs.sort()
for child in files + dirs:
plan_install_recursive(path.join(src_path, child), path.join(dst_path, child),
app_dir_name, commands, stats)
else:
# Link to the file or directory at src_path
if path.exists(dst_path):
print('Error: {} already exists'.format(dst_path))
exit(1)
commands.append('ln -s {} {}'.format(src_path, dst_path))
stats['links_created'] += 1
def plan_remove_recursive(installed_path, app_dir, commands, stats):
if path.islink(installed_path):
src_path = os.readlink(installed_path) # type: str
if src_path.startswith(app_dir):
commands.append('rm {}'.format(installed_path))
stats['links_deleted'] += 1
return True
elif path.isdir(installed_path):
children = os.listdir(installed_path)
n_removed = 0
for child in children:
if plan_remove_recursive(path.join(installed_path, child), app_dir, commands, stats):
n_removed += 1
if n_removed > 0 and n_removed == len(children):
commands.append('rmdir {}'.format(installed_path))
stats['dirs_deleted'] += 1
return True
return False
def plan(app_dir, subdirs, destination_root, mode):
if mode == 'install':
app_dir_name = path.basename(app_dir)
any_subdir_found = False
commands = []
stats = defaultdict(lambda: 0)
for dir_name in subdirs:
src_dir = path.join(app_dir, dir_name)
if path.isdir(src_dir):
any_subdir_found = True
dst_dir = path.join(destination_root, dir_name)
if mode == 'install':
plan_install_recursive(src_dir, dst_dir, app_dir_name, commands, stats)
elif mode == 'remove':
plan_remove_recursive(dst_dir, src_dir, commands, stats)
return any_subdir_found, commands, stats
def format_plan_summary(stats):
stats = dict(stats)
links_created = stats.pop('links_created', 0)
dirs_created = stats.pop('dirs_created', 0)
links_deleted = stats.pop('links_deleted', 0)
dirs_deleted = stats.pop('dirs_deleted', 0)
assert not stats, 'Unused keys: {}'.format(', '.join(stats.keys()))
messages = []
create_messages = []
if links_created:
create_messages.append('{} symbolic links'.format(links_created))
if dirs_created:
create_messages.append('{} directories'.format(dirs_created))
if create_messages:
messages.append('create ' + ' and '.join(create_messages))
delete_messages = []
if links_deleted:
delete_messages.append('{} symbolic links'.format(links_deleted))
if dirs_deleted:
delete_messages.append('{} empty directories'.format(dirs_deleted))
if delete_messages:
messages.append('delete ' + ' and '.join(delete_messages))
summary = ', '.join(messages)
return summary
def print_plan(commands):
print()
for s in commands:
print(' ' + s)
print()
def execute_plan(commands):
for s in commands:
print(s)
os.system(s)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('mode', choices=['install', 'uninstall', 'remove'])
parser.add_argument('app_root')
parser.add_argument('--subdirs', metavar='DIR', nargs='*',
help='Subdirectories to install/remove. Default: {}.'.format(', '.join(standard_dirs)),
default=standard_dirs)
parser.add_argument('-n', '--dry_run', action='store_true', help='Only print the actions '
'without performing them')
args = parser.parse_args()
mode = args.mode
if mode == 'uninstall':
mode = 'remove'
app_dir = path.abspath(args.app_root)
subdirs = args.subdirs
dry = args.dry_run
if mode == 'install' and not path.exists(app_dir):
print('App dir not found: {}'.format(app_dir))
exit(1)
destination_root = path.join(os.environ['HOME'], '.local')
any_subdir_found, plan_commands, plan_stats = plan(app_dir, subdirs, destination_root, mode)
if not any_subdir_found:
print('No matching subdirectories found in {} (expected: {}).'.format(
app_dir, ', '.join(subdirs)))
exit(1)
if not plan_commands:
print('Nothing to do')
exit(1)
if dry:
print_plan(plan_commands)
print('Total: ' + format_plan_summary(plan_stats))
else:
print('Going to ' + format_plan_summary(plan_stats))
while True:
try:
user_input = input('Proceed? y(es) / n(o) / s(how): ')
except EOFError:
print()
return
user_input = user_input.lower()
if user_input in ['y', 'yes']:
print()
execute_plan(plan_commands)
print()
print('Done')
return
elif user_input in ['n', 'no']:
return
elif user_input in ['s', 'show']:
print_plan(plan_commands)
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment