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