X-Git-Url: https://gerrit.onap.org/r/gitweb?a=blobdiff_plain;f=ice_validator%2Fvvp.py;h=a998fd18789d8f22c096efd2c6f7d5d51827b244;hb=0c4e64d87728b89aa9cd4d41d738f5bfe64ceee3;hp=8d1fc69103362254f58a89cef90304302b1d1aff;hpb=961f572383ff3398bcafc802682b92f23f8ab1fe;p=vvp%2Fvalidation-scripts.git diff --git a/ice_validator/vvp.py b/ice_validator/vvp.py index 8d1fc69..a998fd1 100644 --- a/ice_validator/vvp.py +++ b/ice_validator/vvp.py @@ -35,7 +35,6 @@ # # ============LICENSE_END============================================ # -# ECOMP is a trademark and service mark of AT&T Intellectual Property. # """ @@ -47,21 +46,21 @@ To make an executable for windows execute the ``make_exe.bat`` to generate the NOTE: This script does require Python 3.6+ """ -import appdirs + import os +import traceback + import pytest -import sys import version -import yaml import contextlib import multiprocessing import queue import tempfile import webbrowser import zipfile +import platform +import subprocess # nosec -from collections import MutableMapping -from configparser import ConfigParser from multiprocessing import Queue from pathlib import Path from shutil import rmtree @@ -89,9 +88,22 @@ 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 +from typing import Optional, TextIO, Callable + +from config import Config VERSION = version.VERSION PATH = os.path.dirname(os.path.realpath(__file__)) @@ -104,8 +116,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("", self.enter) @@ -114,9 +126,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() @@ -126,17 +140,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) @@ -159,41 +174,42 @@ class ToolTip(object): tw.destroy() -class QueueWriter: - """``stdout`` and ``stderr`` will be written to this queue by pytest, and - pulled into the main GUI application""" - - def __init__(self, log_queue: queue.Queue): - """Writes data to the provided queue. - - :param log_queue: the queue instance to write to. - """ - self.queue = log_queue - - def write(self, data: str): - """Writes ``data`` to the queue """ - self.queue.put(data) - - # noinspection PyMethodMayBeStatic - def isatty(self) -> bool: - """Always returns ``False``""" - return False - - def flush(self): - """No operation method to satisfy file-like behavior""" - 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 +class HyperlinkManager: + """Adapted from http://effbot.org/zone/tkinter-text-hyperlink.htm""" - return [pytest_tap.plugin] - else: - return None + def __init__(self, text): + self.links = {} + self.text = text + self.text.tag_config("hyper", foreground="blue", underline=1) + self.text.tag_bind("hyper", "", self._enter) + self.text.tag_bind("hyper", "", self._leave) + self.text.tag_bind("hyper", "", 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 def run_pytest( @@ -201,10 +217,11 @@ def run_pytest( log: TextIO, result_queue: Queue, categories: Optional[list], - verbosity: str, report_format: str, halt_on_failure: bool, template_source: str, + env_dir: str, + preload_format: list, ): """Runs pytest using the given ``profile`` in a background process. All ``stdout`` and ``stderr`` are redirected to ``log``. The result of the job @@ -219,15 +236,16 @@ def run_pytest( will collect and execute all tests that are decorated with any of the passed categories, as well as tests not decorated with a category. - :param verbosity: Flag to be passed to pytest to control verbosity. - Options are '' (empty string), '-v' (verbose), - '-vv' (more verbose). :param report_format: Determines the style of report written. Options are csv, html, or excel :param halt_on_failure: Determines if validation will halt when basic failures 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 + :param env_dir: Optional directory of env files that can be used + to generate populated preload templates + :param preload_format: Selected preload format """ out_path = "{}/{}".format(PATH, OUT_DIR) if os.path.exists(out_path): @@ -237,211 +255,119 @@ def run_pytest( args = [ "--ignore=app_tests", "--capture=sys", - verbosity, "--template-directory={}".format(template_dir), "--report-format={}".format(report_format), "--template-source={}".format(template_source), ] + if env_dir: + args.append("--env-directory={}".format(env_dir)) if categories: for category in categories: args.extend(("--category", category)) if not halt_on_failure: args.append("--continue-on-failure") - pytest.main(args=args, plugins=get_plugins()) + if preload_format: + args.append("--preload-format={}".format(preload_format)) + pytest.main(args=args) result_queue.put((True, None)) - except Exception as e: - result_queue.put((False, e)) - - -class UserSettings(MutableMapping): - FILE_NAME = "UserSettings.ini" - - def __init__(self): - user_config_dir = appdirs.AppDirs("org.onap.vvp", "ONAP").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) - self._config = ConfigParser() - self._config.read(self._settings_path) - - def __getitem__(self, k): - return self._config["DEFAULT"][k] - - def __setitem__(self, k, v) -> None: - self._config["DEFAULT"][k] = v - - def __delitem__(self, v) -> None: - del self._config["DEFAULT"][v] - - def __len__(self) -> int: - return len(self._config["DEFAULT"]) - - def __iter__(self) -> Iterator: - return iter(self._config["DEFAULT"]) + except Exception: + result_queue.put((False, traceback.format_exc())) - def save(self): - with open(self._settings_path, "w") as f: - self._config.write(f) - -class Config: +class Dialog(Toplevel): """ - Configuration for the Validation GUI Application - - Attributes - ---------- - ``log_queue`` Queue for the ``stdout`` and ``stderr` of - the background job - ``log_file`` File-like object (write only!) that writes to - the ``log_queue`` - ``status_queue`` Job completion status of the background job is - posted here as a tuple of (bool, Exception). - The first parameter is True if the job completed - successfully, and False otherwise. If the job - failed, then an Exception will be provided as the - second element. - ``command_queue`` Used to send commands to the GUI. Currently only - used to send shutdown commands in tests. + Adapted from http://www.effbot.org/tkinterbook/tkinter-dialog-windows.htm """ - DEFAULT_FILENAME = "vvp-config.yaml" - DEFAULT_POLLING_FREQUENCY = "1000" - - def __init__(self, config: dict = None): - """Creates instance of application configuration. - - :param config: override default configuration if provided.""" - if config: - self._config = config - else: - with open(self.DEFAULT_FILENAME, "r") as f: - self._config = yaml.load(f) - self._user_settings = UserSettings() - self._watched_variables = [] - self._validate() - self._manager = multiprocessing.Manager() - self.log_queue = self._manager.Queue() - self.status_queue = self._manager.Queue() - self.log_file = QueueWriter(self.log_queue) - self.command_queue = self._manager.Queue() - - def watch(self, *variables): - """Traces the variables and saves their settings for the user. The - last settings will be used where available""" - self._watched_variables = variables - for var in self._watched_variables: - var.trace_add("write", self.save_settings) - - def save_settings(self, *args): - """Save the value of all watched variables to user settings""" - for var in self._watched_variables: - self._user_settings[var._name] = str(var.get()) - self._user_settings.save() - - @property - def app_name(self) -> str: - """Name of the application (displayed in title bar)""" - app_name = self._config["ui"].get("app-name", "VNF Validation Tool") - return "{} - {}".format(app_name, VERSION) - - @property - def category_names(self) -> List[str]: - """List of validation profile names for display in the UI""" - return [category["name"] for category in self._config["categories"]] - - @property - def polling_frequency(self) -> int: - """Returns the frequency (in ms) the UI polls the queue communicating - with any background job""" - return int( - self._config["settings"].get( - "polling-frequency", self.DEFAULT_POLLING_FREQUENCY - ) + 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) ) - - def default_verbosity(self, levels: Dict[str, str]) -> str: - requested_level = self._user_settings.get("verbosity") or self._config[ - "settings" - ].get("default-verbosity", "Standard") - keys = [key for key in levels] - for key in levels: - if key.lower().startswith(requested_level.lower()): - return key - raise RuntimeError( - "Invalid default-verbosity level {}. Valid" - "values are {}".format(requested_level, ", ".join(keys)) + 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, ) - - def get_description(self, category_name: str) -> str: - """Returns the description associated with the category name""" - return self._get_category(category_name)["description"] - - def get_category(self, category_name: str) -> str: - """Returns the category associated with the category name""" - return self._get_category(category_name).get("category", "") - - def get_category_value(self, category_name: str) -> str: - """Returns the saved value for a category name""" - return self._user_settings.get(category_name, 0) - - def _get_category(self, category_name: str) -> Dict[str, str]: - """Returns the profile definition""" - for category in self._config["categories"]: - if category["name"] == category_name: - return category - raise RuntimeError( - "Unexpected error: No category found in vvp-config.yaml " - "with a name of " + category_name + 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("", self.ok) + self.bind("", self.cancel) + box.pack() + + # noinspection PyUnusedLocal + def ok(self, event=None): + 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("", self.open_terms) + tc_link.grid(row=1, pady=5) - @property - def default_report_format(self): - return self._user_settings.get("report_format", "HTML") - - @property - def report_formats(self): - return ["CSV", "Excel", "HTML"] - - @property - def default_input_format(self): - requested_default = self._user_settings.get("input_format") or self._config[ - "settings" - ].get("default-input-format") - if requested_default in self.input_formats: - return requested_default - else: - return self.input_formats[0] - - @property - def input_formats(self): - return ["Directory (Uncompressed)", "ZIP File"] + # noinspection PyUnusedLocal + def open_terms(self, event): + webbrowser.open(self.config.terms_link_url) + self.activate_buttons() - @property - def default_halt_on_failure(self): - setting = self._user_settings.get("halt_on_failure", "True") - return setting.lower() == "true" - - def _validate(self): - """Ensures the config file is properly formatted""" - categories = self._config["categories"] - - # All profiles have required keys - expected_keys = {"name", "description"} - for category in categories: - actual_keys = set(category.keys()) - missing_keys = expected_keys.difference(actual_keys) - if missing_keys: - raise RuntimeError( - "Error in vvp-config.yaml file: " - "Required field missing in category. " - "Missing: {} " - "Categories: {}".format(",".join(missing_keys), category) - ) + def apply(self): + self.config.set_terms_accepted() class ValidatorApp: - VERBOSITY_LEVELS = {"Less": "", "Standard (-v)": "-v", "More (-vv)": "-vv"} - def __init__(self, config: Config = None): """Constructs the GUI element of the Validation Tool""" self.task = None @@ -451,7 +377,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( @@ -459,7 +394,11 @@ class ValidatorApp: ) actions = Frame(control_panel) control_panel.add(actions) - control_panel.paneconfigure(actions, minsize=250) + control_panel.paneconfigure(actions, minsize=350) + + 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) @@ -472,6 +411,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( @@ -481,45 +421,80 @@ 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) - 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) + + 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 = Label( + settings_frame, text="Halt on Basic Failures:", anchor=W, justify=LEFT + ) + halt_on_failure_label.grid(row=settings_row, column=1, sticky=W, 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) + settings_row += 1 + + self.create_preloads = BooleanVar(self._root, name="create_preloads") + self.create_preloads.set(self.config.default_create_preloads) + create_preloads_label = Label( + settings_frame, + text="Create Preload from Env Files:", + anchor=W, + justify=LEFT, + ) + create_preloads_label.grid(row=settings_row, column=1, sticky=W, pady=5) + create_preloads_checkbox = Checkbutton( + settings_frame, + offvalue=False, + onvalue=True, + variable=self.create_preloads, + command=self.set_env_dir_state, + ) + create_preloads_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) @@ -529,8 +504,21 @@ 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) + env_dir_label = Label(actions, text="Env Files:") + env_dir_label.grid(row=5, column=1, pady=5, sticky=W) + self.env_dir = StringVar(self._root, name="env_dir") + env_dir_state = NORMAL if self.create_preloads.get() else DISABLED + self.env_dir_entry = Entry( + actions, width=40, textvariable=self.env_dir, state=env_dir_state + ) + self.env_dir_entry.grid(row=5, column=2, pady=5, sticky=W) + env_dir_browse = Button(actions, text="...", command=self.ask_env_dir_source) + env_dir_browse.grid(row=5, column=3, pady=5, sticky=W) + + validate_button = Button( + actions, text="Process Templates", command=self.validate + ) + validate_button.grid(row=6, 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 @@ -540,7 +528,14 @@ class ValidatorApp: ) self.underline(self.result_label) self.result_label.bind("", self.open_report) - self.result_panel.grid(row=6, column=1, columnspan=2) + + self.preload_label = Label( + self.result_panel, text="View Preload Templates", fg="blue", cursor="hand2" + ) + self.underline(self.preload_label) + self.preload_label.bind("", self.open_preloads) + + self.result_panel.grid(row=7, column=1, columnspan=2) control_panel.pack(fill=BOTH, expand=1) main_window.add(control_panel) @@ -555,21 +550,63 @@ 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(), ) ) self.config.watch( *self.categories, - self.verbosity, self.input_format, self.report_format, self.halt_on_failure, + self.preload_format, + self.create_preloads, ) 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( + "", 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 set_env_dir_state(self): + state = NORMAL if self.create_preloads.get() else DISABLED + self.env_dir_entry.config(state=state) def ask_template_source(self): if self.input_format.get() == "ZIP File": @@ -581,6 +618,9 @@ class ValidatorApp: template_source = filedialog.askdirectory() self.template_source.set(template_source) + def ask_env_dir_source(self): + self.env_dir.set(filedialog.askdirectory()) + def validate(self): """Run the pytest validations in a background process""" if not self.delete_prior_report(): @@ -596,6 +636,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=( @@ -603,10 +644,11 @@ class ValidatorApp: self.config.log_file, self.config.status_queue, self.categories_list(), - self.VERBOSITY_LEVELS[self.verbosity.get()], self.report_format.get().lower(), self.halt_on_failure.get(), self.template_source.get(), + self.env_dir.get(), + self.preload_format.get(), ), ) self.task.daemon = True @@ -652,6 +694,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)) @@ -695,9 +739,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)) + path = Path(self.report_file_path).absolute().resolve().as_uri() + webbrowser.open_new(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"""