Last active
May 20, 2020 19:56
-
-
Save PeterMinin/4d564aee402cc522761acc2ac4df574d to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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