Source code for seamm.tk_open

# -*- coding: utf-8 -*-

"""The GUI for opening flowcharts."""

import collections.abc
import datetime
import json
import logging
from pathlib import Path
import pprint  # noqa: F401
import tkinter as tk
from tkinter.scrolledtext import ScrolledText
import tkinter.ttk as ttk

import dateutil
import Pmw

from .dashboard_handler import DashboardHandler
from .seammrc import SEAMMrc
from seamm_dashboard_client import DashboardConnectionError
import seamm_util
import seamm_widgets as sw

logger = logging.getLogger(__name__)
# logger.setLevel("DEBUG")

# "operators": (
#     "must be",
#     "must be like",
#     "must contain",
#     "must contain like",
#     "must not be",
#     "must not be like",
#     "must not contain",
#     "must not contain like",
#     "may be",
#     "may be like",
#     "may contain",
#     "must contain like",
# ),

zenodo_fields = {
    "any field": {
        "field": "",
        "operators": (
            "contains",
            "contains like",
        ),
    },
    "author name": {
        "field": "creators.name:",
        "operators": (
            "contains",
            "contains like",
        ),
    },
    "author orcid": {"field": "creators.orcid:", "operators": ("is", "is not")},
    "author affiliation": {
        "field": "creators.affiliation:",
        "operators": (
            "contains",
            "contains like",
        ),
    },
    "community": {
        "field": "communities:",
        "operators": ("is", "is like", "is not", "is not like"),
    },
    "date": {"field": "created", "operators": ("after", "before", "on", "between")},
    "description": {
        "field": "description:",
        "operators": (
            "contains",
            "contains like",
        ),
    },
    "keyword": {
        "field": "keywords:",
        "operators": (
            "contains",
            "contains like",
        ),
    },
    "title": {
        "field": "title:",
        "operators": (
            "contains",
            "contains like",
        ),
    },
}


