[VVP] Adding preload generation functionality
[vvp/validation-scripts.git] / ice_validator / vvp.py
index 731d49b..b8e2e84 100644 (file)
@@ -49,7 +49,6 @@ NOTE: This script does require Python 3.6+
 import appdirs
 import os
 import pytest
-import sys
 import version
 import yaml
 import contextlib
@@ -58,6 +57,8 @@ import queue
 import tempfile
 import webbrowser
 import zipfile
+import platform
+import subprocess  # nosec
 
 from collections import MutableMapping
 from configparser import ConfigParser
@@ -88,10 +89,23 @@ from tkinter import (
     Checkbutton,
     IntVar,
     Toplevel,
+    Message,
+    CURRENT,
+    Text,
+    INSERT,
+    DISABLED,
+    FLAT,
+    CENTER,
+    ACTIVE,
+    LEFT,
+    Menu,
+    NORMAL,
 )
 from tkinter.scrolledtext import ScrolledText
 from typing import Optional, List, Dict, TextIO, Callable, Iterator
 
+import preload
+
 VERSION = version.VERSION
 PATH = os.path.dirname(os.path.realpath(__file__))
 OUT_DIR = "output"
@@ -103,8 +117,8 @@ class ToolTip(object):
     """
 
     def __init__(self, widget, text="widget info"):
-        self.waittime = 750  # miliseconds
-        self.wraplength = 180  # pixels
+        self.waittime = 750  # milliseconds
+        self.wraplength = 300  # pixels
         self.widget = widget
         self.text = text
         self.widget.bind("<Enter>", self.enter)
@@ -113,9 +127,11 @@ class ToolTip(object):
         self.id = None
         self.tw = None
 
+    # noinspection PyUnusedLocal
     def enter(self, event=None):
         self.schedule()
 
+    # noinspection PyUnusedLocal
     def leave(self, event=None):
         self.unschedule()
         self.hidetip()
@@ -125,17 +141,18 @@ class ToolTip(object):
         self.id = self.widget.after(self.waittime, self.showtip)
 
     def unschedule(self):
-        id = self.id
+        orig_id = self.id
         self.id = None
-        if id:
-            self.widget.after_cancel(id)
+        if orig_id:
+            self.widget.after_cancel(orig_id)
 
+    # noinspection PyUnusedLocal
     def showtip(self, event=None):
         x = y = 0
         x, y, cx, cy = self.widget.bbox("insert")
         x += self.widget.winfo_rootx() + 25
         y += self.widget.winfo_rooty() + 20
-        # creates a toplevel window
+        # creates a top level window
         self.tw = Toplevel(self.widget)
         # Leaves only the label and removes the app window
         self.tw.wm_overrideredirect(True)
@@ -158,6 +175,44 @@ class ToolTip(object):
             tw.destroy()
 
 
+class HyperlinkManager:
+    """Adapted from http://effbot.org/zone/tkinter-text-hyperlink.htm"""
+
+    def __init__(self, text):
+        self.links = {}
+        self.text = text
+        self.text.tag_config("hyper", foreground="blue", underline=1)
+        self.text.tag_bind("hyper", "<Enter>", self._enter)
+        self.text.tag_bind("hyper", "<Leave>", self._leave)
+        self.text.tag_bind("hyper", "<Button-1>", self._click)
+        self.reset()
+
+    def reset(self):
+        self.links.clear()
+
+    def add(self, action):
+        # add an action to the manager.  returns tags to use in
+        # associated text widget
+        tag = "hyper-%d" % len(self.links)
+        self.links[tag] = action
+        return "hyper", tag
+
+    # noinspection PyUnusedLocal
+    def _enter(self, event):
+        self.text.config(cursor="hand2")
+
+    # noinspection PyUnusedLocal
+    def _leave(self, event):
+        self.text.config(cursor="")
+
+    # noinspection PyUnusedLocal
+    def _click(self, event):
+        for tag in self.text.tag_names(CURRENT):
+            if tag[:6] == "hyper-":
+                self.links[tag]()
+                return
+
+
 class QueueWriter:
     """``stdout`` and ``stderr`` will be written to this queue by pytest, and
     pulled into the main GUI application"""
