8db2d5163324683de84465335d9dafa1bae48283
[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         if not path.startswith("http"):
429             path = "file://{}".format(os.path.join(PATH, path))
430         return path
431
432     @property
433     def terms(self) -> dict:
434         return self._config.get("terms", {})
435
436     @property
437     def terms_link_url(self) -> Optional[str]:
438         return self.terms.get("path")
439
440     @property
441     def terms_link_text(self):
442         return self.terms.get("popup-link-text")
443
444     @property
445     def terms_version(self) -> Optional[str]:
446         return self.terms.get("version")
447
448     @property
449     def terms_popup_title(self) -> Optional[str]:
450         return self.terms.get("popup-title")
451
452     @property
453     def terms_popup_message(self) -> Optional[str]:
454         return self.terms.get("popup-msg-text")
455
456     @property
457     def are_terms_accepted(self) -> bool:
458         version = "terms-{}".format(self.terms_version)
459         return self._user_settings.get(version, "False") == "True"
460
461     def set_terms_accepted(self):
462         version = "terms-{}".format(self.terms_version)
463         self._user_settings[version] = "True"
464         self._user_settings.save()
465
466     def default_verbosity(self, levels: Dict[str, str]) -> str:
467         requested_level = self._user_settings.get("verbosity") or self._config[
468             "settings"
469         ].get("default-verbosity", "Standard")
470         keys = [key for key in levels]
471         for key in levels:
472             if key.lower().startswith(requested_level.lower()):
473                 return key
474         raise RuntimeError(
475             "Invalid default-verbosity level {}. Valid "
476             "values are {}".format(requested_level, ", ".join(keys))
477         )
478
479     def get_description(self, category_name: str) -> str:
480         """Returns the description associated with the category name"""
481         return self._get_category(category_name)["description"]
482
483     def get_category(self, category_name: str) -> str:
484         """Returns the category associated with the category name"""
485         return self._get_category(category_name).get("category", "")
486
487     def get_category_value(self, category_name: str) -> str:
488         """Returns the saved value for a category name"""
489         return self._user_settings.get(category_name, 0)
490
491     def _get_category(self, category_name: str) -> Dict[str, str]:
492         """Returns the profile definition"""
493         for category in self._config["categories"]:
494             if category["name"] == category_name:
495                 return category
496         raise RuntimeError(
497             "Unexpected error: No category found in vvp-config.yaml "
498             "with a name of " + category_name
499         )
500
501     @property
502     def default_report_format(self):
503         return self._user_settings.get("report_format", "HTML")
504
505     @property
506     def report_formats(self):
507         return ["CSV", "Excel", "HTML"]
508
509     @property
510     def default_input_format(self):
511         requested_default = self._user_settings.get("input_format") or self._config[
512             "settings"
513         ].get("default-input-format")
514         if requested_default in self.input_formats:
515             return requested_default
516         else:
517             return self.input_formats[0]
518
519     @property
520     def input_formats(self):
521         return ["Directory (Uncompressed)", "ZIP File"]
522
523     @property
524     def default_halt_on_failure(self):
525         setting = self._user_settings.get("halt_on_failure", "True")
526         return setting.lower() == "true"
527
528     def _validate(self):
529         """Ensures the config file is properly formatted"""
530         categories = self._config["categories"]
531
532         # All profiles have required keys
533         expected_keys = {"name", "description"}
534         for category in categories:
535             actual_keys = set(category.keys())
536             missing_keys = expected_keys.difference(actual_keys)
537             if missing_keys:
538                 raise RuntimeError(
539                     "Error in vvp-config.yaml file: "
540                     "Required field missing in category. "
541                     "Missing: {} "
542                     "Categories: {}".format(",".join(missing_keys), category)
543                 )
544
545
546 def validate():
547     return True
548
549
550 class Dialog(Toplevel):
551     """
552     Adapted from http://www.effbot.org/tkinterbook/tkinter-dialog-windows.htm
553     """
554
555     def __init__(self, parent: Frame, title=None):
556         Toplevel.__init__(self, parent)
557         self.transient(parent)
558         if title:
559             self.title(title)
560         self.parent = parent
561         self.result = None
562         body = Frame(self)
563         self.initial_focus = self.body(body)
564         body.pack(padx=5, pady=5)
565         self.buttonbox()
566         self.grab_set()
567         if not self.initial_focus:
568             self.initial_focus = self
569         self.protocol("WM_DELETE_WINDOW", self.cancel)
570         self.geometry(
571             "+%d+%d" % (parent.winfo_rootx() + 600, parent.winfo_rooty() + 400)
572         )
573         self.initial_focus.focus_set()
574         self.wait_window(self)
575
576     def body(self, master):
577         raise NotImplementedError()
578
579     # noinspection PyAttributeOutsideInit
580     def buttonbox(self):
581         box = Frame(self)
582         self.accept = Button(
583             box,
584             text="Accept",
585             width=10,
586             state=DISABLED,
587             command=self.ok,
588             default=ACTIVE,
589         )
590         self.accept.pack(side=LEFT, padx=5, pady=5)
591         self.decline = Button(
592             box, text="Decline", width=10, state=DISABLED, command=self.cancel
593         )
594         self.decline.pack(side=LEFT, padx=5, pady=5)
595         self.bind("<Return>", self.ok)
596         self.bind("<Escape>", self.cancel)
597         box.pack()
598
599     # noinspection PyUnusedLocal
600     def ok(self, event=None):
601         if not validate():
602             self.initial_focus.focus_set()  # put focus back
603             return
604         self.withdraw()
605         self.update_idletasks()
606         self.apply()
607         self.cancel()
608
609     # noinspection PyUnusedLocal
610     def cancel(self, event=None):
611         self.parent.focus_set()
612         self.destroy()
613
614     def apply(self):
615         raise NotImplementedError()
616
617     def activate_buttons(self):
618         self.accept.configure(state=NORMAL)
619         self.decline.configure(state=NORMAL)
620
621
622 class TermsAndConditionsDialog(Dialog):
623     def __init__(self, parent, config: Config):
624         self.config = config
625         self.parent = parent
626         super().__init__(parent, config.terms_popup_title)
627
628     def body(self, master):
629         Label(master, text=self.config.terms_popup_message).grid(row=0, pady=5)
630         tc_link = Label(
631             master, text=self.config.terms_link_text, fg="blue", cursor="hand2"
632         )
633         ValidatorApp.underline(tc_link)
634         tc_link.bind("<Button-1>", self.open_terms)
635         tc_link.grid(row=1, pady=5)
636
637     # noinspection PyUnusedLocal
638     def open_terms(self, event):
639         webbrowser.open(self.config.terms_link_url)
640         self.activate_buttons()
641
642     def apply(self):
643         self.config.set_terms_accepted()
644
645
646 class ValidatorApp:
647     VERBOSITY_LEVELS = {"Less": "", "Standard (-v)": "-v", "More (-vv)": "-vv"}
648
649     def __init__(self, config: Config = None):
650         """Constructs the GUI element of the Validation Tool"""
651         self.task = None
652         self.config = config or Config()
653
654         self._root = Tk()
655         self._root.title(self.config.app_name)
656         self._root.protocol("WM_DELETE_WINDOW", self.shutdown)
657
658         if self.config.terms_link_text:
659             menubar = Menu(self._root)
660             menubar.add_command(
661                 label=self.config.terms_link_text,
662                 command=lambda: webbrowser.open(self.config.terms_link_url),
663             )
664             self._root.config(menu=menubar)
665
666         parent_frame = Frame(self._root)
667         main_window = PanedWindow(parent_frame)
668         main_window.pack(fill=BOTH, expand=1)
669
670         control_panel = PanedWindow(
671             main_window, orient=HORIZONTAL, sashpad=4, sashrelief=RAISED
672         )
673         actions = Frame(control_panel)
674         control_panel.add(actions)
675         control_panel.paneconfigure(actions, minsize=250)
676
677         if self.config.disclaimer_text or self.config.requirement_link_text:
678             self.footer = self.create_footer(parent_frame)
679         parent_frame.pack(fill=BOTH, expand=True)
680
681         # profile start
682         number_of_categories = len(self.config.category_names)
683         category_frame = LabelFrame(actions, text="Additional Validation Categories:")
684         category_frame.grid(row=1, column=1, columnspan=3, pady=5, sticky="we")
685
686         self.categories = []
687
688         for x in range(0, number_of_categories):
689             category_name = self.config.category_names[x]
690             category_value = IntVar(value=0)
691             category_value._name = "category_{}".format(category_name.replace(" ", "_"))
692             # noinspection PyProtectedMember
693             category_value.set(self.config.get_category_value(category_value._name))
694             self.categories.append(category_value)
695             category_checkbox = Checkbutton(
696                 category_frame, text=category_name, variable=self.categories[x]
697             )
698             ToolTip(category_checkbox, self.config.get_description(category_name))
699             category_checkbox.grid(row=x + 1, column=1, columnspan=2, sticky="w")
700
701         settings_frame = LabelFrame(actions, text="Settings")
702         settings_frame.grid(row=3, column=1, columnspan=3, pady=10, sticky="we")
703         verbosity_label = Label(settings_frame, text="Verbosity:")
704         verbosity_label.grid(row=1, column=1, sticky=W)
705         self.verbosity = StringVar(self._root, name="verbosity")
706         self.verbosity.set(self.config.default_verbosity(self.VERBOSITY_LEVELS))
707         verbosity_menu = OptionMenu(
708             settings_frame, self.verbosity, *tuple(self.VERBOSITY_LEVELS.keys())
709         )
710         verbosity_menu.config(width=25)
711         verbosity_menu.grid(row=1, column=2, columnspan=3, sticky=E, pady=5)
712
713         report_format_label = Label(settings_frame, text="Report Format:")
714         report_format_label.grid(row=2, column=1, sticky=W)
715         self.report_format = StringVar(self._root, name="report_format")
716         self.report_format.set(self.config.default_report_format)
717         report_format_menu = OptionMenu(
718             settings_frame, self.report_format, *self.config.report_formats
719         )
720         report_format_menu.config(width=25)
721         report_format_menu.grid(row=2, column=2, columnspan=3, sticky=E, pady=5)
722
723         input_format_label = Label(settings_frame, text="Input Format:")
724         input_format_label.grid(row=3, column=1, sticky=W)
725         self.input_format = StringVar(self._root, name="input_format")
726         self.input_format.set(self.config.default_input_format)
727         input_format_menu = OptionMenu(
728             settings_frame, self.input_format, *self.config.input_formats
729         )
730         input_format_menu.config(width=25)
731         input_format_menu.grid(row=3, column=2, columnspan=3, sticky=E, pady=5)
732
733         self.halt_on_failure = BooleanVar(self._root, name="halt_on_failure")
734         self.halt_on_failure.set(self.config.default_halt_on_failure)
735         halt_on_failure_label = Label(settings_frame, text="Halt on Basic Failures:")
736         halt_on_failure_label.grid(row=4, column=1, sticky=E, pady=5)
737         halt_checkbox = Checkbutton(
738             settings_frame, offvalue=False, onvalue=True, variable=self.halt_on_failure
739         )
740         halt_checkbox.grid(row=4, column=2, columnspan=2, sticky=W, pady=5)
741
742         directory_label = Label(actions, text="Template Location:")
743         directory_label.grid(row=4, column=1, pady=5, sticky=W)
744         self.template_source = StringVar(self._root, name="template_source")
745         directory_entry = Entry(actions, width=40, textvariable=self.template_source)
746         directory_entry.grid(row=4, column=2, pady=5, sticky=W)
747         directory_browse = Button(actions, text="...", command=self.ask_template_source)
748         directory_browse.grid(row=4, column=3, pady=5, sticky=W)
749
750         validate_button = Button(
751             actions, text="Validate Templates", command=self.validate
752         )
753         validate_button.grid(row=5, column=1, columnspan=2, pady=5)
754
755         self.result_panel = Frame(actions)
756         # We'll add these labels now, and then make them visible when the run completes
757         self.completion_label = Label(self.result_panel, text="Validation Complete!")
758         self.result_label = Label(
759             self.result_panel, text="View Report", fg="blue", cursor="hand2"
760         )
761         self.underline(self.result_label)
762         self.result_label.bind("<Button-1>", self.open_report)
763         self.result_panel.grid(row=6, column=1, columnspan=2)
764         control_panel.pack(fill=BOTH, expand=1)
765
766         main_window.add(control_panel)
767
768         self.log_panel = ScrolledText(main_window, wrap=WORD, width=120, height=20)
769         self.log_panel.configure(font=font.Font(family="Courier New", size="11"))
770         self.log_panel.pack(fill=BOTH, expand=1)
771
772         main_window.add(self.log_panel)
773
774         # Briefly add the completion and result labels so the window size includes
775         # room for them
776         self.completion_label.pack()
777         self.result_label.pack()  # Show report link
778         self._root.after_idle(
779             lambda: (
780                 self.completion_label.pack_forget(),
781                 self.result_label.pack_forget(),
782             )
783         )
784
785         self.config.watch(
786             *self.categories,
787             self.verbosity,
788             self.input_format,
789             self.report_format,
790             self.halt_on_failure,
791         )
792         self.schedule(self.execute_pollers)
793         if self.config.terms_link_text and not self.config.are_terms_accepted:
794             TermsAndConditionsDialog(parent_frame, self.config)
795             if not self.config.are_terms_accepted:
796                 self.shutdown()
797
798     def create_footer(self, parent_frame):
799         footer = Frame(parent_frame)
800         disclaimer = Message(
801             footer, text=self.config.disclaimer_text, anchor=CENTER
802         )
803         disclaimer.grid(row=0, pady=2)
804         parent_frame.bind(
805             "<Configure>", lambda e: disclaimer.configure(width=e.width - 20)
806         )
807         if self.config.requirement_link_text:
808             requirement_link = Text(
809                 footer,
810                 height=1,
811                 bg=disclaimer.cget("bg"),
812                 relief=FLAT,
813                 font=disclaimer.cget("font"),
814             )
815             requirement_link.tag_configure("center", justify="center")
816             hyperlinks = HyperlinkManager(requirement_link)
817             requirement_link.insert(INSERT, "Validating: ")
818             requirement_link.insert(
819                 INSERT,
820                 self.config.requirement_link_text,
821                 hyperlinks.add(self.open_requirements),
822             )
823             requirement_link.tag_add("center", "1.0", "end")
824             requirement_link.config(state=DISABLED)
825             requirement_link.grid(row=1, pady=2)
826             ToolTip(requirement_link, self.config.requirement_link_url)
827         footer.grid_columnconfigure(0, weight=1)
828         footer.pack(fill=BOTH, expand=True)
829         return footer
830
831     def ask_template_source(self):
832         if self.input_format.get() == "ZIP File":
833             template_source = filedialog.askopenfilename(
834                 title="Select Archive",
835                 filetypes=(("ZIP Files", "*.zip"), ("All Files", "*")),
836             )
837         else:
838             template_source = filedialog.askdirectory()
839         self.template_source.set(template_source)
840
841     def validate(self):
842         """Run the pytest validations in a background process"""
843         if not self.delete_prior_report():
844             return
845
846         if not self.template_source.get():
847             self.ask_template_source()
848
849         template_dir = self.resolve_template_dir()
850
851         if template_dir:
852             self.kill_background_task()
853             self.clear_log()
854             self.completion_label.pack_forget()
855             self.result_label.pack_forget()
856             self.task = multiprocessing.Process(
857                 target=run_pytest,
858                 args=(
859                     template_dir,
860                     self.config.log_file,
861                     self.config.status_queue,
862                     self.categories_list(),
863                     self.VERBOSITY_LEVELS[self.verbosity.get()],
864                     self.report_format.get().lower(),
865                     self.halt_on_failure.get(),
866                     self.template_source.get(),
867                 ),
868             )
869             self.task.daemon = True
870             self.task.start()
871
872     @property
873     def title(self):
874         """Returns the text displayed in the title bar of the application"""
875         return self._root.title()
876
877     def execute_pollers(self):
878         """Call all methods that require periodic execution, and re-schedule
879         their execution for the next polling interval"""
880         try:
881             self.poll_log_file()
882             self.poll_status_queue()
883             self.poll_command_queue()
884         finally:
885             self.schedule(self.execute_pollers)
886
887     @staticmethod
888     def _drain_queue(q):
889         """Yields values from the queue until empty"""
890         while True:
891             try:
892                 yield q.get(block=False)
893             except queue.Empty:
894                 break
895
896     def poll_command_queue(self):
897         """Picks up command strings from the commmand queue, and
898         dispatches it for execution.  Only SHUTDOWN is supported
899         currently"""
900         for command in self._drain_queue(self.config.command_queue):
901             if command == "SHUTDOWN":
902                 self.shutdown()
903
904     def poll_status_queue(self):
905         """Checks for completion of the job, and then displays the View Report link
906         if it was successful or writes the exception to the ``log_panel`` if
907         it fails."""
908         for is_success, e in self._drain_queue(self.config.status_queue):
909             if is_success:
910                 self.completion_label.pack()
911                 self.result_label.pack()  # Show report link
912             else:
913                 self.log_panel.insert(END, str(e))
914
915     def poll_log_file(self):
916         """Reads captured stdout and stderr from the log queue and writes it to the
917         log panel."""
918         for line in self._drain_queue(self.config.log_queue):
919             self.log_panel.insert(END, line)
920             self.log_panel.see(END)
921
922     def schedule(self, func: Callable):
923         """Schedule the callable ``func`` to be executed according to
924         the polling_frequency"""
925         self._root.after(self.config.polling_frequency, func)
926
927     def clear_log(self):
928         """Removes all log entries from teh log panel"""
929         self.log_panel.delete("1.0", END)
930
931     def delete_prior_report(self) -> bool:
932         """Attempts to delete the current report, and pops up a warning message
933         to the user if it can't be deleted.  This will force the user to
934         close the report before re-running the validation.  Returns True if
935         the file was deleted or did not exist, or False otherwise"""
936         if not os.path.exists(self.report_file_path):
937             return True
938
939         try:
940             os.remove(self.report_file_path)
941             return True
942         except OSError:
943             messagebox.showerror(
944                 "Error",
945                 "Please close or rename the open report file before re-validating",
946             )
947             return False
948
949     @property
950     def report_file_path(self):
951         ext_mapping = {"csv": "csv", "html": "html", "excel": "xlsx"}
952         ext = ext_mapping.get(self.report_format.get().lower())
953         return os.path.join(PATH, OUT_DIR, "report.{}".format(ext))
954
955     # noinspection PyUnusedLocal
956     def open_report(self, event):
957         """Open the report in the user's default browser"""
958         webbrowser.open_new("file://{}".format(self.report_file_path))
959
960     def open_requirements(self):
961         """Open the report in the user's default browser"""
962         webbrowser.open_new(self.config.requirement_link_url)
963
964     def start(self):
965         """Start the event loop of the application.  This method does not return"""
966         self._root.mainloop()
967
968     @staticmethod
969     def underline(label):
970         """Apply underline format to an existing label"""
971         f = font.Font(label, label.cget("font"))
972         f.configure(underline=True)
973         label.configure(font=f)
974
975     def kill_background_task(self):
976         if self.task and self.task.is_alive():
977             self.task.terminate()
978             for _ in self._drain_queue(self.config.log_queue):
979                 pass
980
981     def shutdown(self):
982         """Shutdown the application"""
983         self.kill_background_task()
984         self._root.destroy()
985
986     def check_template_source_is_valid(self):
987         """Verifies the value of template source exists and of valid type based
988         on input setting"""
989         if not self.template_source.get():
990             return False
991         template_path = Path(self.template_source.get())
992
993         if not template_path.exists():
994             messagebox.showerror(
995                 "Error",
996                 "Input does not exist. Please provide a valid file or directory.",
997             )
998             return False
999
1000         if self.input_format.get() == "ZIP File":
1001             if zipfile.is_zipfile(template_path):
1002                 return True
1003             else:
1004                 messagebox.showerror(
1005                     "Error", "Expected ZIP file, but input is not a valid ZIP file"
1006                 )
1007                 return False
1008         else:
1009             if template_path.is_dir():
1010                 return True
1011             else:
1012                 messagebox.showerror(
1013                     "Error", "Expected directory, but input is not a directory"
1014                 )
1015                 return False
1016
1017     def resolve_template_dir(self) -> str:
1018         """Extracts the zip file to a temporary directory if needed, otherwise
1019         returns the directory supplied to template source.  Returns empty string
1020         if the template source isn't valid"""
1021         if not self.check_template_source_is_valid():
1022             return ""
1023         if self.input_format.get() == "ZIP File":
1024             temp_dir = tempfile.mkdtemp()
1025             archive = zipfile.ZipFile(self.template_source.get())
1026             archive.extractall(path=temp_dir)
1027             return temp_dir
1028         else:
1029             return self.template_source.get()
1030
1031     def categories_list(self) -> list:
1032         categories = []
1033         selected_categories = self.categories
1034         for x in range(0, len(selected_categories)):
1035             if selected_categories[x].get():
1036                 category = self.config.category_names[x]
1037                 categories.append(self.config.get_category(category))
1038         return categories
1039
1040
1041 if __name__ == "__main__":
1042     multiprocessing.freeze_support()  # needed for PyInstaller to work
1043     ValidatorApp().start()