#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
The graphical interface for submitting SEAMM jobs.
A job in SEAMM is composed of a flowchart and any other files that the
flowchart requires. This module provides the TkJobHandler class, which
provides a use interface and the machinery to gather the necessary
files and submit the job to a dashboard.
"""
import configparser
import logging
from pathlib import Path
import pkg_resources
import shlex
import tkinter as tk
from tkinter import messagebox
from tkinter import simpledialog
import tkinter.ttk as ttk
import Pmw
from .dashboard_handler import DashboardHandler
from seamm_dashboard_client import DashboardConnectionError, DashboardLoginError
import seamm_widgets as sw
logger = logging.getLogger(__name__)
[docs]
class TkJobHandler(object):
def __init__(self, root=None):
"""Setup the Job Handler object.
Parameters
----------
root : Tk window
The root Tk window.
"""
self._root = root
self.config = configparser.ConfigParser()
self.dialog = None
self._dashboard_handler = None
self._flowchart = None
self._widgets = {}
self._variable_value = {}
self._tk_var = {} # For checkbuttons
self.resource_path = Path(pkg_resources.resource_filename(__name__, "data/"))
s = ttk.Style()
s.configure("Border.TLabel", relief="ridge", anchor=tk.W, padding=5)
# Provide dict like access to the widgets to make
# the code cleaner
def __getitem__(self, key):
"""Allow [] access to the widgets."""
return self._widgets[key]
def __setitem__(self, key, value):
"""Allow [key] access to set a widgets."""
self._widgets[key] = value
def __delitem__(self, key):
"""Allow deletion of widgets."""
try:
if key in self._widgets:
self._widgets[key].destroy()
except Exception:
pass
del self._widgets[key]
def __iter__(self):
"""Allow iteration over the widgets"""
return iter(self._widgets)
def __len__(self):
"""Provide the nmber of widgets, for e.g. len() command."""
return len(self._widgets)
@property
def current_dashboard(self):
"The current dashboard, from dashboard_handler"
return self.dashboard_handler.current_dashboard
@current_dashboard.setter
def current_dashboard(self, dashboard):
self.current_dashboard.current_dashboard = dashboard
@property
def dashboard_handler(self):
"The connection to the dashboards."
if self._dashboard_handler is None:
self._dashboard_handler = DashboardHandler()
return self._dashboard_handler
[docs]
def add_dashboard_cb(self):
"""Post a dialog for adding a dashboard to the list."""
dialog = Pmw.Dialog(
self._root,
buttons=("OK", "Cancel"),
master=self._root,
title="Add Dashboard to list",
command=self.handle_add_dialog,
)
dialog.withdraw()
w = self["add"] = {"dialog": dialog}
d = dialog.interior()
name = sw.LabeledEntry(d, labeltext="Name", width=50)
url = sw.LabeledEntry(d, labeltext="URL")
protocol = sw.LabeledCombobox(
d, labeltext="Protocol", values=["http", "sshtunnel"]
)
protocol.set("http")
w["name"] = name
w["url"] = url
w["protocol"] = protocol
name.grid(row=0, column=0, sticky=tk.EW)
url.grid(row=1, column=0, sticky=tk.EW)
protocol.grid(row=2, column=0, sticky=tk.W)
sw.align_labels([name, url, protocol])
dialog.activate(geometry="centerscreenfirst")
[docs]
def ask_for_credentials(self, dashboard, user=None, password=None):
"""Prompt the user for the login for the dashboard
Parameters
----------
dashboard : str
The name of the dashboard.
user : str
The username for that dashboard.
password : str
The password for the user.
Returns
-------
(str, str)
A tuple with the username and password.
"""
dialog = Pmw.Dialog(
self._root,
buttons=("OK", "Cancel"),
master=self._root,
title=f"Log-in for {dashboard}",
)
dialog.withdraw()
d = dialog.interior()
w_user = sw.LabeledEntry(d, labeltext="Username:", width=50)
w_password = sw.LabeledEntry(d, labeltext="Password:", show="*")
w_user.grid(row=0, column=0, sticky=tk.EW)
w_password.grid(row=1, column=0, sticky=tk.EW)
sw.align_labels([w_user, w_password], sticky=tk.E)
result = dialog.activate(geometry="centerscreenfirst")
if result == "OK":
user = w_user.get()
password = w_password.get()
dialog.destroy()
return user, password
[docs]
def check_status_cb(self):
"""Helper for checking the status of a dashboard."""
w = self["edit dashboard"]
dashboard = w["dashboard"]
status = self.dashboard_handler.get_dashboard(dashboard).status()
w["status"].set(status)
[docs]
def create_submit_dialog(self, title="", description=""):
"""Create the dialog for submitting a job.
Parameters
----------
flowchart : seamm.Flowchart
The flowchart object
"""
logger.debug("Creating submit dialog")
self.dialog = Pmw.Dialog(
self._root,
buttons=("OK", "Cancel"),
master=self._root,
title="Submit job to SEAMM",
command=self.handle_dialog,
)
self.dialog.withdraw()
d = self.dialog.interior()
# Dashboard
dashboards = self.dashboard_handler.dashboards
self["dashboard"] = sw.LabeledCombobox(
d, labeltext="Dashboard:", values=dashboards
)
self["dashboard"].combobox.bind("<<ComboboxSelected>>", self.dashboard_cb)
self["add"] = ttk.Button(
d, text="add dashboard...", command=self.add_dashboard_cb
)
# User and project
self["project"] = sw.LabeledCombobox(d, labeltext="Project:", state="readonly")
self["project"].bind("<<ComboboxSelected>>", self.project_cb)
# Title
self["title"] = sw.LabeledEntry(d, labeltext="Title:", width=100)
self["title"].set(title)
# Description
self["description label"] = ttk.Label(d, text="Description:")
frame = sw.ScrolledFrame(
d, scroll_vertically=True, borderwidth=2, relief=tk.SUNKEN
)
f = frame.interior()
self["description"] = tk.Text(f)
self["description"].grid(sticky=tk.EW)
self["description"].insert("1.0", description)
f.rowconfigure(0, weight=1)
f.columnconfigure(0, weight=1)
# Reset and clear buttons
rf = self["reset frame"] = tk.Frame(d)
self["reset title"] = ttk.Button(
rf, text="reset title", command=self.reset_title
)
self["clear title"] = ttk.Button(
rf, text="clear title", command=self.clear_title
)
self["reset description"] = ttk.Button(
rf, text="reset description", command=self.reset_description
)
self["clear description"] = ttk.Button(
rf, text="clear description", command=self.clear_description
)
self["reset title"].grid(row=0, column=0)
self["clear title"].grid(row=0, column=1)
self["reset description"].grid(row=0, column=2)
self["clear description"].grid(row=0, column=3)
# Space for any parameters
self["parameters label"] = ttk.Label(d, text="Parameters:")
self["parameters"] = sw.ScrolledColumns(
d,
columns=[
"Name",
"Value",
"",
"Description",
],
)
# Set up the dashboard and projects if needed
if len(dashboards) > 0:
self["dashboard"].set(self.current_dashboard.name)
self.dashboard_cb()
# Grid the widgets into rows and columns
self["dashboard"].grid(row=0, column=0, sticky=tk.EW)
self["add"].grid(row=0, column=1, sticky=tk.W)
self["project"].grid(row=1, column=0, sticky=tk.EW)
self["title"].grid(row=2, column=0, columnspan=2, sticky=tk.W)
self["description label"].grid(row=3, column=0, columnspan=2, sticky=tk.W)
frame.grid(row=4, column=0, columnspan=2, sticky=tk.NSEW)
self["reset frame"].grid(row=5, column=0, columnspan=2)
self["parameters label"].grid(row=6, column=0, columnspan=2, sticky=tk.W)
self["parameters"].grid(row=7, column=0, columnspan=2, sticky=tk.NSEW)
sw.align_labels([self["dashboard"], self["project"], self["title"]])
d.rowconfigure(4, weight=1)
d.rowconfigure(7, weight=1)
d.columnconfigure(1, weight=1)
[docs]
def dashboard_cb(self, event=None):
"""The selected dashboard has been changed"""
dashboard = self["dashboard"].get()
# Ensure that user has account on dashboard
user, passwd = self.dashboard_handler.get_credentials(
dashboard, ask=self.ask_for_credentials
)
if user is None or passwd is None:
# Unable to log in, so go back
if self.current_dashboard is not None:
dashboard = self.current_dashboard.name
self["dashboard"].set(dashboard)
return
try:
projects = self.dashboard_handler.get_dashboard(dashboard).list_projects()
except DashboardLoginError as e:
msg = e.args[1]
messagebox.showwarning("Unable to login", msg)
return
except DashboardConnectionError as e:
msg = e.args[1]
messagebox.showwarning("Unable to reach the dashboard", msg)
return
if len(projects) == 0:
if self.current_dashboard is not None:
self["dashboard"].set(self.current_dashboard.name)
return
# All OK, changed the widgets
projects.append("-- Create new project --")
self["project"].combobox.config({"value": projects})
if len(projects) > 0:
self["project"].set(projects[0])
else:
self["project"].set("")
self.current_dashboard = dashboard
[docs]
def display_dashboards(self):
"""Display a list of all the dashboards with their status.
Allow users to edit, remove and add dashboards.
"""
# statuses = self.get_all_status(master=self._root)
dialog = Pmw.Dialog(
self._root,
buttons=("OK", "Edit", "Remove", "Cancel"),
master=self._root,
title="Dashboards",
command=self.handle_dashboard_dialog,
)
dialog.withdraw()
w = self["display"] = {"dialog": dialog}
d = dialog.interior()
w["table"] = sw.ScrolledColumns(
d,
columns=[
"Select",
"Dashboard",
"URL",
"Status",
],
header_style="Border.TLabel",
)
w["table"].grid(row=0, column=0, sticky=tk.NSEW)
d.columnconfigure(0, weight=1)
d.rowconfigure(0, weight=1)
# Button to update all status
w["update all"] = ttk.Button(
d, text="Update Status of All Dashboards", command=self.fill_statuses
)
w["update all"].grid()
# Fill in the dashboards
table = w["table"]
f = table.interior()
row = 0
w["selected"] = tk.StringVar()
if self.current_dashboard is not None:
w["selected"].set(self.current_dashboard.name)
for dashboard in self.dashboard_handler.dashboards:
table[row, 0] = ttk.Radiobutton(
f,
variable=w["selected"],
value=dashboard,
)
table[row, 1] = ttk.Label(f, text=dashboard, style="Border.TLabel")
table[row, 2] = ttk.Label(
f, text=self.config[dashboard]["url"], style="Border.TLabel"
)
state = self.config.get(dashboard, "state", fallback="active")
if state == "active":
table[row, 3] = ttk.Label(f, width=16, style="Border.TLabel")
else:
table[row, 3] = ttk.Label(
f, text=state, width=16, style="Border.TLabel"
)
table[row, 0].grid(sticky=tk.EW)
table[row, 1].grid(sticky=tk.EW)
table[row, 2].grid(sticky=tk.EW)
table[row, 3].grid(sticky=tk.EW)
row += 1
self.fit_dialog(dialog)
dialog.activate(geometry="centerscreenfirst")
dialog.destroy()
del self["display"]
[docs]
def edit_cb(self, dashboard):
"""Edit the information for a dashboard."""
table = self["display"]["table"]
for trow in range(table.nrows):
if table[trow, 1].cget("text") == dashboard:
break
dialog = Pmw.Dialog(
self._root,
buttons=("OK", "Cancel"),
title="Edit " + dashboard.title(),
)
dialog.withdraw()
w = self["edit dashboard"] = {"dialog": dialog, "dashboard": dashboard}
d = dialog.interior()
name = sw.LabeledEntry(d, labeltext="Name:", width=50)
name.set(table[trow, 1].cget("text"))
url = sw.LabeledEntry(d, labeltext="URL:", width=50)
url.set(table[trow, 2].cget("text"))
state = sw.LabeledCombobox(d, labeltext="State:", values=["active", "inactive"])
current_status = table[trow, 3].cget("text")
if current_status == "inactive":
state.set("inactive")
else:
state.set("active")
status = sw.LabeledEntry(d, labeltext="Status:", width=16)
status.set(current_status)
check_status = ttk.Button(d, text="Check Status", command=self.check_status_cb)
w["status"] = status
name.grid(row=0, columnspan=2, sticky=tk.EW)
url.grid(row=1, columnspan=2, sticky=tk.EW)
state.grid(row=2, columnspan=2, sticky=tk.EW)
status.grid(row=3, sticky=tk.EW)
check_status.grid(row=3, column=1)
widgets = [name, url, state, status]
# If there are any other items in the config file, put them in
row = 3
for key, value in self.config.items(dashboard):
if key not in ("url", "state", "status"):
row += 1
w[key] = sw.LabeledEntry(d, labeltext=key + ":")
w[key].set(value)
w[key].grid(row=row, columnspan=2, sticky=tk.EW)
widgets.append(w[key])
sw.align_labels(widgets)
result = dialog.activate(geometry="centerscreenfirst")
if result == "OK":
if name.get() != dashboard:
self.dashboard_handler.rename_dashboard(dashboard, name.get())
# Changed the name. Move the section in the config file
dashboard = name.get()
table[trow, 1].configure(text=dashboard)
table[trow, 0].configure(value=dashboard)
self["display"]["selected"].set(dashboard)
self.current_dashboard = dashboard
dboard = self.dashboard_handler.get_dashboard(dashboard)
table[trow, 2].configure(text=url.get())
dboard["url"] = url.get()
if state.get() == "active":
if status.get() == "inactive":
table[trow, 3].configure(text="")
else:
table[trow, 3].configure(text=status.get())
else:
table[trow, 3].configure(text=state.get())
dboard["state"] = state.get()
for key, value in self.config.items(dashboard):
if key not in ("url", "state", "status"):
dboard[key] = w[key].get()
self.dashboard_handler.update(dashboard)
[docs]
def file_cb(self, table, row, name, data):
"""Method to handle parameters with files
Parameters
----------
table : sw.ScrolledColumns
The widget displaying the table of parameters.
row : int
The row of the table.
name : str
The name of the parameter.
data : dict(str, str)
The definition of the parameter.
"""
multiple = data["nargs"] != "a single value"
filetypes = [
("MOL", "*.mol"),
("MOL", "*.mol2"),
("SDF", "*.sdf"),
("XYZ", "*.xyz"),
("CIF", "*.cif"),
("MMCIF", "*.mmcif"),
("All files", "*"),
]
filename = tk.filedialog.askopenfilename(filetypes=filetypes, multiple=multiple)
if filename == "":
return
w = table[row, 1]
if multiple:
current = shlex.split(w.get())
for name in filename:
if name not in current:
current.append(name)
w.delete(0, tk.END)
w.insert(0, " " + shlex.join(current))
else:
w.delete(0, tk.END)
w.insert(0, filename)
[docs]
def fill_statuses(self):
w = self["display"]
dialog = w["dialog"]
table = w["table"]
dialog.configure(title="Dashboards -- Updating Status")
progress = ttk.Progressbar(
dialog.interior(),
orient=tk.HORIZONTAL,
maximum=table.nrows + 1,
mode="determinate",
value=1,
)
progress.grid(sticky=tk.EW)
for row in range(table.nrows):
current_status = table[row, 3].cget("text")
if current_status != "inactive":
table[row, 3].configure(text="...")
table.update()
dashboard = table[row, 1].cget("text")
status = self.dashboard_handler.get_dashboard(dashboard).status()
table[row, 3].configure(text=status)
progress.step()
table.update()
progress.destroy()
dialog.configure(title="Dashboards")
[docs]
def fit_dialog(self, dialog):
"""Resize and fit the dialog to the current contents and the
constraint of the window.
"""
logger.debug("Entering fit_dialog")
widget = self["display"]["table"].interior()
logger.debug(" widget = {}".format(widget))
widget.update_idletasks()
frame = dialog.interior()
frame.update_idletasks()
width = frame.winfo_width()
height = frame.winfo_height()
sw = frame.winfo_screenwidth()
sh = frame.winfo_screenheight()
logger.debug(
" frame wxh = {} x {}, screen = {} x {}".format(width, height, sw, sh)
)
mw = frame.winfo_reqwidth()
mh = frame.winfo_reqheight()
logger.debug(" frame requested = {} x {}".format(mw, mh))
# Need to handle scrolledtable using its inside frame
ww = widget.winfo_width()
hh = widget.winfo_height()
w = widget.winfo_reqwidth()
h = widget.winfo_reqheight()
logger.debug(" table wxh = {} x {}, requested = {} x {}".format(ww, hh, w, h))
h += 20 # Add a bit of space...
if w > mw:
mw = w
if h > mh:
mh = h
if ww > width:
width = ww
if hh > height:
height = hh
if width < mw:
width = mw
width += 70
if width > 0.9 * sw:
width = int(0.9 * sw)
if height < mh:
height = mh
height += 70
if height > 0.9 * sh:
height = int(0.9 * sh)
dialog.geometry("{}x{}".format(width, height))
[docs]
def get_all_status(self, show_progress=True, master=None):
"""Get the status of all the dashboards.
Parameters
----------
show_progress : Boolean, optional
Show a dialog with progress, default is True
"""
dashboards = self.dashboard_handler.dashboards
if show_progress:
dialog = tk.Toplevel(master=master)
dialog.focus_set() # set focus on the ProgressWindow
dialog.grab_set() # make a modal window
dialog.transient(master) # show only one window in the task bar
dialog.title("Getting Status of Dashboards")
dialog.resizable(False, False) # window is not resizable
# dialog.close gets fired when the window is destroyed
# dialog.protocol(u'WM_DELETE_WINDOW', dialog.close)
dialog.geometry("400x200+200+200")
# cancel progress when <Escape> key is pressed
# dialog.bind(u'<Escape>', self.close)
progress = ttk.Progressbar(
dialog,
orient=tk.HORIZONTAL,
length=len(dashboards) + 1,
maximum=len(dashboards),
mode="determinate",
value=1,
)
progress.grid(ipady=20, sticky=tk.NSEW)
label = ttk.Label(dialog, text="Dashboard")
label.grid()
dialog.rowconfigure(0, minsize=30)
dialog.columnconfigure(0, weight=1)
dialog.update_idletasks()
dialog.after(50)
result = []
for dashboard in dashboards:
if show_progress:
label.configure({"text": dashboard})
dialog.update_idletasks()
dialog.after(50)
status = self.status(dashboard)
result.append((dashboard, status))
if show_progress:
progress.step()
label.configure({"text": dashboard})
dialog.update_idletasks()
dialog.after(50)
if show_progress:
master.focus_set()
dialog.destroy()
return result
[docs]
def handle_add_dialog(self, result):
"""Handle the dialog to add a dashboard to the list."""
save_dashboard = self.current_dashboard.name
w = self["add"]
dialog = w["dialog"]
if result is None or result == "Cancel":
dialog.deactivate(result)
else:
name = w["name"].get()
url = w["url"].get()
protocol = w["protocol"].get()
if name in self.config:
messagebox.showwarning(
"Duplicate name",
(
"There is already a dashboard called '{}'\n"
"Use a different name."
).format(name),
)
return
dialog.deactivate(result)
# Now add to the configuration
self.dashboard_handler.add_dashboard(name, url, protocol)
# And reset the list in the dashboard combobox
c = self["dashboard"]
c.combobox.config({"value": self.dashboard_handler.dashboards})
c.set(name)
# check have credentials
user, passwd = self.dashboard_handler.get_credentials(
name, ask=self.ask_for_credentials
)
if user is None or passwd is None:
# Unable to log in, so go back
self["dashboard"].set(save_dashboard)
dialog.destroy()
del self["add"]
[docs]
def handle_dialog(self, result):
"""Handle the submit dialog being completed."""
if result is None or result == "Cancel":
self.dialog.deactivate(None)
else:
w = self
self.dialog.deactivate(
{
"project": w["project"].get(),
"title": w["title"].get(),
"dashboard": w["dashboard"].get(),
"description": w["description"].get(1.0, tk.END).strip("\n"),
}
)
[docs]
def handle_dashboard_dialog(self, result):
"""Handle the dialog to add a dashboard to the list."""
w = self["display"]
dialog = w["dialog"]
dashboard = w["selected"].get()
if result is None or result == "Cancel":
dialog.deactivate(None)
elif result == "Edit":
self.edit_cb(dashboard)
elif result == "Remove":
self.remove_cb(dashboard)
else:
dialog.deactivate(None)
[docs]
def project_cb(self, event=None):
"""Handle a change in the project since it might be asking for adding a project
in which case prompt for the new project's name, create it, and sleect it in the
widget.
"""
w = self
project = w["project"].get()
if project == "-- Create new project --":
result = simpledialog.askstring("Add Project", "Project name:")
if result is None or result == "":
return
# Add the project
self.current_dashboard.add_project(result)
self.dashboard_cb()
w["project"].set(result)
[docs]
def reset_title(self):
self["title"].set(self._flowchart.metadata["title"])
[docs]
def clear_title(self):
self["title"].set("")
[docs]
def reset_description(self):
self["description"].delete("1.0", "end")
self["description"].insert("1.0", self._flowchart.metadata["description"])
[docs]
def clear_description(self):
self["description"].delete("1.0", "end")
[docs]
def submit_with_dialog(self, flowchart):
"""
Allow the user to choose the dashboard and other parameters,
and submit the job as requested.
Parameters
----------
flowchart : seamm.Flowchart
The flowchart to use.
Returns
-------
job_id : integer
The id of the submitted job.
"""
self._flowchart = flowchart
if self.dialog is None:
title = flowchart.metadata["title"]
description = flowchart.metadata["description"]
self.create_submit_dialog(title=title, description=description)
value = self._variable_value
# Find any Parameter steps.
parameter_steps = []
step = flowchart.get_node("1")
while step:
if step.step_type == "control-parameters-step":
parameter_steps.append(step)
step = step.next()
if len(parameter_steps) == 0:
# Remove the parameter section
self["parameters label"].grid_forget()
self["parameters"].grid_forget()
d = self.dialog.interior()
d.rowconfigure(6, weight=0)
else:
self["parameters label"].grid(row=6, column=0, columnspan=2, sticky=tk.W)
table = self["parameters"]
table.clear()
table.grid(row=7, column=0, columnspan=2, sticky=tk.NSEW)
frame = table.interior()
d = self.dialog.interior()
d.rowconfigure(7, weight=1)
row = 0
for step in parameter_steps:
variables = {**step.parameters["variables"].value}
for name, data in variables.items():
table[row, 0] = name
table[row, 0].grid(sticky=tk.E)
if data["type"] == "bool":
if name not in value or value[name] is None:
value[name] = False
var = self._tk_var[name] = tk.IntVar()
checkbox = ttk.Checkbutton(frame, variable=var)
var.set(1 if value[name] else 0)
table[row, 1] = checkbox
else:
if name not in value or value[name] is None:
value[name] = data["default"]
if len(data["choices"]) > 0:
combo = ttk.Combobox(frame, values=data["choices"])
combo.set(value[name])
combo.configure(state="readonly")
table[row, 1] = combo
else:
entry = ttk.Entry(frame)
entry.insert(0, value[name])
table[row, 1] = entry
table[row, 1].grid(sticky=tk.EW)
if data["type"] == "file":
button = tk.Button(
frame,
text="...",
command=(
lambda t=table, r=row, n=name, d=data: self.file_cb(
t, r, n, d
)
),
)
table[row, 2] = button
table[row, 3] = data["help"]
row += 1
frame.columnconfigure(1, weight=1)
# Post the dialog
result = self.dialog.activate(geometry="centerscreenfirst")
if result is not None:
if len(parameter_steps) == 0:
value = {}
else:
# Get the variable values
table = self["parameters"]
for row in range(table.nrows):
name = table[row, 0].cget("text")
data = variables[name]
if data["type"] == "bool":
tmp = self._tk_var[name].get()
value[name] = True if tmp == 1 else False
else:
value[name] = table[row, 1].get()
dashboard_name = result.pop("dashboard")
dashboard = self.dashboard_handler.get_dashboard(dashboard_name)
job_id = dashboard.submit(flowchart, values=value, **result)
return job_id
else:
return None