[docs] class TkOpen(collections.abc.MutableMapping): def __init__(self, toplevel): self._widgets = {"toplevel": toplevel} self._data = dict() self.zenodo = None self._dashboard_handler = None self.dashboards = None self.current_dashboard = None self.current_project = "any" self._projects = None self.config = SEAMMrc() if "SEAMM open" not in self.config: self.config.add_section("SEAMM open") # 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 x[key] access to the data""" self._widgets[key] = value def __delitem__(self, key): """Allow deletion of keys""" if key in self._widgets: self._widgets[key].destroy() del self._widgets[key] def __iter__(self): """Allow iteration over the object""" return iter(self._widgets) def __len__(self): """The len() command""" return len(self._widgets) @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 clear_tree(self): """Remove any contents from the tree.""" tree = self["tree"] children = tree.get_children() if len(children) > 0: tree.delete(*children) self._data = {}
[docs] def create_dialog(self): """Create the dialog for opening.""" if "dialog" in self: return self["dialog"] = d = Pmw.Dialog( self["toplevel"], buttons=("Open", "Cancel"), master=self["toplevel"], title="Open Flowchart", ) d.withdraw() frame = self["frame"] = d.interior() w = self["what"] = sw.LabeledCombobox( frame, labeltext="Open a", values=("flowchart",), state="readonly", ) w.set("flowchart") if not self.config.has_option("SEAMM open", "source"): self.config.set("SEAMM open", "source", "local files") path = Path("~/SEAMM/flowcharts").expanduser() path.mkdir(parents=True, exist_ok=True) source = self.config.get("SEAMM open", "source") w = self["source"] = sw.LabeledCombobox( frame, labeltext="from", values=("local files", "previous jobs", "Zenodo", "Zenodo sandbox"), state="readonly", ) w.set(source) # For local files, a directory to start from if not self.config.has_option("SEAMM open", "directory"): self.config.set( "SEAMM open", "directory", json.dumps(["~/SEAMM/flowcharts"]) ) path = Path("~/SEAMM/flowcharts").expanduser() path.mkdir(parents=True, exist_ok=True) directories = json.loads(self.config.get("SEAMM open", "directory")) directory = directories[0] w = self["directory"] = sw.LabeledCombobox( frame, labeltext="directory", values=directories, ) w.set(directory) self["get directory"] = ttk.Button( frame, text="...", command=self.directory_cb, width=3 ) # From dashboard if not self.config.has_option("SEAMM open", "dashboard"): self.config.set("SEAMM open", "dashboard", "") w = self["dashboard"] = sw.LabeledCombobox( frame, labeltext="Dashboard", ) if not self.config.has_option("SEAMM open", "project"): self.config.set("SEAMM open", "project", "any") w = self["project"] = sw.LabeledCombobox( frame, labeltext="Project", ) self["criteria"] = sw.SearchCriteria( frame, text="Show flowcharts where", labelanchor=tk.NW, inclusiontext="", inclusionvalues=( "must have", "must not have", "may have", "and", "or", "(", ")", "ignore", ), operatorvalues=( "contains", "contains like", ), fieldvalues=[*zenodo_fields.keys()], two_values=("between",), command=self.zenodo_callback, ) self["search"] = ttk.Button(frame, text="Search", command=self.search_cb) w = self["tree"] = ttk.Treeview(frame, selectmode="browse") w.bind("<ButtonRelease-1>", self.select_record) w.bind( "<Double-Button-1>", lambda e=None: self["dialog"].deactivate(result="Open") ) # Add scrollbars self["tree ysb"] = ttk.Scrollbar(frame, orient="vertical", command=w.yview) self["tree xsb"] = ttk.Scrollbar(frame, orient="horizontal", command=w.xview) w.configure(yscroll=self["tree ysb"].set, xscroll=self["tree xsb"].set) detail = self["detail"] = ttk.LabelFrame( frame, borderwidth=5, relief=tk.SUNKEN, text="Flowchart Details", labelanchor=tk.N, ) row = 0 w = self["title"] = ttk.Label(detail) w.grid(row=row, column=0) row += 1 w = self["description"] = ScrolledText( detail, wrap=tk.WORD, font=("Helvetica",), height=6 ) w.grid(row=row, column=0, sticky=tk.NSEW) detail.rowconfigure(row, weight=1) detail.columnconfigure(0, weight=1) row += 1 w = self["version"] = ttk.Label(detail) w.grid(row=row, column=0, sticky=tk.W) for item in ("what", "source"): self[item].bind("<<ComboboxSelected>>", self.reset_dialog) for item in ("directory",): self[item].bind("<<ComboboxSelected>>", self.reset_tree) self[item].bind("<Return>", self.reset_tree) self[item].bind("<FocusOut>", self.reset_tree) for item in ("dashboard", "project"): self[item].bind("<<ComboboxSelected>>", self.update_dashboard) # Fill the window with the dialog! swidth = frame.winfo_screenwidth() sheight = frame.winfo_screenheight() d.geometry(f"{int(0.8 * swidth)}x{int(0.8 * sheight)}")
[docs] def directory_cb(self, event=None): """Invoked by the ... button to get new directory.""" d = self["directory"] current = Path(d.get()).expanduser().resolve() if not current.exists() or not current.is_dir(): current = Path("~").expanduser() directory = tk.filedialog.askdirectory( parent=self["toplevel"], title="Change directory", initialdir=current, mustexist=True, ) if directory is None or current.samefile(directory): return d.set(directory) self.reset_tree()
[docs] def fill_tree(self, job_list): """Fill the tree with a job list The job list looks is a list of Job objects that are dicts like:: { 'description': 'test of api', 'finished': '2022-02-24 10:06', 'flowchart_id': '1', 'group': None, 'group_id': None, 'id': 18, 'last_update': '2022-02-24 10:05', 'owner': 'psaxe', 'owner_id': 2, 'parameters': { 'cmdline': ['job:data/Users_psaxe_SEAMM_data_TiO2--anatase.cif'] }, 'path': '/Users/psaxe/SEAMM_DEV/Jobs/projects/default/Job_000018', 'projects': [{'id': 1, 'name': 'default'}], 'started': '2022-02-24 10:06', 'status': 'finished', 'submitted': '2022-02-24 10:05', 'title': 'test of api' } Parameters ---------- job_list : [seamm_dashboard_client._Job] List of Job objects contain info about the jobs """ # Remove any current data self.clear_tree() self._data = {} tree = self["tree"] for job in job_list: iid = tree.insert("", "end", text=f"Job_{job['id']:0>6d}: {job['title']}") self._data[iid] = job
[docs] def insert_node(self, parent, text, path, open=False): """Insert a new node in the tree, corresponding to a file or directory. Parameters ---------- parent : str The parent node in the tree, "" for toplevel. text : str The text to display for the node. path : pathlib.Path The absolute path of the file or directory. """ tree = self["tree"] node = tree.insert(parent, "end", text=text, open=open) self._data[node] = path if path.is_dir(): tree.insert(node, "end") return node
[docs] def open_node(self, event=None): tree = self["tree"] node = tree.focus() path = self._data.get(node, None) if path is not None: tree.delete(*tree.get_children(node)) for p in sorted(path.iterdir(), key=lambda p: p.name): if p.is_dir() or p.suffix == ".flow": self.insert_node(node, p.name, p)
[docs] def reset_dialog(self, event=None): """Layout the widgets in the dialog according to the parameters.""" frame = self["frame"] what = self["what"].get() source = self["source"].get() # Temporary if what != "flowchart": what = "flowchart" self["what"].set(what) # Remove all the widgets frame = self["frame"] for slave in frame.grid_slaves(): slave.grid_forget() # and put them back in as needed. row = 0 self["what"].grid(row=row, column=0, sticky=tk.W) self["source"].grid(row=row, column=1, sticky=tk.W) if source == "local files": self["directory"].grid(row=row, column=2, sticky=tk.EW) self["get directory"].grid(row=row, column=3, columnspan=2, sticky=tk.E) elif source == "previous jobs": self["dashboard"].grid(row=row, column=2, sticky=tk.EW) self["project"].grid(row=row, column=3, columnspan=2, sticky=tk.E) self.dashboards = self.dashboard_handler.dashboards # Remove any dashboards that we cannot access for dashboard, status in self.dashboard_handler.get_all_status(): if status != "running": self.dashboards.remove(dashboard) if len(self.dashboards) == 0: self.current_dashboard = None tk.messagebox.showwarning( title="No Dashboards available", message="The are no Dashboards that can be connected to.", ) else: dashboard_name = self.config.get("SEAMM open", "dashboard") if dashboard_name not in self.dashboards: dashboard_name = self.dashboards[0] if ( self.current_dashboard is None or self.current_dashboard.name != dashboard_name ): self.current_dashboard = self.dashboard_handler.get_dashboard( dashboard_name ) self._projects = None projects = ["any"] try: self._projects = self.current_dashboard.projects() projects += [*self._projects.keys()] except DashboardConnectionError: projects = [] self["dashboard"].bind("<<ComboboxSelected>>", "") self["dashboard"].config(values=self.dashboards, state="readonly") self["dashboard"].set(self.current_dashboard.name) self["dashboard"].bind("<<ComboboxSelected>>", self.update_dashboard) self["project"].config(values=projects, state="readonly") project = self.config.get("SEAMM open", "project") if project not in projects: project = projects[0] if self.current_project is None or self.current_project != project: self.current_project = project self["project"].set(self.current_project) row += 1 if what == "flowchart": if "Zenodo" in source: self["criteria"].grid(row=row, column=0, columnspan=5, sticky=tk.NSEW) frame.rowconfigure(row, weight=1) frame.columnconfigure(1, weight=1) row += 1 self["tree"].bind("<<TreeviewOpen>>", "") self["search"].grid(row=row, column=0, sticky=tk.W) row += 1 self.clear_tree() elif source == "previous jobs": self.clear_tree() self["tree"].bind("<<TreeviewOpen>>", "") # Get the jobs from the Dashboard. if self.current_dashboard is not None: if self.current_project == "any": job_list = self.current_dashboard.jobs() else: job_list = self._projects[self.current_project].jobs() self.fill_tree(job_list) elif source == "local files": self.reset_tree() self["tree"].grid(row=row, column=0, columnspan=4, sticky=tk.NSEW) self["tree ysb"].grid(row=row, column=4, sticky=tk.NS + tk.W) frame.rowconfigure(row, weight=1) row += 1 self["tree xsb"].grid(row=row, column=0, columnspan=4, sticky=tk.EW) row += 1 self["detail"].grid(row=row, column=0, columnspan=5, sticky=tk.NSEW) row += 1 frame.columnconfigure(2, weight=1)
[docs] def reset_tree(self, event=None): """Reset the file tree to start with the given directory.""" directory = self["directory"].get() path = Path(directory).expanduser().resolve() # Fixes issue #131 if not path.exists(): tk.messagebox.showwarning( title="Invalid directory", message=f"The directory given '{path}' does not exist!", ) return elif not path.is_dir(): if path.is_file(): path = path.parent self["directory"].set(str(path)) else: tk.messagebox.showwarning( title="Not a directory", message=f"The path '{path}' exists, but is not a directory!", ) return self.clear_tree() node = self.insert_node("", path, path, open=True) self["tree"].focus(node) self.open_node() self["tree"].bind("<<TreeviewOpen>>", self.open_node)
[docs] def open(self): """Present a dialog for opening.""" # Reread the configuration file in case it has changed self.config.re_read() # Create the dialog if it doesn't exist self.create_dialog() self.reset_dialog() # And put it on-screen, the first time centered. result = self["dialog"].activate(geometry="centerscreenfirst") if result == "Open": what = self["what"].get() source = self["source"].get() if what == "flowchart": tree = self["tree"] selected = tree.selection() if source != self.config.get("SEAMM open", "source"): self.config.set("SEAMM open", "source", source) if source == "Zenodo" or source == "Zenodo sandbox": if len(selected) > 0: record = self._data[selected[0]]["record"] data = record.get_file("flowchart.flow") return {"data": data, "source": source} elif "previous jobs" == source: if len(selected) > 0: self.config.set( "SEAMM open", "dashboard", self.current_dashboard.name ) self.config.set("SEAMM open", "project", self.current_project) job = self._data[selected[0]] job_id = job["id"] job = self.current_dashboard.job(job_id) data = job.get_file("flowchart.flow") return {"data": data, "source": source, "job_id": job_id} elif "local files" in source: if len(selected) > 0: path = self._data[selected[0]] directory = str(path.parent) directories = json.loads( self.config.get("SEAMM open", "directory") ) if directory != directories[0]: if directory in directories: directories.remove(directory) directories.insert(0, directory) self.config.set( "SEAMM open", "directory", json.dumps(directories) ) w = self["directory"] w.configure(values=directories) w.set(directory) if path.suffix == ".flow": data = path.read_text() return {"data": data, "source": source, "path": str(path)} else: raise RuntimeError(f"cannot handle source '{source}'") else: raise RuntimeError(f"cannot handle opening '{what}'") return None
[docs] def search_cb(self): """Handle the search.""" what = self["what"].get() source = self["source"].get() if what == "flowchart": if source == "Zenodo": self.search_zenodo_for_flowcharts() elif source == "Zenodo sandbox": self.search_zenodo_for_flowcharts(sandbox=True) else: raise RuntimeError(f"Can't handle source '{source}'") else: raise RuntimeError(f"Can't handle searching for '{what}'")
[docs] def search_zenodo_for_flowcharts(self, sandbox=False): """Search for flowcharts in Zenodo. Parameters ---------- sandbox : bool = False If true, search the Zenodo sandbox. """ self.zenodo = seamm_util.Zenodo(use_sandbox=sandbox) criteria = self["criteria"].get() query = "" after_parenthesis = False for i, search_criteria in enumerate(criteria): inclusion, field, operator, value, value2 = search_criteria logger.debug(f"{inclusion=} {field=} {operator=} {value=} {value2=}") zf = zenodo_fields[field]["field"] if i == 0: # query += " AND (" after_parenthesis = True pre = "" if inclusion == "and": if not after_parenthesis: query += " AND" continue elif inclusion == "or": if not after_parenthesis: query += " OR" continue elif inclusion == "(": query += " (" after_parenthesis = True continue elif inclusion == ")": query += " )" after_parenthesis = False continue elif inclusion == "must have": pre = "+" elif inclusion == "must not have": pre = "-" elif inclusion == "may have": pre = "" elif inclusion == "ignore": continue else: raise RuntimeError(f"{inclusion=}") after_parenthesis = False if " " in value: value = f'"{value}"' if field == "date": try: date = dateutil.parser.parse(value).isoformat() except Exception: tk.messagebox.showwarning( title="Invalid date format", message=( f"The date given '{value}' can't be handled. Try formats " "like '2022-04-23' or '4/23/2022' or '23 Apr 2022'" ), ) else: if operator == "after": query += ( f' (+keywords:"seamm-flowchart" AND {pre}{zf}[{date} TO *]' ) elif operator == "before": query += ( f' (+keywords:"seamm-flowchart" AND {pre}{zf}[* TO {date}]' ) elif operator == "on": query += f' (+keywords:"seamm-flowchart" AND {pre}{zf}{date}' elif operator == "between": try: date2 = dateutil.parser.parse(value2).isoformat() except Exception: tk.messagebox.showwarning( title="Invalid date format", message=( f"The date given '{value}' can't be handled. Try " "formats like '2022-04-23' or '4/23/2022' or " "'23 Apr 2022'" ), ) else: query += ( f' (+keywords:"seamm-flowchart" AND {pre}{zf}[{date} to' f" {date2}])" ) else: raise RuntimeError(f"Don't recognize operator '{operator}'") else: if operator == "is": query += f' (+keywords:"seamm-flowchart" AND {pre}{zf}/{value}/)' elif operator == "is like": query += f' (+keywords:"seamm-flowchart" AND {pre}{zf}{value}~)' elif operator == "contains": query += f' (+keywords:"seamm-flowchart" AND {pre}{zf}{value})' elif operator == "contains like": query += f' (+keywords:"seamm-flowchart" AND {pre}{zf}{value}~)' elif operator == "is not": query += f' (+keywords:"seamm-flowchart" AND -{zf}/{value}/)' elif operator == "is not like": query += f' (+keywords:"seamm-flowchart" AND -{zf}{value}~)' elif operator == "does not contain": query += f' (+keywords:"seamm-flowchart" AND -{zf}{value})' elif operator == "does not contain like": query += f' (+keywords:"seamm-flowchart" AND -{zf}{value}~)' else: raise RuntimeError(f"Don't recognize operator '{operator}'") if len(query) == 0: query = '+keywords:"seamm-flowchart"' logger.debug(f"query = {query}") try: n_hits, records = self.zenodo.search(query=query, all_versions=True) except RuntimeError as e: tk.messagebox.showwarning(title="Invalid Zenodo query", message=str(e)) return # Find all related records using conceptrecid concepts = {} logger.debug(f"Records {n_hits=}") for record in records: concept_id = record["conceptrecid"] if concept_id not in concepts: concepts[concept_id] = {} data = concepts[concept_id] version = record["metadata"]["relations"]["version"][0]["index"] data[version] = record logger.debug("\n" + pprint.pformat(concepts)) logger.debug("--------\n\n") # Remove any current data self.clear_tree() self._data = {} tree = self["tree"] for concept_id, data in concepts.items(): first = True count = len(data) for version in sorted(data.keys(), reverse=True): record = data[version] if first: first = False iid = tree.insert("", "end", text=record.title) self._data[iid] = { "count": count, "record": record, } if len(data) > 1: text = f"Version {version + 1}: {record.title}" jid = tree.insert(iid, "end", text=text) self._data[jid] = { "count": count, "record": record, }
[docs] def select_record(self, event): """The user clicked on the tree-view ... handle the selected record.""" tree = self["tree"] selected = tree.selection() if len(selected) == 0: return source = self["source"].get() if "Zenodo" in source: tmp = self._data[selected[0]] count = tmp["count"] data = tmp["record"]["metadata"] logger.debug("\n\nZenodo source, data =") logger.debug("\n" + pprint.pformat(data)) logger.debug("-------") self["title"].configure(text=data["title"]) self["description"].delete("1.0", "end") self["description"].insert("1.0", data["description"]) info = data["relations"]["version"][0] if "publication_data" in data: date = data["publication_data"] version = f"Version {info['index'] + 1} of {count} -- {date}" else: version = f"Version {info['index'] + 1} of {count}" self["version"].configure(text=version) elif "previous jobs" == source: job = self._data[selected[0]] self["title"].configure(text=job["title"]) self["description"].delete("1.0", "end") self["description"].insert("1.0", job["description"]) elif source == "local files": self["title"].configure(text="") self["description"].delete("1.0", "end") self["version"].configure(text="") path = self._data[selected[0]] if path.suffix == ".flow": capture = False lines = [] with path.open() as fd: for line in fd: line = line.strip() if line[0] == "#": if line == "#metadata": capture = True elif capture: break elif capture: lines.append(line) if len(lines) > 0: mtime = path.stat().st_mtime date = datetime.datetime.fromtimestamp(mtime).isoformat(" ") data = json.loads("\n".join(lines)) if "title" in data: self["title"].configure(text=data["title"]) if "description" in data: self["description"].insert("1.0", data["description"]) if "version" in data: line = f"Version {data['version']} -- {date}" else: line = f"Version <none> -- {date}" self["version"].configure(text=line) else: raise RuntimeError(f"Can't handle source '{source}'")
[docs] def update_dashboard(self, event=None): """The dashboard has been changed!""" dashboard = self["dashboard"].get() project = self["project"].get() if dashboard != self.current_dashboard.name: projects = ["any"] try: dashboard = self.dashboard_handler.get_dashboard(dashboard) self._projects = self.current_dashboard.projects() projects += [*self._projects.keys()] except DashboardConnectionError: self.dashboards.remove(dashboard) self["dashboard"].config(values=self.dashboards, state="readonly") self["dashboard"].set(self.current_dashboard.name) tk.messagebox.showwarning( title="Cannot access Dashboard", message=f"Cannot access Dashboard {dashboard}. Resetting!", ) else: self.current_dashboard = dashboard self["project"].config(values=projects, state="readonly") if self.current_project is None or project not in projects: self.current_project = projects[0] self["project"].set(self.current_project) elif project != self.current_project: self.current_project = project self.clear_tree() # Get the jobs from the Dashboard. if self.current_project == "any": job_list = self.current_dashboard.jobs() else: job_list = self._projects[self.current_project].jobs() self.fill_tree(job_list)
[docs] def zenodo_callback(self, widget, criterion, event, what): if criterion is not None: inclusion, field, operator, value, value2 = criterion.get() if what == "field": operators = zenodo_fields[field]["operators"] w = criterion.operator w.configure(values=operators) if operator in operators: w.set(operator) else: w.set(operators[0])