[VVP] Adding preload generation functionality
[vvp/validation-scripts.git] / ice_validator / vvp.py
1 # -*- coding: utf8 -*-
2 # ============LICENSE_START====================================================
3 # org.onap.vvp/validation-scripts
4 # ===================================================================
5 # Copyright © 2019 AT&T Intellectual Property. All rights reserved.
6 # ===================================================================
7 #
8 # Unless otherwise specified, all software contained herein is licensed
9 # under the Apache License, Version 2.0 (the "License");
10 # you may not use this software except in compliance with the License.
11 # You may obtain a copy of the License at
12 #
13 #             http://www.apache.org/licenses/LICENSE-2.0
14 #
15 # Unless required by applicable law or agreed to in writing, software
16 # distributed under the License is distributed on an "AS IS" BASIS,
17 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18 # See the License for the specific language governing permissions and
19 # limitations under the License.
20 #
21 #
22 #
23 # Unless otherwise specified, all documentation contained herein is licensed
24 # under the Creative Commons License, Attribution 4.0 Intl. (the "License");
25 # you may not use this documentation except in compliance with the License.
26 # You may obtain a copy of the License at
27 #
28 #             https://creativecommons.org/licenses/by/4.0/
29 #
30 # Unless required by applicable law or agreed to in writing, documentation
31 # distributed under the License is distributed on an "AS IS" BASIS,
32 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
33 # See the License for the specific language governing permissions and
34 # limitations under the License.
35 #
36 # ============LICENSE_END============================================
37 #
38 #
39
40 """
41 A GUI that wraps the pytest validations scripts.
42
43 To make an executable for windows execute  the ``make_exe.bat`` to generate the
44 .exe and its associated files.  The the necessary files will be written to the
45 ``dist/vvp/`` directory.  This entire directory must be copied to the target machine.
46
47 NOTE: This script does require Python 3.6+
48 """
49 import appdirs
50 import os
51 import pytest
52 import version
53 import yaml
54 import contextlib
55 import multiprocessing
56 import queue
57 import tempfile
58 import webbrowser
59 import zipfile
60 import platform
61 import subprocess  # nosec
62
63 from collections import MutableMapping
64 from configparser import ConfigParser
65 from multiprocessing import Queue
66 from pathlib import Path
67 from shutil import rmtree
68 from tkinter import (
69     filedialog,
70     font,
71     messagebox,
72     Tk,
73     PanedWindow,
74     BOTH,
75     HORIZONTAL,
76     RAISED,
77     Frame,
78     Label,
79     W,
80     StringVar,
81     OptionMenu,
82     LabelFrame,
83     E,
84     BooleanVar,
85     Entry,
86     Button,
87     WORD,
88     END,
89     Checkbutton,
90     IntVar,
91     Toplevel,
92     Message,
93     CURRENT,
94     Text,
95     INSERT,
96     DISABLED,
97     FLAT,
98     CENTER,
99     ACTIVE,
100     LEFT,
101     Menu,
102     NORMAL,
103 )
104 from tkinter.scrolledtext import ScrolledText
105 from typing import Optional, List, Dict, TextIO, Callable, Iterator
106
107 import preload
108
109 VERSION = version.VERSION
110 PATH = os.path.dirname(os.path.realpath(__file__))
111 OUT_DIR = "output"
112
113
114 class ToolTip(object):
115     """
116     create a tooltip for a given widget
117     """
118
119     def __init__(self, widget, text="widget info"):
120         self.waittime = 750  # milliseconds
121         self.wraplength = 300  # pixels
122         self.widget = widget
123         self.text = text
124         self.widget.bind("<Enter>", self.enter)
125         self.widget.bind("<Leave>", self.leave)
126         self.widget.bind("<ButtonPress>", self.leave)
127         self.id = None
128         self.tw = None
129
130     # noinspection PyUnusedLocal
131     def enter(self, event=None):
132         self.schedule()
133
134     # noinspection PyUnusedLocal
135     def leave(self, event=None):
136         self.unschedule()
137         self.hidetip()
138
139     def schedule(self):
140         self.unschedule()
141         self.id = self.widget.after(self.waittime, self.showtip)
142
143     def unschedule(self):
144         orig_id = self.id
145         self.id = None
146         if orig_id:
147             self.widget.after_cancel(orig_id)
148
149     # noinspection PyUnusedLocal
150     def showtip(self, event=None):
151         x = y = 0
152         x, y, cx, cy = self.widget.bbox("insert")
153         x += self.widget.winfo_rootx() + 25
154         y += self.widget.winfo_rooty() + 20
155         # creates a top level window
156         self.tw = Toplevel(self.widget)
157         # Leaves only the label and removes the app window
158         self.tw.wm_overrideredirect(True)
159         self.tw.wm_geometry("+%d+%d" % (x, y))
160         label = Label(
161             self.tw,
162             text=self.text,
163             justify="left",
164             background="#ffffff",
165             relief="solid",
166             borderwidth=1,
167             wraplength=self.wraplength,
168         )
169         label.pack(ipadx=1)
170
171     def hidetip(self):
172         tw = self.tw
173         self.tw = None
174         if tw:
175             tw.destroy()
176
177
178 class HyperlinkManager:
179     """Adapted from http://effbot.org/zone/tkinter-text-hyperlink.htm"""
180
181     def __init__(self, text):
182         self.links = {}
183         self.text = text
184         self.text.tag_config("hyper", foreground="blue", underline=1)
185         self.text.tag_bind("hyper", "<Enter>", self._enter)
186         self.text.tag_bind("hyper", "<Leave>", self._leave)
187         self.text.tag_bind("hyper", "<Button-1>", self._click)
188         self.reset()
189
190     def reset(self):
191         self.links.clear()
192
193     def add(self, action):
194         # add an action to the manager.  returns tags to use in
195         # associated text widget
196         tag = "hyper-%d" % len(self.links)
197         self.links[tag] = action
198         return "hyper", tag
199
200     # noinspection PyUnusedLocal
201     def _enter(self, event):
202         self.text.config(cursor="hand2")
203
204     # noinspection PyUnusedLocal
205     def _leave(self, event):
206         self.text.config(cursor="")
207
208     # noinspection PyUnusedLocal
209     def _click(self, event):
210         for tag in self.text.tag_names(CURRENT):
211             if tag[:6] == "hyper-":
212                 self.links[tag]()
213                 return
214
215
216 class QueueWriter:
217     """``stdout`` and ``stderr`` will be written to this queue by pytest, and
218     pulled into the main GUI application"""
219
220     def __init__(self, log_queue: queue.Queue):
221         """Writes data to the provided queue.
222
223         :param log_queue: the queue instance to write to.
224         """
225         self.queue = log_queue
226
227     def write(self, data: str):
228         """Writes ``data`` to the queue """
229         self.queue.put(data)
230
231     # noinspection PyMethodMayBeStatic
232     def isatty(self) -> bool:
233         """Always returns ``False``"""
234         return False
235
236     def flush(self):
237         """No operation method to satisfy file-like behavior"""
238         pass
239
240
241 def run_pytest(
242     template_dir: str,
243     log: TextIO,
244     result_queue: Queue,
245     categories: Optional[list],
246     verbosity: str,
247     report_format: str,
248     halt_on_failure: bool,
249     template_source: str,
250 ):
251     """Runs pytest using the given ``profile`` in a background process.  All
252     ``stdout`` and ``stderr`` are redirected to ``log``.  The result of the job
253     will be put on the ``completion_queue``
254
255     :param template_dir:        The directory containing the files to be validated.
256     :param log: `               `stderr`` and ``stdout`` of the pytest job will be
257                                 directed here
258     :param result_queue:        Completion status posted here.  See :class:`Config`
259                                 for more information.
260     :param categories:          list of optional categories. When provided, pytest
261                                 will collect and execute all tests that are
262                                 decorated with any of the passed categories, as
263                                 well as tests not decorated with a category.
264     :param verbosity:           Flag to be passed to pytest to control verbosity.
265                                 Options are '' (empty string), '-v' (verbose),
266                                 '-vv' (more verbose).
267     :param report_format:       Determines the style of report written.  Options are
268                                 csv, html, or excel
269     :param halt_on_failure:     Determines if validation will halt when basic failures
270                                 are encountered in the input files.  This can help
271                                 prevent a large number of errors from flooding the
272                                 report.
273     :param template_source:     The path or name of the template to show on the report
274     """
275     out_path = "{}/{}".format(PATH, OUT_DIR)
276     if os.path.exists(out_path):
277         rmtree(out_path, ignore_errors=True)
278     with contextlib.redirect_stderr(log), contextlib.redirect_stdout(log):
279         try:
280             args = [
281                 "--ignore=app_tests",
282                 "--capture=sys",
283                 verbosity,
284                 "--template-directory={}".format(template_dir),
285                 "--report-format={}".format(report_format),
286                 "--template-source={}".format(template_source),
287             ]
288             if categories:
289                 for category in categories:
290                     args.extend(("--category", category))
291             if not halt_on_failure:
292                 args.append("--continue-on-failure")
293             pytest.main(args=args)
294             result_queue.put((True, None))
295         except Exception as e:
296             result_queue.put((False, e))
297
298
299 class UserSettings(MutableMapping):
300     FILE_NAME = "UserSettings.ini"
301
302     def __init__(self, namespace, owner):
303         user_config_dir = appdirs.AppDirs(namespace, owner).user_config_dir
304         if not os.path.exists(user_config_dir):
305             os.makedirs(user_config_dir, exist_ok=True)
306         self._settings_path = os.path.join(user_config_dir, self.FILE_NAME)
307         self._config = ConfigParser()
308         self._config.read(self._settings_path)
309
310     def __getitem__(self, k):
311         return self._config["DEFAULT"][k]
312
313     def __setitem__(self, k, v) -> None:
314         self._config["DEFAULT"][k] = v
315
316     def __delitem__(self, v) -> None:
317         del self._config["DEFAULT"][v]
318
319     def __len__(self) -> int:
320         return len(self._config["DEFAULT"])
321
322     def __iter__(self) -> Iterator:
323         return iter(self._config["DEFAULT"])
324
325     def save(self):
326         with open(self._settings_path, "w") as f:
327             self._config.write(f)
328
329
330 class Config:
331     """
332     Configuration for the Validation GUI Application
333
334     Attributes
335     ----------
336     ``log_queue``       Queue for the ``stdout`` and ``stderr` of
337                         the background job
338     ``log_file``        File-like object (write only!) that writes to
339                         the ``log_queue``
340     ``status_queue``    Job completion status of the background job is
341                         posted here as a tuple of (bool, Exception).
342                         The first parameter is True if the job completed
343                         successfully, and False otherwise.  If the job
344                         failed, then an Exception will be provided as the
345                         second element.
346     ``command_queue``   Used to send commands to the GUI.  Currently only
347                         used to send shutdown commands in tests.
348     """
349
350     DEFAULT_FILENAME = "vvp-config.yaml"
351     DEFAULT_POLLING_FREQUENCY = "1000"
352
353     def __init__(self, config: dict = None):
354         """Creates instance of application configuration.
355
356         :param config: override default configuration if provided."""
357         if config:
358             self._config = config
359         else:
360             with open(self.DEFAULT_FILENAME, "r") as f:
361                 self._config = yaml.safe_load(f)
362         self._user_settings = UserSettings(
363             self._config["namespace"], self._config["owner"]
364         )
365         self._watched_variables = []
366         self._validate()
367         self._manager = multiprocessing.Manager()
368         self.log_queue = self._manager.Queue()
369         self.status_queue = self._manager.Queue()
370         self.log_file = QueueWriter(self.log_queue)
371         self.command_queue = self._manager.Queue()
372
373     def watch(self, *variables):
374         """Traces the variables and saves their settings for the user.  The
375         last settings will be used where available"""
376         self._watched_variables = variables
377         for var in self._watched_variables:
378             var.trace_add("write", self.save_settings)
379
380     # noinspection PyProtectedMember,PyUnusedLocal
381     def save_settings(self, *args):
382         """Save the value of all watched variables to user settings"""
383         for var in self._watched_variables:
384             self._user_settings[var._name] = str(var.get())
385         self._user_settings.save()
386
387     @property
388     def app_name(self) -> str:
389         """Name of the application (displayed in title bar)"""
390         app_name = self._config["ui"].get("app-name", "VNF Validation Tool")
391         return "{} - {}".format(app_name, VERSION)
392
393     @property
394     def category_names(self) -> List[str]:
395         """List of validation profile names for display in the UI"""
396         return [category["name"] for category in self._config["categories"]]
397
398     @property
399     def polling_frequency(self) -> int:
400         """Returns the frequency (in ms) the UI polls the queue communicating
401         with any background job"""
402         return int(
403             self._config["settings"].get(
404                 "polling-frequency", self.DEFAULT_POLLING_FREQUENCY
405             )
406         )
407
408     @property
409     def disclaimer_text(self) -> str:
410         return self._config["ui"].get("disclaimer-text", "")
411
412     @property
413     def requirement_link_text(self) -> str:
414         return self._config["ui"].get("requirement-link-text", "")
415
416     @property
417     def requirement_link_url(self) -> str:
418         path = self._config["ui"].get("requirement-link-url", "")
419         if not path.startswith("http"):
420             path = "file://{}".format(os.path.join(PATH, path))
421         return path
422
423     @property
424     def terms(self) -> dict:
425         return self._config.get("terms", {})
426
427     @property
428     def terms_link_url(self) -> Optional[str]:
429         return self.terms.get("path")
430
431     @property
432     def terms_link_text(self):
433         return self.terms.get("popup-link-text")
434
435     @property
436     def terms_version(self) -> Optional[str]:
437         return self.terms.get("version")
438
439     @property
440     def terms_popup_title(self) -> Optional[str]:
441         return self.terms.get("popup-title")
442
443     @property
444     def terms_popup_message(self) -> Optional[str]:
445         return self.terms.get("popup-msg-text")
446
447     @property
448     def are_terms_accepted(self) -> bool:
449         version = "terms-{}".format(self.terms_version)
450         return self._user_settings.get(version, "False") == "True"
451
452     def set_terms_accepted(self):
453         version = "terms-{}".format(self.terms_version)
454         self._user_settings[version] = "True"
455         self._user_settings.save()
456
457     def default_verbosity(self, levels: Dict[str, str]) -> str:
458         requested_level = self._user_settings.get("verbosity") or self._config[
459             "settings"
460         ].get("default-verbosity", "Standard")
461         keys = [key for key in levels]
462         for key in levels:
463             if key.lower().startswith(requested_level.lower()):
464                 return key
465         raise RuntimeError(
466             "Invalid default-verbosity level {}. Valid "
467             "values are {}".format(requested_level, ", ".join(keys))
468         )
469
470     def get_description(self, category_name: str) -> str:
471         """Returns the description associated with the category name"""
472         return self._get_category(category_name)["description"]
473
474     def get_category(self, category_name: str) -> str:
475         """Returns the category associated with the category name"""
476         return self._get_category(category_name).get("category", "")
477
478     def get_category_value(self, category_name: str) -> str:
479         """Returns the saved value for a category name"""
480         return self._user_settings.get(category_name, 0)
481
482     def _get_category(self, category_name: str) -> Dict[str, str]:
483         """Returns the profile definition"""
484         for category in self._config["categories"]:
485             if category["name"] == category_name:
486                 return category
487         raise RuntimeError(
488             "Unexpected error: No category found in vvp-config.yaml "
489             "with a name of " + category_name
490         )
491
492     @property
493     def default_report_format(self):
494         return self._user_settings.get("report_format", "HTML")
495
496     @property
497     def report_formats(self):
498         return ["CSV", "Excel", "HTML"]
499
500     @property
501     def preload_formats(self):
502         excluded = self._config.get("excluded-preloads", [])
503         formats = (cls.format_name() for cls in preload.get_generator_plugins())
504         return [f for f in formats if f not in excluded]
505
506     @property
507     def default_preload_format(self):
508         default = self._user_settings.get("preload_format")
509         if default and default in self.preload_formats:
510             return default
511         else:
512             return self.preload_formats[0]
513
514     @staticmethod
515     def get_subdir_for_preload(preload_format):
516         for gen in preload.get_generator_plugins():
517             if gen.format_name() == preload_format:
518                 return gen.output_sub_dir()
519         return ""
520
521     @property
522     def default_input_format(self):
523         requested_default = self._user_settings.get("input_format") or self._config[
524             "settings"
525         ].get("default-input-format")
526         if requested_default in self.input_formats:
527             return requested_default
528         else:
529             return self.input_formats[0]
530
531     @property
532     def input_formats(self):
533         return ["Directory (Uncompressed)", "ZIP File"]
534
535     @property
536     def default_halt_on_failure(self):
537         setting = self._user_settings.get("halt_on_failure", "True")
538         return setting.lower() == "true"
539
540     def _validate(self):
541         """Ensures the config file is properly formatted"""
542         categories = self._config["categories"]
543
544         # All profiles have required keys
545         expected_keys = {"name", "description"}
546         for category in categories:
547             actual_keys = set(category.keys())
548             missing_keys = expected_keys.difference(actual_keys)
549             if missing_keys:
550                 raise RuntimeError(
551                     "Error in vvp-config.yaml file: "
552                     "Required field missing in category. "
553                     "Missing: {} "
554                     "Categories: {}".format(",".join(missing_keys), category)
555                 )
556
557
558 def validate():
559     return True
560
561
562 class Dialog(Toplevel):
563     """
564     Adapted from http://www.effbot.org/tkinterbook/tkinter-dialog-windows.htm
565     """
566
567     def __init__(self, parent: Frame, title=None):
568         Toplevel.__init__(self, parent)
569         self.transient(parent)
570         if title:
571             self.title(title)
572         self.parent = parent
573         self.result = None
574         body = Frame(self)
575         self.initial_focus = self.body(body)
576         body.pack(padx=5, pady=5)
577         self.buttonbox()
578         self.grab_set()
579         if not self.initial_focus:
580             self.initial_focus = self
581         self.protocol("WM_DELETE_WINDOW", self.cancel)
582         self.geometry(
583             "+%d+%d" % (parent.winfo_rootx() + 600, parent.winfo_rooty() + 400)
584         )
585         self.initial_focus.focus_set()
586         self.wait_window(self)
587
588     def body(self, master):
589         raise NotImplementedError()
590
591     # noinspection PyAttributeOutsideInit
592     def buttonbox(self):
593         box = Frame(self)
594         self.accept = Button(
595             box,
596             text="Accept",
597             width=10,
598             state=DISABLED,
599             command=self.ok,
600             default=ACTIVE,
601         )
602         self.accept.pack(side=LEFT, padx=5, pady=5)
603         self.decline = Button(
604             box, text="Decline", width=10, state=DISABLED, command=self.cancel
605         )
606         self.decline.pack(side=LEFT, padx=5, pady=5)
607         self.bind("<Return>", self.ok)
608         self.bind("<Escape>", self.cancel)
609         box.pack()
610
611     # noinspection PyUnusedLocal
612     def ok(self, event=None):
613         if not validate():
614             self.initial_focus.focus_set()  # put focus back
615             return
616         self.withdraw()
617         self.update_idletasks()
618         self.apply()
619         self.cancel()
620
621     # noinspection PyUnusedLocal
622     def cancel(self, event=None):
623         self.parent.focus_set()
624         self.destroy()
625
626     def apply(self):
627         raise NotImplementedError()
628
629     def activate_buttons(self):
630         self.accept.configure(state=NORMAL)
631         self.decline.configure(state=NORMAL)
632
633
634 class TermsAndConditionsDialog(Dialog):
635     def __init__(self, parent, config: Config):
636         self.config = config
637         self.parent = parent
638         super().__init__(parent, config.terms_popup_title)
639
640     def body(self, master):
641         Label(master, text=self.config.terms_popup_message).grid(row=0, pady=5)
642         tc_link = Label(
643             master, text=self.config.terms_link_text, fg="blue", cursor="hand2"
644         )
645         ValidatorApp.underline(tc_link)
646         tc_link.bind("<Button-1>", self.open_terms)
647         tc_link.grid(row=1, pady=5)
648
649     # noinspection PyUnusedLocal
650     def open_terms(self, event):
651         webbrowser.open(self.config.terms_link_url)
652         self.activate_buttons()
653
654     def apply(self):
655         self.config.set_terms_accepted()
656
657
658 class ValidatorApp:
659     VERBOSITY_LEVELS = {"Less": "", "Standard (-v)": "-v", "More (-vv)": "-vv"}
660
661     def __init__(self, config: Config = None):
662         """Constructs the GUI element of the Validation Tool"""
663         self.task = None
664         self.config = config or Config()
665
666         self._root = Tk()
667         self._root.title(self.config.app_name)
668         self._root.protocol("WM_DELETE_WINDOW", self.shutdown)
669
670         if self.config.terms_link_text:
671             menubar = Menu(self._root)
672             menubar.add_command(
673                 label=self.config.terms_link_text,
674                 command=lambda: webbrowser.open(self.config.terms_link_url),
675             )
676             self._root.config(menu=menubar)
677
678         parent_frame = Frame(self._root)
679         main_window = PanedWindow(parent_frame)
680         main_window.pack(fill=BOTH, expand=1)
681
682         control_panel = PanedWindow(
683             main_window, orient=HORIZONTAL, sashpad=4, sashrelief=RAISED
684         )
685         actions = Frame(control_panel)
686         control_panel.add(actions)
687         control_panel.paneconfigure(actions, minsize=250)
688
689         if self.config.disclaimer_text or self.config.requirement_link_text:
690             self.footer = self.create_footer(parent_frame)
691         parent_frame.pack(fill=BOTH, expand=True)
692
693         # profile start
694         number_of_categories = len(self.config.category_names)
695         category_frame = LabelFrame(actions, text="Additional Validation Categories:")
696         category_frame.grid(row=1, column=1, columnspan=3, pady=5, sticky="we")
697
698         self.categories = []
699
700         for x in range(0, number_of_categories):
701             category_name = self.config.category_names[x]
702             category_value = IntVar(value=0)
703             category_value._name = "category_{}".format(category_name.replace(" ", "_"))
704             # noinspection PyProtectedMember
705             category_value.set(self.config.get_category_value(category_value._name))
706             self.categories.append(category_value)
707             category_checkbox = Checkbutton(
708                 category_frame, text=category_name, variable=self.categories[x]
709             )
710             ToolTip(category_checkbox, self.config.get_description(category_name))
711             category_checkbox.grid(row=x + 1, column=1, columnspan=2, sticky="w")
712
713         settings_frame = LabelFrame(actions, text="Settings")
714         settings_row = 1
715         settings_frame.grid(row=3, column=1, columnspan=3, pady=10, sticky="we")
716         verbosity_label = Label(settings_frame, text="Verbosity:")
717         verbosity_label.grid(row=settings_row, column=1, sticky=W)
718         self.verbosity = StringVar(self._root, name="verbosity")
719         self.verbosity.set(self.config.default_verbosity(self.VERBOSITY_LEVELS))
720         verbosity_menu = OptionMenu(
721             settings_frame, self.verbosity, *tuple(self.VERBOSITY_LEVELS.keys())
722         )
723         verbosity_menu.config(width=25)
724         verbosity_menu.grid(row=settings_row, column=2, columnspan=3, sticky=E, pady=5)
725         settings_row += 1
726
727         if self.config.preload_formats:
728             preload_format_label = Label(settings_frame, text="Preload Template:")
729             preload_format_label.grid(row=settings_row, column=1, sticky=W)
730             self.preload_format = StringVar(self._root, name="preload_format")
731             self.preload_format.set(self.config.default_preload_format)
732             preload_format_menu = OptionMenu(
733                 settings_frame, self.preload_format, *self.config.preload_formats
734             )
735             preload_format_menu.config(width=25)
736             preload_format_menu.grid(
737                 row=settings_row, column=2, columnspan=3, sticky=E, pady=5
738             )
739             settings_row += 1
740
741         report_format_label = Label(settings_frame, text="Report Format:")
742         report_format_label.grid(row=settings_row, column=1, sticky=W)
743         self.report_format = StringVar(self._root, name="report_format")
744         self.report_format.set(self.config.default_report_format)
745         report_format_menu = OptionMenu(
746             settings_frame, self.report_format, *self.config.report_formats
747         )
748         report_format_menu.config(width=25)
749         report_format_menu.grid(
750             row=settings_row, column=2, columnspan=3, sticky=E, pady=5
751         )
752         settings_row += 1
753
754         input_format_label = Label(settings_frame, text="Input Format:")
755         input_format_label.grid(row=settings_row, column=1, sticky=W)
756         self.input_format = StringVar(self._root, name="input_format")
757         self.input_format.set(self.config.default_input_format)
758         input_format_menu = OptionMenu(
759             settings_frame, self.input_format, *self.config.input_formats
760         )
761         input_format_menu.config(width=25)
762         input_format_menu.grid(
763             row=settings_row, column=2, columnspan=3, sticky=E, pady=5
764         )
765         settings_row += 1
766
767         self.halt_on_failure = BooleanVar(self._root, name="halt_on_failure")
768         self.halt_on_failure.set(self.config.default_halt_on_failure)
769         halt_on_failure_label = Label(settings_frame, text="Halt on Basic Failures:")
770         halt_on_failure_label.grid(row=settings_row, column=1, sticky=E, pady=5)
771         halt_checkbox = Checkbutton(
772             settings_frame, offvalue=False, onvalue=True, variable=self.halt_on_failure
773         )
774         halt_checkbox.grid(row=settings_row, column=2, columnspan=2, sticky=W, pady=5)
775
776         directory_label = Label(actions, text="Template Location:")
777         directory_label.grid(row=4, column=1, pady=5, sticky=W)
778         self.template_source = StringVar(self._root, name="template_source")
779         directory_entry = Entry(actions, width=40, textvariable=self.template_source)
780         directory_entry.grid(row=4, column=2, pady=5, sticky=W)
781         directory_browse = Button(actions, text="...", command=self.ask_template_source)
782         directory_browse.grid(row=4, column=3, pady=5, sticky=W)
783
784         validate_button = Button(
785             actions, text="Validate Templates", command=self.validate
786         )
787         validate_button.grid(row=5, column=1, columnspan=2, pady=5)
788
789         self.result_panel = Frame(actions)
790         # We'll add these labels now, and then make them visible when the run completes
791         self.completion_label = Label(self.result_panel, text="Validation Complete!")
792         self.result_label = Label(
793             self.result_panel, text="View Report", fg="blue", cursor="hand2"
794         )
795         self.underline(self.result_label)
796         self.result_label.bind("<Button-1>", self.open_report)
797
798         self.preload_label = Label(
799             self.result_panel, text="View Preloads", fg="blue", cursor="hand2"
800         )
801         self.underline(self.preload_label)
802         self.preload_label.bind("<Button-1>", self.open_preloads)
803
804         self.result_panel.grid(row=6, column=1, columnspan=2)
805         control_panel.pack(fill=BOTH, expand=1)
806
807         main_window.add(control_panel)
808
809         self.log_panel = ScrolledText(main_window, wrap=WORD, width=120, height=20)
810         self.log_panel.configure(font=font.Font(family="Courier New", size="11"))
811         self.log_panel.pack(fill=BOTH, expand=1)
812
813         main_window.add(self.log_panel)
814
815         # Briefly add the completion and result labels so the window size includes
816         # room for them
817         self.completion_label.pack()
818         self.result_label.pack()  # Show report link
819         self.preload_label.pack()  # Show preload link
820         self._root.after_idle(
821             lambda: (
822                 self.completion_label.pack_forget(),
823                 self.result_label.pack_forget(),
824                 self.preload_label.pack_forget(),
825             )
826         )
827
828         self.config.watch(
829             *self.categories,
830             self.verbosity,
831             self.input_format,
832             self.report_format,
833             self.halt_on_failure,
834         )
835         if self.config.preload_formats:
836             self.config.watch(self.preload_format)
837         self.schedule(self.execute_pollers)
838         if self.config.terms_link_text and not self.config.are_terms_accepted:
839             TermsAndConditionsDialog(parent_frame, self.config)
840             if not self.config.are_terms_accepted:
841                 self.shutdown()
842
843     def create_footer(self, parent_frame):
844         footer = Frame(parent_frame)
845         disclaimer = Message(footer, text=self.config.disclaimer_text, anchor=CENTER)
846         disclaimer.grid(row=0, pady=2)
847         parent_frame.bind(
848             "<Configure>", lambda e: disclaimer.configure(width=e.width - 20)
849         )
850         if self.config.requirement_link_text:
851             requirement_link = Text(
852                 footer,
853                 height=1,
854                 bg=disclaimer.cget("bg"),
855                 relief=FLAT,
856                 font=disclaimer.cget("font"),
857             )
858             requirement_link.tag_configure("center", justify="center")
859             hyperlinks = HyperlinkManager(requirement_link)
860             requirement_link.insert(INSERT, "Validating: ")
861             requirement_link.insert(
862                 INSERT,
863                 self.config.requirement_link_text,
864                 hyperlinks.add(self.open_requirements),
865             )
866             requirement_link.tag_add("center", "1.0", "end")
867             requirement_link.config(state=DISABLED)
868             requirement_link.grid(row=1, pady=2)
869             ToolTip(requirement_link, self.config.requirement_link_url)
870         footer.grid_columnconfigure(0, weight=1)
871         footer.pack(fill=BOTH, expand=True)
872         return footer
873
874     def ask_template_source(self):
875         if self.input_format.get() == "ZIP File":
876             template_source = filedialog.askopenfilename(
877                 title="Select Archive",
878                 filetypes=(("ZIP Files", "*.zip"), ("All Files", "*")),
879             )
880         else:
881             template_source = filedialog.askdirectory()
882         self.template_source.set(template_source)
883
884     def validate(self):
885         """Run the pytest validations in a background process"""
886         if not self.delete_prior_report():
887             return
888
889         if not self.template_source.get():
890             self.ask_template_source()
891
892         template_dir = self.resolve_template_dir()
893
894         if template_dir:
895             self.kill_background_task()
896             self.clear_log()
897             self.completion_label.pack_forget()
898             self.result_label.pack_forget()
899             self.preload_label.pack_forget()
900             self.task = multiprocessing.Process(
901                 target=run_pytest,
902                 args=(
903                     template_dir,
904                     self.config.log_file,
905                     self.config.status_queue,
906                     self.categories_list(),
907                     self.VERBOSITY_LEVELS[self.verbosity.get()],
908                     self.report_format.get().lower(),
909                     self.halt_on_failure.get(),
910                     self.template_source.get(),
911                 ),
912             )
913             self.task.daemon = True
914             self.task.start()
915
916     @property
917     def title(self):
918         """Returns the text displayed in the title bar of the application"""
919         return self._root.title()
920
921     def execute_pollers(self):
922         """Call all methods that require periodic execution, and re-schedule
923         their execution for the next polling interval"""
924         try:
925             self.poll_log_file()
926             self.poll_status_queue()
927             self.poll_command_queue()
928         finally:
929             self.schedule(self.execute_pollers)
930
931     @staticmethod
932     def _drain_queue(q):
933         """Yields values from the queue until empty"""
934         while True:
935             try:
936                 yield q.get(block=False)
937             except queue.Empty:
938                 break
939
940     def poll_command_queue(self):
941         """Picks up command strings from the commmand queue, and
942         dispatches it for execution.  Only SHUTDOWN is supported
943         currently"""
944         for command in self._drain_queue(self.config.command_queue):
945             if command == "SHUTDOWN":
946                 self.shutdown()
947
948     def poll_status_queue(self):
949         """Checks for completion of the job, and then displays the View Report link
950         if it was successful or writes the exception to the ``log_panel`` if
951         it fails."""
952         for is_success, e in self._drain_queue(self.config.status_queue):
953             if is_success:
954                 self.completion_label.pack()
955                 self.result_label.pack()  # Show report link
956                 if hasattr(self, "preload_format"):
957                     self.preload_label.pack()  # Show preload link
958             else:
959                 self.log_panel.insert(END, str(e))
960
961     def poll_log_file(self):
962         """Reads captured stdout and stderr from the log queue and writes it to the
963         log panel."""
964         for line in self._drain_queue(self.config.log_queue):
965             self.log_panel.insert(END, line)
966             self.log_panel.see(END)
967
968     def schedule(self, func: Callable):
969         """Schedule the callable ``func`` to be executed according to
970         the polling_frequency"""
971         self._root.after(self.config.polling_frequency, func)
972
973     def clear_log(self):
974         """Removes all log entries from teh log panel"""
975         self.log_panel.delete("1.0", END)
976
977     def delete_prior_report(self) -> bool:
978         """Attempts to delete the current report, and pops up a warning message
979         to the user if it can't be deleted.  This will force the user to
980         close the report before re-running the validation.  Returns True if
981         the file was deleted or did not exist, or False otherwise"""
982         if not os.path.exists(self.report_file_path):
983             return True
984
985         try:
986             os.remove(self.report_file_path)
987             return True
988         except OSError:
989             messagebox.showerror(
990                 "Error",
991                 "Please close or rename the open report file before re-validating",
992             )
993             return False
994
995     @property
996     def report_file_path(self):
997         ext_mapping = {"csv": "csv", "html": "html", "excel": "xlsx"}
998         ext = ext_mapping.get(self.report_format.get().lower())
999         return os.path.join(PATH, OUT_DIR, "report.{}".format(ext))
1000
1001     # noinspection PyUnusedLocal
1002     def open_report(self, event):
1003         """Open the report in the user's default browser"""
1004         webbrowser.open_new("file://{}".format(self.report_file_path))
1005
1006     def open_preloads(self, event):
1007         """Open the report in the user's default browser"""
1008         path = os.path.join(
1009             PATH,
1010             OUT_DIR,
1011             "preloads",
1012             self.config.get_subdir_for_preload(self.preload_format.get()),
1013         )
1014         if platform.system() == "Windows":
1015             os.startfile(path)  # nosec
1016         elif platform.system() == "Darwin":
1017             subprocess.Popen(["open", path])  # nosec
1018         else:
1019             subprocess.Popen(["xdg-open", path])  # nosec
1020
1021     def open_requirements(self):
1022         """Open the report in the user's default browser"""
1023         webbrowser.open_new(self.config.requirement_link_url)
1024
1025     def start(self):
1026         """Start the event loop of the application.  This method does not return"""
1027         self._root.mainloop()
1028
1029     @staticmethod
1030     def underline(label):
1031         """Apply underline format to an existing label"""
1032         f = font.Font(label, label.cget("font"))
1033         f.configure(underline=True)
1034         label.configure(font=f)
1035
1036     def kill_background_task(self):
1037         if self.task and self.task.is_alive():
1038             self.task.terminate()
1039             for _ in self._drain_queue(self.config.log_queue):
1040                 pass
1041
1042     def shutdown(self):
1043         """Shutdown the application"""
1044         self.kill_background_task()
1045         self._root.destroy()
1046
1047     def check_template_source_is_valid(self):
1048         """Verifies the value of template source exists and of valid type based
1049         on input setting"""
1050         if not self.template_source.get():
1051             return False
1052         template_path = Path(self.template_source.get())
1053
1054         if not template_path.exists():
1055             messagebox.showerror(
1056                 "Error",
1057                 "Input does not exist. Please provide a valid file or directory.",
1058             )
1059             return False
1060
1061         if self.input_format.get() == "ZIP File":
1062             if zipfile.is_zipfile(template_path):
1063                 return True
1064             else:
1065                 messagebox.showerror(
1066                     "Error", "Expected ZIP file, but input is not a valid ZIP file"
1067                 )
1068                 return False
1069         else:
1070             if template_path.is_dir():
1071                 return True
1072             else:
1073                 messagebox.showerror(
1074                     "Error", "Expected directory, but input is not a directory"
1075                 )
1076                 return False
1077
1078     def resolve_template_dir(self) -> str:
1079         """Extracts the zip file to a temporary directory if needed, otherwise
1080         returns the directory supplied to template source.  Returns empty string
1081         if the template source isn't valid"""
1082         if not self.check_template_source_is_valid():
1083             return ""
1084         if self.input_format.get() == "ZIP File":
1085             temp_dir = tempfile.mkdtemp()
1086             archive = zipfile.ZipFile(self.template_source.get())
1087             archive.extractall(path=temp_dir)
1088             return temp_dir
1089         else:
1090             return self.template_source.get()
1091
1092     def categories_list(self) -> list:
1093         categories = []
1094         selected_categories = self.categories
1095         for x in range(0, len(selected_categories)):
1096             if selected_categories[x].get():
1097                 category = self.config.category_names[x]
1098                 categories.append(self.config.get_category(category))
1099         return categories
1100
1101
1102 if __name__ == "__main__":
1103     multiprocessing.freeze_support()  # needed for PyInstaller to work
1104     ValidatorApp().start()