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

feat: allow installation of multiple packages in one run #157

Merged
merged 15 commits into from
Oct 11, 2023
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ opi codecs
```

```
usage: opi [-h] [-v] [-n] [query ...]
usage: opi [-h] [-v] [-n] [-P] [-m] [query ...]

openSUSE Package Installer
==========================
Expand All @@ -113,25 +113,29 @@ 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
argument
nobkd marked this conversation as resolved.
Show resolved Hide resolved

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
Expand Down
155 changes: 92 additions & 63 deletions bin/opi
Original file line number Diff line number Diff line change
Expand Up @@ -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('''\
Expand All @@ -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 argument')
nobkd marked this conversation as resolved.
Show resolved Hide resolved

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()
13 changes: 11 additions & 2 deletions opi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@

REPO_DIR = '/etc/zypp/repos.d/'

##################
### Exceptions ###
##################

class NoOptionSelected(Exception):
pass

class HTTPError(Exception):
pass

###################
### System Info ###
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 6 additions & 3 deletions opi/plugins/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
import sys
from importlib import import_module
import inspect
from opi import NoOptionSelected

class BasePlugin:
main_query = ''
Expand Down Expand Up @@ -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 = ''
Expand Down
2 changes: 1 addition & 1 deletion opi/plugins/chrome.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def run(cls, query):
'google-chrome-beta',
'google-chrome-unstable',
])

nobkd marked this conversation as resolved.
Show resolved Hide resolved
opi.add_repo(
filename = 'google-chrome',
name = 'google-chrome',
Expand Down
1 change: 0 additions & 1 deletion test/05_install_from_local_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import sys
import pexpect
import subprocess

c = pexpect.spawn('./bin/opi htop', logfile=sys.stdout.buffer, echo=False)

Expand Down
41 changes: 41 additions & 0 deletions test/07_install_multiple.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/python3

import sys
import pexpect
import subprocess

c = pexpect.spawn('./bin/opi -nm zfs resilio-sync html2text yandex-disk', logfile=sys.stdout.buffer, echo=False)

# plugins are installed first
c.expect('Do you want to install resilio-sync')
c.expect('Import package signing key', timeout=10)
c.expect('Continue')
c.expect('Do you want to keep', timeout=500)

c.expect('Do you want to install yandex-disk')
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', 'yandex-disk'])
nobkd marked this conversation as resolved.
Show resolved Hide resolved
subprocess.check_call(['rpm', '-qi', 'zfs'])
subprocess.check_call(['rpm', '-qi', 'html2text'])