diff --git a/README.md b/README.md index 6316dcd..5b01375 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ opi codecs ``` ``` -usage: opi [-h] [-v] [-n] [query ...] +usage: opi [-h] [-v] [-n] [-P] [-m] [query ...] openSUSE Package Installer ========================== @@ -113,25 +113,28 @@ positional arguments: options: -h, --help show this help message and exit -v, --version show program's version number and exit - -n Run in non interactive mode + -n run in non interactive mode + -P don't run any plugins - only search repos, OBS and Packman + -m run installation process individually for each query arg -Also these queries can be used to install packages from various other vendors: +Also these queries (provided by plugins) can be used to install packages from various other vendors: anydesk AnyDesk remote access atom Atom Text Editor brave Brave web browser chrome Google Chrome web browser codecs Media Codecs from Packman and official repo - dotnet Microsoft .NET + dotnet Microsoft .NET framework jami Jami p2p messenger maptool Virtual Tabletop for playing roleplaying games megasync Mega Desktop App - msedge Microsoft Edge + msedge Microsoft Edge web browser + ocenaudio Audio Editor plex Plex Media Server - resilio-sync Resilio Sync decentralized file synchronization between devices using the bittorrent protocol + resilio-sync Decentralized file synchronization between devices using bittorrent protocol skype Microsoft Skype slack Slack messenger sublime Editor for code, markup and prose - teams-for-linux unofficial Microsoft Teams for Linux + teams-for-linux Unofficial Microsoft Teams for Linux client teamviewer TeamViewer remote access vivaldi Vivaldi web browser vscode Microsoft Visual Studio Code diff --git a/bin/opi b/bin/opi index ef15580..13ede8b 100755 --- a/bin/opi +++ b/bin/opi @@ -34,8 +34,7 @@ class PreserveWhiteSpaceWrapRawTextHelpFormatter(argparse.RawTextHelpFormatter): return [item for sublist in textRows for item in sublist] -try: - pm = PluginManager() +def setup_argparser(plugin_manager): ap = argparse.ArgumentParser( formatter_class=PreserveWhiteSpaceWrapRawTextHelpFormatter, description=textwrap.dedent('''\ @@ -50,72 +49,102 @@ try: '''), epilog=textwrap.dedent('''\ Also these queries (provided by plugins) can be used to install packages from various other vendors: - ''') + pm.get_plugin_string(' ' * 2)) + ''') + plugin_manager.get_plugin_string(' ' * 2)) ap.add_argument('query', nargs='*', type=str, help=textwrap.dedent('''\ can be any package name or part of it and will be searched for both at the openSUSE Build Service and Packman. If multiple query arguments are provided only results matching all of them are returned. ''')) ap.add_argument('-v', '--version', action='version', version=f'opi version {__version__}') - ap.add_argument('-n', dest='non_interactive', action='store_true', help="Run in non interactive mode") - ap.add_argument('-P', dest='no_plugins', action='store_true', help="Don't run any plugins - only search repos, OBS and Packman") - - args = ap.parse_args() - - if not args.query: - ap.print_help() - sys.exit() - - if args.non_interactive: - global_state.arg_non_interactive = True - if subprocess.run(['sudo', '-n', 'true']).returncode != 0: - print('Error: In non-interactive mode this command must be run as root') - print(' or sudo must not require interaction.') - sys.exit(1) - - if not args.no_plugins: - # Try to find a matching plugin for the query (and run it and exit afterwards) - pm.run(args.query[0]) - - binaries = [] - binaries.extend(opi.search_published_binary('openSUSE', args.query)) - binaries.extend(opi.search_published_binary('Packman', args.query)) - binaries = opi.sort_uniq_binaries(binaries) - if len(binaries) == 0: - print('No package found.') - sys.exit() - - # Print and select a package name option - binary_names = opi.get_binary_names(binaries) - selected_name = opi.ask_for_option(binary_names) - print('You have selected package name:', selected_name) - - # Inject binaries from local repos - binaries = opi.search_local_repos(selected_name) + binaries - - binary_options = opi.get_binaries_by_name(selected_name, binaries) - - # Print and select a binary package option - selected_binary = opi.ask_for_option(binary_options, option_filter=opi.format_binary_option, disable_pager=True) - print('You have selected binary package:', opi.format_binary_option(selected_binary, table=False)) - if opi.is_personal_project(selected_binary['project']): - print(colored( - 'BE CAREFUL! The package is from a personal repository and NOT reviewed by others.\n' - 'You can ask the author to submit the package to development projects and openSUSE:Factory.\n' - 'Learn more at https://en.opensuse.org/openSUSE:How_to_contribute_to_Factory', - 'red' - )) - elif selected_binary['project'] == 'openSUSE:Factory': - print(opi.colored( - 'BE CAREFUL! You are about to add the Factory Repository.\n' - 'This repo contains the unreleased Tumbleweed distro before openQA tests have been run.\n' - 'Only proceed if you know what you are doing!', - 'yellow' - )) - if not opi.ask_yes_or_no('Do you want to continue?', default_answer='n'): + ap.add_argument('-n', dest='non_interactive', action='store_true', help='run in non interactive mode') + ap.add_argument('-P', dest='no_plugins', action='store_true', help="don't run any plugins - only search repos, OBS and Packman") + ap.add_argument('-m', dest='multi_package', action='store_true', help='run installation process individually for each query arg') + + return ap + + +def repo_query(query): + try: + print(f'Searching repos for: {(" ".join(query) if isinstance(query, list) else query)}') + + binaries = [] + binaries.extend(opi.search_published_binary('openSUSE', query)) + binaries.extend(opi.search_published_binary('Packman', query)) + binaries = opi.sort_uniq_binaries(binaries) + if len(binaries) == 0: + print('No package found.') + return + + # Print and select a package name option + binary_names = opi.get_binary_names(binaries) + selected_name = opi.ask_for_option(binary_names) + print('You have selected package name:', selected_name) + + # Inject binaries from local repos + binaries = opi.search_local_repos(selected_name) + binaries + + binary_options = opi.get_binaries_by_name(selected_name, binaries) + + # Print and select a binary package option + selected_binary = opi.ask_for_option(binary_options, option_filter=opi.format_binary_option, disable_pager=True) + print('You have selected binary package:', opi.format_binary_option(selected_binary, table=False)) + if opi.is_personal_project(selected_binary['project']): + print(colored( + 'BE CAREFUL! The package is from a personal repository and NOT reviewed by others.\n' + 'You can ask the author to submit the package to development projects and openSUSE:Factory.\n' + 'Learn more at https://en.opensuse.org/openSUSE:How_to_contribute_to_Factory', + 'red' + )) + elif selected_binary['project'] == 'openSUSE:Factory': + print(opi.colored( + 'BE CAREFUL! You are about to add the Factory Repository.\n' + 'This repo contains the unreleased Tumbleweed distro before openQA tests have been run.\n' + 'Only proceed if you know what you are doing!', + 'yellow' + )) + if not opi.ask_yes_or_no('Do you want to continue?', default_answer='n'): + return + + # Install selected package + opi.install_binary(selected_binary) + except (opi.NoOptionSelected, opi.HTTPError): + return + + +if __name__ == '__main__': + try: + pm = PluginManager() + ap = setup_argparser(pm) + args = ap.parse_args() + + if not args.query: + ap.print_help() sys.exit() - # Install selected package - opi.install_binary(selected_binary) -except KeyboardInterrupt: - print() + if args.non_interactive: + global_state.arg_non_interactive = True + if subprocess.run(['sudo', '-n', 'true']).returncode != 0: + print('Error: In non-interactive mode this command must be run as root') + print(' or sudo must not require interaction.') + sys.exit(1) + + # Search plugins + if not args.no_plugins: + # Iterate over queries, copy list as modifying it from within the loop + for query in list(args.query): + # Try to find a matching plugin for the query (and run it); runs just first query if not in multi_package mode + if pm.run(query): + # After plugin successfully ran, remove from queries to not try again in repo search + args.query.remove(query) + if not args.multi_package: + sys.exit() + + # Search repos + if not args.multi_package: + repo_query(args.query) + else: + for query in args.query: + repo_query(query) + + except KeyboardInterrupt: + print() diff --git a/opi/__init__.py b/opi/__init__.py index eaa2e2c..c34ebc6 100644 --- a/opi/__init__.py +++ b/opi/__init__.py @@ -26,6 +26,15 @@ REPO_DIR = '/etc/zypp/repos.d/' +################## +### Exceptions ### +################## + +class NoOptionSelected(Exception): + pass + +class HTTPError(Exception): + pass ################### ### System Info ### @@ -390,7 +399,7 @@ def search_published_binary(obs_instance, query): print('Please use different search keywords. Some short keywords cause OBS timeout.') else: print('HTTPError:', e) - sys.exit(1) + raise HTTPError() def get_binary_names(binaries): names = [] @@ -562,7 +571,7 @@ def ask_for_option(options, question='Pick a number (0 to quit):', option_filter input_string = input_string.strip() or '0' num = int(input_string) if input_string.isdecimal() else -1 if num == 0: - sys.exit() + raise NoOptionSelected() elif not (num >= 1 and num <= len(options)): return ask_for_option(options, question, option_filter, disable_pager) else: diff --git a/opi/plugins/__init__.py b/opi/plugins/__init__.py index ef8e262..86ccdda 100644 --- a/opi/plugins/__init__.py +++ b/opi/plugins/__init__.py @@ -1,7 +1,7 @@ import os -import sys from importlib import import_module import inspect +from opi import NoOptionSelected class BasePlugin: main_query = '' @@ -32,8 +32,11 @@ def run(self, query): query = query.lower() for plugin in self.plugins: if plugin.matches(query): - plugin.run(query) - sys.exit() + try: + plugin.run(query) + except NoOptionSelected: + pass + return True def get_plugin_string(self, indent=''): plugins = '' diff --git a/opi/plugins/chrome.py b/opi/plugins/chrome.py index 16d8914..eb90d16 100644 --- a/opi/plugins/chrome.py +++ b/opi/plugins/chrome.py @@ -18,7 +18,6 @@ def run(cls, query): 'google-chrome-beta', 'google-chrome-unstable', ]) - opi.add_repo( filename = 'google-chrome', name = 'google-chrome', diff --git a/test/05_install_from_local_repo.py b/test/05_install_from_local_repo.py index f2afb49..720f356 100755 --- a/test/05_install_from_local_repo.py +++ b/test/05_install_from_local_repo.py @@ -2,7 +2,6 @@ import sys import pexpect -import subprocess c = pexpect.spawn('./bin/opi htop', logfile=sys.stdout.buffer, echo=False) diff --git a/test/07_install_multiple.py b/test/07_install_multiple.py new file mode 100755 index 0000000..7dd91cf --- /dev/null +++ b/test/07_install_multiple.py @@ -0,0 +1,35 @@ +#!/usr/bin/python3 + +import sys +import pexpect +import subprocess + +c = pexpect.spawn('./bin/opi -nm zfs resilio-sync html2text', logfile=sys.stdout.buffer, echo=False) + +# plugins are installed first +c.expect('Do you want to install') +c.expect('Import package signing key', timeout=10) +c.expect('Continue') +c.expect('Do you want to keep', timeout=500) + +# packages come after plugins +c.expect(r'([0-9]+)\. zfs', timeout=10) +c.expect('Pick a number') +c.expect(r'([0-9]+)\. [^ ]*(filesystems)', timeout=10) +c.expect('Adding repo \'filesystems\'', timeout=10) +c.expect('Continue?', timeout=20) + +c.expect(r'([0-9]+)\. html2text', timeout=10) +c.expect('Pick a number') +c.expect(r'([0-9]+)\. [^ ]*(openSUSE-Tumbleweed-Oss|Main Repository)', timeout=10) +c.expect('Installing from existing repo', timeout=10) +c.expect('Continue?', timeout=20) + +c.interact() +c.wait() +c.close() +print() +assert c.exitstatus == 0, f'Exit code: {c.exitstatus}' +subprocess.check_call(['rpm', '-qi', 'resilio-sync']) +subprocess.check_call(['rpm', '-qi', 'zfs']) +subprocess.check_call(['rpm', '-qi', 'html2text'])