Source code for seamm.tk_job_handler

#!/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