# urwid user interface for reportbug
# Written by Chris Lawrence <lawrencc@debian.org>
# (C) 2006-08 Chris Lawrence
# Copyright (C) 2008-2022 Sandro Tosi <morph@debian.org>
#
# This program is freely distributable per the following license:
#
# Permission to use, copy, modify, and distribute this software and its
# documentation for any purpose and without fee is hereby granted,
# provided that the above copyright notice appears in all copies and that
# both that copyright notice and this permission notice appear in
# supporting documentation.
#
# I DISCLAIM ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING ALL
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS, IN NO EVENT SHALL I
# BE LIABLE FOR ANY SPECIAL, 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.
#
# Portions of this file are licensed under the Lesser GNU Public License
# (LGPL) Version 2.1 or later. On Debian systems, this license is available
# in /usr/share/common-licenses/LGPL
import locale
import sys
import re
import getpass
from reportbug.exceptions import (
UINotImportable,
NoPackage, NoBugs, NoReport, QuertBTSError,
)
from reportbug.urlutils import launch_browser
from .text_ui import ( # noqa: F401
display_report,
ewrite,
spawn_editor,
system
)
from reportbug import VERSION
try:
import urwid.raw_display
import urwid
except ImportError:
raise UINotImportable('Please install the python3-urwid package to use this interface.')
ISATTY = sys.stdin.isatty()
log_message = ewrite
# Start a urwid session
def initialize_urwid_ui():
ui = urwid.raw_display.Screen()
ui.register_palette(palette)
# Improve responsiveness of UI
ui.set_input_timeouts(max_wait=0.1)
return ui
# Empty function to satisfy ui.run_wrapper()
def nullfunc():
pass
# Widgets ripped mercilessly from urwid examples (dialog.py)
class buttonpush(Exception):
pass
def button_press(button):
raise buttonpush(button.exitcode)
class SelectableText(urwid.Edit):
def valid_char(self, ch):
return False
class dialog(object):
def __init__(self, message, body=None, width=None, height=None,
title='', long_message=''):
self.body = body
self.scrollmode = False
if not body:
if long_message:
box = SelectableText(edit_text=long_message)
box.set_edit_pos(0)
self.body = body = urwid.ListBox([box])
self.scrollmode = True
else:
self.body = body = urwid.Filler(urwid.Divider(), 'top')
if not width:
width = ('relative', 80)
if not height:
height = ('relative', 80)
self.frame = urwid.Frame(body, focus_part='footer')
if message:
self.frame.header = urwid.Pile([urwid.Text(message),
urwid.Divider()])
w = self.frame
# pad area around listbox
w = urwid.Padding(w, ('fixed left', 2), ('fixed right', 2))
w = urwid.Filler(w, ('fixed top', 1), ('fixed bottom', 1))
w = urwid.AttrWrap(w, 'body')
if title:
w = urwid.Frame(w)
w.header = urwid.Text(('title', title))
# "shadow" effect
w = urwid.Columns([w, ('fixed', 1, urwid.AttrWrap(urwid.Filler(urwid.Text(('border', ' ')), "top"), 'shadow'))])
w = urwid.Frame(w, footer=urwid.AttrWrap(urwid.Text(('border', ' ')), 'shadow'))
# outermost border area
w = urwid.Padding(w, 'center', width)
w = urwid.Filler(w, 'middle', height)
w = urwid.AttrWrap(w, 'border')
self.view = w
def add_buttons(self, buttons, default=0, vertical=False):
li = []
for name, exitcode in buttons:
if exitcode == '---':
# Separator is just a text label
b = urwid.Text(name)
b = urwid.AttrWrap(b, 'scrolllabel')
else:
b = urwid.Button(name, self.button_press)
b.exitcode = exitcode
b = urwid.AttrWrap(b, 'selectable', 'focus')
li.append(b)
if vertical:
box = urwid.ListBox(li)
box.set_focus(default or 0)
self.buttons = urwid.Frame(urwid.AttrWrap(box, 'selectable'))
self.frame.footer = urwid.BoxAdapter(self.buttons, min(len(li), 10))
else:
self.buttons = urwid.GridFlow(li, 12, 3, 1, 'center')
self.buttons.set_focus(default or 0)
self.frame.footer = urwid.Pile([urwid.Divider(), self.buttons],
focus_item=1)
def button_press(self, button):
raise buttonpush(button.exitcode)
def run(self):
# self.ui.set_mouse_tracking()
size = self.ui.get_cols_rows()
try:
while True:
canvas = self.view.render(size, focus=True)
self.ui.draw_screen(size, canvas)
keys = None
while not keys:
keys = self.ui.get_input()
for k in keys:
if urwid.util.is_mouse_event(k):
event, button, col, row = k
self.view.mouse_event(size,
event, button, col, row,
focus=True)
if k == 'window resize':
size = self.ui.get_cols_rows()
k = self.view.keypress(size, k)
if k:
self.unhandled_key(size, k)
except buttonpush as e:
return self.on_exit(e.args[0])
def on_exit(self, exitcode):
return exitcode
def unhandled_key(self, size, k):
if k in ('tab', 'shift tab'):
focus = self.frame.focus_part
if focus == 'footer':
self.frame.set_focus('body')
else:
self.frame.set_focus('footer')
if k in ('up', 'page up', 'down', 'page down'):
if self.scrollmode:
self.frame.set_focus('body')
self.body.keypress(size, k)
elif k in ('up', 'page up'):
self.frame.set_focus('body')
else:
self.frame.set_focus('footer')
if k == 'enter':
# pass enter to the "ok" button
self.frame.set_focus('footer')
self.view.keypress(size, k)
def main(self, ui=None):
if ui:
self.ui = ui
else:
self.ui = initialize_urwid_ui()
return self.ui.run_wrapper(self.run)
class displaybox(dialog):
def show(self, ui=None):
if ui:
self.ui = ui
else:
self.ui = initialize_urwid_ui()
size = self.ui.get_cols_rows()
canvas = self.view.render(size, focus=True)
self.ui.start()
self.ui.draw_screen(size, canvas)
self.ui.stop()
class textentry(dialog):
def __init__(self, text, width=None, height=None, multiline=False,
title='', edit_text=''):
self.edit = urwid.Edit(edit_text=edit_text, multiline=multiline)
body = urwid.ListBox([self.edit])
body = urwid.AttrWrap(body, 'selectable', 'focustext')
if not multiline:
body = urwid.Pile([('fixed', 1, body), urwid.Divider()])
body = urwid.Filler(body)
dialog.__init__(self, text, body, width, height, title)
self.frame.set_focus('body')
def on_exit(self, exitcode):
return exitcode, self.edit.get_edit_text()
class listdialog(dialog):
def __init__(self, text, widgets, has_default=False, width=None,
height=None, title='', buttonwidth=12):
li = []
self.items = []
for (w, label) in widgets:
self.items.append(w)
if label:
w = urwid.Columns([('fixed', buttonwidth, w),
urwid.Text(label)], 2)
w = urwid.AttrWrap(w, 'selectable', 'focus')
li.append(w)
lb = urwid.ListBox(li)
lb = urwid.AttrWrap(lb, "selectable")
dialog.__init__(self, text, height=height, width=width, body=lb,
title=title)
self.frame.set_focus('body')
def on_exit(self, exitcode):
"""Print the tag of the item selected."""
if exitcode:
return exitcode, None
for i in self.items:
if hasattr(i, 'get_state') and i.get_state():
return exitcode, i.get_label()
return exitcode, None
class checklistdialog(listdialog):
def on_exit(self, exitcode):
"""
Mimic dialog(1)'s --checklist exit.
Put each checked item in double quotes with a trailing space.
"""
if exitcode:
return exitcode, []
li = []
for i in self.items:
if i.get_state():
li.append(i.get_label())
return exitcode, li
def display_message(message, *args, **kwargs):
if args:
message = message % tuple(args)
if 'title' in kwargs:
title = kwargs['title']
else:
title = ''
if 'ui' in kwargs:
ui = kwargs['ui']
else:
ui = None
# Rewrap the message
chunks = re.split('\n\n+', message)
chunks = [re.sub(r'\s+', ' ', x).strip() for x in chunks]
message = '\n\n'.join(chunks).strip()
box = displaybox('', long_message=message, title=title or VERSION)
box.show(ui)
def long_message(message, *args, **kwargs):
if args:
message = message % tuple(args)
if 'title' in kwargs:
title = kwargs['title']
else:
title = ''
if 'ui' in kwargs:
ui = kwargs['ui']
else:
ui = None
# Rewrap the message
chunks = re.split('\n+', message)
chunks = [re.sub(r'\s+', ' ', x).strip() for x in chunks]
message = '\n\n'.join(chunks).strip()
box = dialog('', long_message=message, title=title or VERSION)
box.add_buttons([("OK", 0)])
box.main(ui)
final_message = long_message
display_failure = long_message
def select_options(msg, ok, help=None, allow_numbers=False, nowrap=False,
ui=None, title=None):
loc = locale.nl_langinfo(locale.CODESET)
safe_msg = msg.encode(loc, errors='replace').decode(errors='replace')
box = dialog('', long_message=safe_msg, height=('relative', 80),
title=title or VERSION)
if not help:
help = {}
buttons = []
default = None
for i, option in enumerate(ok):
if option.isupper():
default = i
option = option.lower()
buttons.append((help.get(option, option), option))
box.add_buttons(buttons, default, vertical=True)
result = box.main(ui)
return result
def yes_no(msg, yeshelp, nohelp, default=True, nowrap=False, ui=None):
box = dialog('', long_message=msg, title=VERSION)
box.add_buttons([('Yes', True), ('No', False)], default=1 - int(default))
result = box.main(ui)
return result
def get_string(prompt, options=None, title=None, empty_ok=False, force_prompt=False,
default='', ui=None):
if title:
title = '%s: %s' % (VERSION, title)
else:
title = VERSION
box = textentry(prompt, title=title, edit_text=default)
box.add_buttons([("OK", 0)])
code, text = box.main(ui)
return text or default
def get_multiline(prompt, options=None, title=None, force_prompt=False,
ui=None):
if title:
title = '%s: %s' % (VERSION, title)
else:
title = VERSION
box = textentry(prompt, multiline=True)
box.add_buttons([("OK", 0)])
code, text = box.main(ui)
li = text.split('\n')
return li
def get_password(prompt=None):
return getpass.getpass(prompt)
def menu(par, options, prompt, default=None, title=None, any_ok=False,
order=None, extras=None, multiple=False, empty_ok=False, ui=None,
oklabel='Ok', cancellabel='Cancel', quitlabel=None):
if not extras:
extras = []
else:
extras = list(extras)
if not default:
default = ''
if title:
title = '%s: %s' % (VERSION, title)
else:
title = VERSION
if isinstance(options, dict):
options = options.copy()
# Convert to a list
if order:
olist = []
for key in order:
if key in options:
olist.append((key, options[key]))
del options[key]
# Append anything out of order
options = list(options.items())
options.sort()
for option in options:
olist.append(option)
options = olist
else:
options = list(options.items())
options.sort()
opts = []
for option, desc in options:
if desc:
opts.append((option, re.sub(r'\s+', ' ', desc)))
else:
opts.append((option, desc))
options = opts
if multiple:
widgets = [(urwid.CheckBox(option, state=(option == default)),
desc or '') for (option, desc) in options]
box = checklistdialog(par, widgets, height=('relative', 80),
title=title)
if quitlabel:
box.add_buttons([(oklabel, 0), (cancellabel, -1),
(quitlabel, -2)])
else:
box.add_buttons([(oklabel, 0), (cancellabel, -1)])
result, chosen = box.main(ui)
if result < 0:
# We return None to differentiate a Cancel/Quit from no selection, []
return None
return chosen
# Single menu option only
def label_button(option, desc):
return option
widgets = []
rlist = []
for option, desc in options:
if option == '---':
# Separator is just a text label
b = urwid.Text(desc)
b = urwid.AttrWrap(b, 'scrolllabel')
desc = ''
else:
b = urwid.RadioButton(rlist, label_button(option, desc), state=(option == default))
b.exitcode = option
b = urwid.AttrWrap(b, 'selectable', 'focus')
widgets.append((b, desc))
box = listdialog(par, widgets, height=('relative', 80),
title=title, buttonwidth=12)
if quitlabel:
box.add_buttons([(oklabel, 0), (cancellabel, -1), (quitlabel, -2)])
else:
box.add_buttons([(oklabel, 0), (cancellabel, -1)])
focus = 0
if default:
for i, opt in enumerate(options):
if opt[0] == default:
focus = i # noqa: F841
break
result, chosen = box.main(ui)
if result < 0:
return result
return chosen
# A real file dialog would be nice here
def get_filename(prompt, title=None, force_prompt=False, default=''):
return get_string(prompt, title=title, force_prompt=force_prompt,
default=default)
def select_multiple(par, options, prompt, title=None, order=None, extras=None):
return menu(par, options, prompt, title=title, order=order, extras=extras,
multiple=True, empty_ok=False)
# Things that are very UI dependent go here
def show_report(number, bts, mirrors,
http_proxy, timeout, screen=None, queryonly=False, title='',
archived='no', mbox_reader_cmd=None):
from reportbug import debbugs
ui = screen
if not ui:
ui = initialize_urwid_ui()
sysinfo = debbugs.SYSTEMS[bts]
display_message('Retrieving report #%d from %s bug tracking system...',
number, sysinfo['name'], title=title, ui=ui)
info = debbugs.get_report(number, timeout, bts, mirrors=mirrors,
http_proxy=http_proxy, archived=archived)
if not info:
long_message('Bug report #%d not found.', number, title=title, ui=ui)
return
options = dict(o='Ok', d='More details (launch browser)',
m='Submit more information', q='Quit')
valid = 'Odmq'
while 1:
(buginfo, bodies) = info
body = bodies[0]
r = select_options(body, valid, title=buginfo.subject, ui=ui, help=options)
ui = None
if not r or (r == 'o'):
break
elif r == 'q':
return -1
elif r == 'm':
return buginfo
launch_browser(debbugs.get_report_url(bts, number, archived))
return
def handle_bts_query(package, bts, timeout, mirrors=None, http_proxy="",
queryonly=False, screen=None, title="", archived='no',
source=False, version=None, mbox=False, buglist=None,
mbox_reader_cmd=None, latest_first=False):
from reportbug import debbugs
sysinfo = debbugs.SYSTEMS[bts]
root = sysinfo.get('btsroot')
if not root:
ewrite("%s bug tracking system has no web URL; bypassing query.\n",
sysinfo['name'])
return
ui = screen
if not ui:
ui = initialize_urwid_ui()
if isinstance(package, str):
pkgname = package
if source:
pkgname += ' (source)'
display_message('Querying %s bug tracking system for reports on %s',
debbugs.SYSTEMS[bts]['name'], pkgname,
ui=ui, title=title)
else:
display_message('Querying %s bug tracking system for reports %s',
debbugs.SYSTEMS[bts]['name'],
' '.join([str(x) for x in package]), ui=ui, title=title)
result = None
try:
(count, sectitle, hierarchy) = debbugs.get_reports(
package, timeout, bts, mirrors=mirrors, version=version,
http_proxy=http_proxy, archived=archived, source=source)
except Exception as e:
ui.run_wrapper(nullfunc)
errmsg = 'Unable to connect to %s BTS (error: "%s"); ' % (sysinfo['name'], repr(e))
raise QuertBTSError(errmsg)
try:
if not count:
ui.run_wrapper(nullfunc)
if hierarchy is None:
raise NoPackage
else:
raise NoBugs
else:
if count > 1:
sectitle = '%d bug reports found' % (count,)
else:
sectitle = '%d bug report found' % (count,)
buglist = []
for (t, bugs) in hierarchy:
buglist.append(('---', t))
buglist_tmp = {}
for bug in bugs:
# show if the bugs is already resolved
done = ''
if bug.pending == 'done':
done = ' [RESOLVED]'
buglist_tmp[bug.bug_num] = bug.subject + done
# append the sorted list of bugs for this severity
list(map(buglist.append, [(str(k), buglist_tmp[k]) for k in sorted(buglist_tmp, reverse=latest_first)]))
p = buglist[1][0]
# scr.popWindow()
if queryonly:
cancellabel = 'Exit'
quitlabel = None
else:
cancellabel = 'New bug'
quitlabel = 'Quit'
while True:
info = int(menu('Select a bug to read (and possibly report more information) or report a new bug:',
buglist, '', ui=ui, title=sectitle, default=p,
oklabel='Read bug',
cancellabel=cancellabel,
quitlabel=quitlabel))
ui = None
if info < 0:
if info == -1:
result = None
# -2 is the Quit response, triggers the exiting way in main
elif info == -2:
raise NoReport
else:
# uniform to return Bugreport instance
result = debbugs.get_report(info, timeout)[0]
break
else:
res = show_report(info, bts, mirrors, http_proxy,
timeout, queryonly=queryonly)
if res:
result = res
break
except NoPackage:
ui.run_wrapper(nullfunc)
long_message('No record of this package found.', title=title)
raise NoPackage
# we didn't find a report; we access Bugreport through debbugs,
# so to avoid an import of debianbts
if result and not isinstance(result, debbugs.debianbts.Bugreport):
raise NoReport
return result
palette = [
('body', 'black', 'light gray', 'standout'),
('border', 'black', 'dark blue'),
('shadow', 'white', 'black'),
('selectable', 'black', 'dark cyan'),
('focus', 'white', 'dark blue', 'bold'),
('focustext', 'light gray', 'dark blue'),
('title', 'dark red', 'light gray'),
('scrolllabel', 'white', 'dark cyan'),
]
def initialize():
return True
def can_input():
return sys.stdin.isatty()
|