@@ -183,18 +238,6 @@ class QueueWriter:
         pass
 
 
-def get_plugins() -> Optional[List]:
-    """When running in a frozen bundle, plugins to be registered
-    explicitly. This method will return the required plugins to register
-    based on the run mode"""
-    if hasattr(sys, "frozen"):
-        import pytest_tap.plugin
-
-        return [pytest_tap.plugin]
-    else:
-        return None
-
-
 def run_pytest(
     template_dir: str,
     log: TextIO,
@@ -227,6 +270,7 @@ def run_pytest(
                                 are encountered in the input files.  This can help
                                 prevent a large number of errors from flooding the
                                 report.
+    :param template_source:     The path or name of the template to show on the report
     """
     out_path = "{}/{}".format(PATH, OUT_DIR)
     if os.path.exists(out_path):
@@ -246,7 +290,7 @@ def run_pytest(
                     args.extend(("--category", category))
             if not halt_on_failure:
                 args.append("--continue-on-failure")
-            pytest.main(args=args, plugins=get_plugins())
+            pytest.main(args=args)
             result_queue.put((True, None))
         except Exception as e:
             result_queue.put((False, e))
@@ -255,8 +299,8 @@ def run_pytest(
 class UserSettings(MutableMapping):
     FILE_NAME = "UserSettings.ini"
 
-    def __init__(self):
-        user_config_dir = appdirs.AppDirs("org.onap.vvp", "ONAP").user_config_dir
+    def __init__(self, namespace, owner):
+        user_config_dir = appdirs.AppDirs(namespace, owner).user_config_dir
         if not os.path.exists(user_config_dir):
             os.makedirs(user_config_dir, exist_ok=True)
         self._settings_path = os.path.join(user_config_dir, self.FILE_NAME)
@@ -314,8 +358,10 @@ class Config:
             self._config = config
         else:
             with open(self.DEFAULT_FILENAME, "r") as f:
-                self._config = yaml.load(f)
-        self._user_settings = UserSettings()
+                self._config = yaml.safe_load(f)
+        self._user_settings = UserSettings(
+            self._config["namespace"], self._config["owner"]
+        )
         self._watched_variables = []
         self._validate()
         self._manager = multiprocessing.Manager()
@@ -331,6 +377,7 @@ class Config:
         for var in self._watched_variables:
             var.trace_add("write", self.save_settings)
 
+    # noinspection PyProtectedMember,PyUnusedLocal
     def save_settings(self, *args):
         """Save the value of all watched variables to user settings"""
         for var in self._watched_variables:
@@ -358,6 +405,55 @@ class Config:
             )
         )
 
+    @property
+    def disclaimer_text(self) -> str:
+        return self._config["ui"].get("disclaimer-text", "")
+
+    @property
+    def requirement_link_text(self) -> str:
+        return self._config["ui"].get("requirement-link-text", "")
+
+    @property
+    def requirement_link_url(self) -> str:
+        path = self._config["ui"].get("requirement-link-url", "")
+        if not path.startswith("http"):
+            path = "file://{}".format(os.path.join(PATH, path))
+        return path
+
+    @property
+    def terms(self) -> dict:
+        return self._config.get("terms", {})
+
+    @property
+    def terms_link_url(self) -> Optional[str]:
+        return self.terms.get("path")
+
+    @property
+    def terms_link_text(self):
+        return self.terms.get("popup-link-text")
+
+    @property
+    def terms_version(self) -> Optional[str]:
+        return self.terms.get("version")
+
+    @property
+    def terms_popup_title(self) -> Optional[str]:
+        return self.terms.get("popup-title")
+
+    @property
+    def terms_popup_message(self) -> Optional[str]:
+        return self.terms.get("popup-msg-text")
+
+    @property
+    def are_terms_accepted(self) -> bool:
+        version = "terms-{}".format(self.terms_version)
+        return self._user_settings.get(version, "False") == "True"
+
+    def set_terms_accepted(self):
+        version = "terms-{}".format(self.terms_version)
+        self._user_settings[version] = "True"
+        self._user_settings.save()
+
     def default_verbosity(self, levels: Dict[str, str]) -> str:
         requested_level = self._user_settings.get("verbosity") or self._config[
             "settings"
@@ -367,7 +463,7 @@ class Config:
             if key.lower().startswith(requested_level.lower()):
                 return key
         raise RuntimeError(
-            "Invalid default-verbosity level {}. Valid"
+            "Invalid default-verbosity level {}. Valid "
             "values are {}".format(requested_level, ", ".join(keys))
         )
 
@@ -401,6 +497,27 @@ class Config:
     def report_formats(self):
         return ["CSV", "Excel", "HTML"]
 
+    @property
+    def preload_formats(self):
+        excluded = self._config.get("excluded-preloads", [])
+        formats = (cls.format_name() for cls in preload.get_generator_plugins())
+        return [f for f in formats if f not in excluded]
+
+    @property
+    def default_preload_format(self):
+        default = self._user_settings.get("preload_format")
+        if default and default in self.preload_formats:
+            return default
+        else:
+            return self.preload_formats[0]
+
+    @staticmethod
+    def get_subdir_for_preload(preload_format):
+        for gen in preload.get_generator_plugins():
+            if gen.format_name() == preload_format:
+                return gen.output_sub_dir()
+        return ""
+
     @property
     def default_input_format(self):
         requested_default = self._user_settings.get("input_format") or self._config[
@@ -438,6 +555,106 @@ class Config:
                 )
 
 
+def validate():
+    return True
+
+
+class Dialog(Toplevel):
+    """
+    Adapted from http://www.effbot.org/tkinterbook/tkinter-dialog-windows.htm
+    """
+
+    def __init__(self, parent: Frame, title=None):
+        Toplevel.__init__(self, parent)
+        self.transient(parent)
+        if title:
+            self.title(title)
+        self.parent = parent
+        self.result = None
+        body = Frame(self)
+        self.initial_focus = self.body(body)
+        body.pack(padx=5, pady=5)
+        self.buttonbox()
+        self.grab_set()
+        if not self.initial_focus:
+            self.initial_focus = self
+        self.protocol("WM_DELETE_WINDOW", self.cancel)
+        self.geometry(
+            "+%d+%d" % (parent.winfo_rootx() + 600, parent.winfo_rooty() + 400)
+        )
+        self.initial_focus.focus_set()
+        self.wait_window(self)
+
+    def body(self, master):
+        raise NotImplementedError()
+
+    # noinspection PyAttributeOutsideInit
+    def buttonbox(self):
+        box = Frame(self)
+        self.accept = Button(
+            box,
+            text="Accept",
+            width=10,
+            state=DISABLED,
+            command=self.ok,
+            default=ACTIVE,
+        )
+        self.accept.pack(side=LEFT, padx=5, pady=5)
+        self.decline = Button(
+            box, text="Decline", width=10, state=DISABLED, command=self.cancel
+        )
+        self.decline.pack(side=LEFT, padx=5, pady=5)
+        self.bind("<Return>", self.ok)
+        self.bind("<Escape>", self.cancel)
+        box.pack()
+
+    # noinspection PyUnusedLocal
+    def ok(self, event=None):
+        if not validate():
+            self.initial_focus.focus_set()  # put focus back
+            return
+        self.withdraw()
+        self.update_idletasks()
+        self.apply()
+        self.cancel()
+
+    # noinspection PyUnusedLocal
+    def cancel(self, event=None):
+        self.parent.focus_set()
+        self.destroy()
+
+    def apply(self):
+        raise NotImplementedError()
+
+    def activate_buttons(self):
+        self.accept.configure(state=NORMAL)
+        self.decline.configure(state=NORMAL)
+
+
+class TermsAndConditionsDialog(Dialog):
+    def __init__(self, parent, config: Config):
+        self.config = config
+        self.parent = parent
+        super().__init__(parent, config.terms_popup_title)
+
+    def body(self, master):
+        Label(master, text=self.config.terms_popup_message).grid(row=0, pady=5)
+        tc_link = Label(
+            master, text=self.config.terms_link_text, fg="blue", cursor="hand2"
+        )
+        ValidatorApp.underline(tc_link)
+        tc_link.bind("<Button-1>", self.open_terms)
+        tc_link.grid(row=1, pady=5)
+
+    # noinspection PyUnusedLocal
+    def open_terms(self, event):
+        webbrowser.open(self.config.terms_link_url)
+        self.activate_buttons()
+
+    def apply(self):
+        self.config.set_terms_accepted()
+
+
 class ValidatorApp:
     VERBOSITY_LEVELS = {"Less": "", "Standard (-v)": "-v", "More (-vv)": "-vv"}
 
@@ -450,7 +667,16 @@ class ValidatorApp:
         self._root.title(self.config.app_name)
         self._root.protocol("WM_DELETE_WINDOW", self.shutdown)
 
-        main_window = PanedWindow(self._root)
+        if self.config.terms_link_text:
+            menubar = Menu(self._root)
+            menubar.add_command(
+                label=self.config.terms_link_text,
+                command=lambda: webbrowser.open(self.config.terms_link_url),
+            )
+            self._root.config(menu=menubar)
+
+        parent_frame = Frame(self._root)
+        main_window = PanedWindow(parent_frame)
         main_window.pack(fill=BOTH, expand=1)
 
         control_panel = PanedWindow(
@@ -460,6 +686,10 @@ class ValidatorApp:
         control_panel.add(actions)
         control_panel.paneconfigure(actions, minsize=250)
 
+        if self.config.disclaimer_text or self.config.requirement_link_text:
+            self.footer = self.create_footer(parent_frame)
+        parent_frame.pack(fill=BOTH, expand=True)
+
         # profile start
         number_of_categories = len(self.config.category_names)
         category_frame = LabelFrame(actions, text="Additional Validation Categories:")
@@ -471,6 +701,7 @@ class ValidatorApp:
             category_name = self.config.category_names[x]
             category_value = IntVar(value=0)
             category_value._name = "category_{}".format(category_name.replace(" ", "_"))
+            # noinspection PyProtectedMember
             category_value.set(self.config.get_category_value(category_value._name))
             self.categories.append(category_value)
             category_checkbox = Checkbutton(
@@ -480,45 +711,67 @@ class ValidatorApp:
             category_checkbox.grid(row=x + 1, column=1, columnspan=2, sticky="w")
 
         settings_frame = LabelFrame(actions, text="Settings")
+        settings_row = 1
         settings_frame.grid(row=3, column=1, columnspan=3, pady=10, sticky="we")
         verbosity_label = Label(settings_frame, text="Verbosity:")
-        verbosity_label.grid(row=1, column=1, sticky=W)
+        verbosity_label.grid(row=settings_row, column=1, sticky=W)
         self.verbosity = StringVar(self._root, name="verbosity")
         self.verbosity.set(self.config.default_verbosity(self.VERBOSITY_LEVELS))
         verbosity_menu = OptionMenu(
             settings_frame, self.verbosity, *tuple(self.VERBOSITY_LEVELS.keys())
         )
         verbosity_menu.config(width=25)
-        verbosity_menu.grid(row=1, column=2, columnspan=3, sticky=E, pady=5)
+        verbosity_menu.grid(row=settings_row, column=2, columnspan=3, sticky=E, pady=5)
+        settings_row += 1
+
+        if self.config.preload_formats:
+            preload_format_label = Label(settings_frame, text="Preload Template:")
+            preload_format_label.grid(row=settings_row, column=1, sticky=W)
+            self.preload_format = StringVar(self._root, name="preload_format")
+            self.preload_format.set(self.config.default_preload_format)
+            preload_format_menu = OptionMenu(
+                settings_frame, self.preload_format, *self.config.preload_formats
+            )
+            preload_format_menu.config(width=25)
+            preload_format_menu.grid(
+                row=settings_row, column=2, columnspan=3, sticky=E, pady=5
+            )
+            settings_row += 1
 
         report_format_label = Label(settings_frame, text="Report Format:")
-        report_format_label.grid(row=2, column=1, sticky=W)
+        report_format_label.grid(row=settings_row, column=1, sticky=W)
         self.report_format = StringVar(self._root, name="report_format")
         self.report_format.set(self.config.default_report_format)
         report_format_menu = OptionMenu(
             settings_frame, self.report_format, *self.config.report_formats
         )
         report_format_menu.config(width=25)
-        report_format_menu.grid(row=2, column=2, columnspan=3, sticky=E, pady=5)
+        report_format_menu.grid(
+            row=settings_row, column=2, columnspan=3, sticky=E, pady=5
+        )
+        settings_row += 1
 
         input_format_label = Label(settings_frame, text="Input Format:")
-        input_format_label.grid(row=3, column=1, sticky=W)
+        input_format_label.grid(row=settings_row, column=1, sticky=W)
         self.input_format = StringVar(self._root, name="input_format")
         self.input_format.set(self.config.default_input_format)
         input_format_menu = OptionMenu(
             settings_frame, self.input_format, *self.config.input_formats
         )
         input_format_menu.config(width=25)
-        input_format_menu.grid(row=3, column=2, columnspan=3, sticky=E, pady=5)
+        input_format_menu.grid(
+            row=settings_row, column=2, columnspan=3, sticky=E, pady=5
+        )
+        settings_row += 1
 
         self.halt_on_failure = BooleanVar(self._root, name="halt_on_failure")
         self.halt_on_failure.set(self.config.default_halt_on_failure)
         halt_on_failure_label = Label(settings_frame, text="Halt on Basic Failures:")
-        halt_on_failure_label.grid(row=4, column=1, sticky=E, pady=5)
+        halt_on_failure_label.grid(row=settings_row, column=1, sticky=E, pady=5)
         halt_checkbox = Checkbutton(
             settings_frame, offvalue=False, onvalue=True, variable=self.halt_on_failure
         )
-        halt_checkbox.grid(row=4, column=2, columnspan=2, sticky=W, pady=5)
+        halt_checkbox.grid(row=settings_row, column=2, columnspan=2, sticky=W, pady=5)
 
         directory_label = Label(actions, text="Template Location:")
         directory_label.grid(row=4, column=1, pady=5, sticky=W)
@@ -528,8 +781,10 @@ class ValidatorApp:
         directory_browse = Button(actions, text="...", command=self.ask_template_source)
         directory_browse.grid(row=4, column=3, pady=5, sticky=W)
 
-        validate = Button(actions, text="Validate Templates", command=self.validate)
-        validate.grid(row=5, column=1, columnspan=2, pady=5)
+        validate_button = Button(
+            actions, text="Validate Templates", command=self.validate
+        )
+        validate_button.grid(row=5, column=1, columnspan=2, pady=5)
 
         self.result_panel = Frame(actions)
         # We'll add these labels now, and then make them visible when the run completes
@@ -539,6 +794,13 @@ class ValidatorApp:
         )
         self.underline(self.result_label)
         self.result_label.bind("<Button-1>", self.open_report)
+
+        self.preload_label = Label(
+            self.result_panel, text="View Preloads", fg="blue", cursor="hand2"
+        )
+        self.underline(self.preload_label)
+        self.preload_label.bind("<Button-1>", self.open_preloads)
+
         self.result_panel.grid(row=6, column=1, columnspan=2)
         control_panel.pack(fill=BOTH, expand=1)
 
@@ -554,10 +816,12 @@ class ValidatorApp:
         # room for them
         self.completion_label.pack()
         self.result_label.pack()  # Show report link
+        self.preload_label.pack()  # Show preload link
         self._root.after_idle(
             lambda: (
                 self.completion_label.pack_forget(),
                 self.result_label.pack_forget(),
+                self.preload_label.pack_forget(),
             )
         )
 
@@ -568,7 +832,44 @@ class ValidatorApp:
             self.report_format,
             self.halt_on_failure,
         )
+        if self.config.preload_formats:
+            self.config.watch(self.preload_format)
         self.schedule(self.execute_pollers)
+        if self.config.terms_link_text and not self.config.are_terms_accepted:
+            TermsAndConditionsDialog(parent_frame, self.config)
+            if not self.config.are_terms_accepted:
+                self.shutdown()
+
+    def create_footer(self, parent_frame):
+        footer = Frame(parent_frame)
+        disclaimer = Message(footer, text=self.config.disclaimer_text, anchor=CENTER)
+        disclaimer.grid(row=0, pady=2)
+        parent_frame.bind(
+            "<Configure>", lambda e: disclaimer.configure(width=e.width - 20)
+        )
+        if self.config.requirement_link_text:
+            requirement_link = Text(
+                footer,
+                height=1,
+                bg=disclaimer.cget("bg"),
+                relief=FLAT,
+                font=disclaimer.cget("font"),
+            )
+            requirement_link.tag_configure("center", justify="center")
+            hyperlinks = HyperlinkManager(requirement_link)
+            requirement_link.insert(INSERT, "Validating: ")
+            requirement_link.insert(
+                INSERT,
+                self.config.requirement_link_text,
+                hyperlinks.add(self.open_requirements),
+            )
+            requirement_link.tag_add("center", "1.0", "end")
+            requirement_link.config(state=DISABLED)
+            requirement_link.grid(row=1, pady=2)
+            ToolTip(requirement_link, self.config.requirement_link_url)
+        footer.grid_columnconfigure(0, weight=1)
+        footer.pack(fill=BOTH, expand=True)
+        return footer
 
     def ask_template_source(self):
         if self.input_format.get() == "ZIP File":
@@ -595,6 +896,7 @@ class ValidatorApp:
             self.clear_log()
             self.completion_label.pack_forget()
             self.result_label.pack_forget()
+            self.preload_label.pack_forget()
             self.task = multiprocessing.Process(
                 target=run_pytest,
                 args=(
@@ -651,6 +953,8 @@ class ValidatorApp:
             if is_success:
                 self.completion_label.pack()
                 self.result_label.pack()  # Show report link
+                if hasattr(self, "preload_format"):
+                    self.preload_label.pack()  # Show preload link
             else:
                 self.log_panel.insert(END, str(e))
 
@@ -694,10 +998,30 @@ class ValidatorApp:
         ext = ext_mapping.get(self.report_format.get().lower())
         return os.path.join(PATH, OUT_DIR, "report.{}".format(ext))
 
+    # noinspection PyUnusedLocal
     def open_report(self, event):
         """Open the report in the user's default browser"""
         webbrowser.open_new("file://{}".format(self.report_file_path))
 
+    def open_preloads(self, event):
+        """Open the report in the user's default browser"""
+        path = os.path.join(
+            PATH,
+            OUT_DIR,
+            "preloads",
+            self.config.get_subdir_for_preload(self.preload_format.get()),
+        )
+        if platform.system() == "Windows":
+            os.startfile(path)  # nosec
+        elif platform.system() == "Darwin":
+            subprocess.Popen(["open", path])  # nosec
+        else:
+            subprocess.Popen(["xdg-open", path])  # nosec
+
+    def open_requirements(self):
+        """Open the report in the user's default browser"""
+        webbrowser.open_new(self.config.requirement_link_url)
+
     def start(self):
         """Start the event loop of the application.  This method does not return"""
         self._root.mainloop()