[VVP] Support pluggable data sources for preload data
[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
50 import os
51 import traceback
52
53 import pytest
54 import version
55 import contextlib
56 import multiprocessing
57 import queue
58 import tempfile
59 import webbrowser
60 import zipfile
61 import platform
62 import subprocess  # nosec
63
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, TextIO, Callable
105
106 from config import Config
107 from preload.engine import PLUGIN_MGR
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 def run_pytest(
217     template_dir: str,
218     log: TextIO,
219     result_queue: Queue,
220     categories: Optional[list],
221     report_format: str,
222     halt_on_failure: bool,
223     template_source: str,
224     preload_config: str,
225     preload_format: list,
226     preload_source: str,
227 ):
228     """Runs pytest using the given ``profile`` in a background process.  All
229     ``stdout`` and ``stderr`` are redirected to ``log``.  The result of the job
230     will be put on the ``completion_queue``
231
232     :param template_dir:        The directory containing the files to be validated.
233     :param log: `               `stderr`` and ``stdout`` of the pytest job will be
234                                 directed here
235     :param result_queue:        Completion status posted here.  See :class:`Config`
236                                 for more information.
237     :param categories:          list of optional categories. When provided, pytest
238                                 will collect and execute all tests that are
239                                 decorated with any of the passed categories, as
240                                 well as tests not decorated with a category.
241     :param report_format:       Determines the style of report written.  Options are
242                                 csv, html, or excel
243     :param halt_on_failure:     Determines if validation will halt when basic failures
244                                 are encountered in the input files.  This can help
245                                 prevent a large number of errors from flooding the
246                                 report.
247     :param template_source:     The path or name of the template to show on the report
248     :param preload_config:      Optional directory or file that is input to preload
249                                 data source
250     :param preload_format:      Selected preload format
251     :param preload_source:      Name of selected preload data source plugin
252     """
253     out_path = "{}/{}".format(PATH, OUT_DIR)
254     if os.path.exists(out_path):
255         rmtree(out_path, ignore_errors=True)
256     with contextlib.redirect_stderr(log), contextlib.redirect_stdout(log):
257         try:
258             args = [
259                 "--ignore=app_tests",
260                 "--capture=sys",
261                 "--template-directory={}".format(template_dir),
262                 "--report-format={}".format(report_format),
263                 "--template-source={}".format(template_source),
264             ]
265             if preload_config:
266                 args.append("--preload-source={}".format(preload_config))
267                 args.append(
268                     "--preload-source-type={}".format(
269                         PLUGIN_MGR.get_source_for_name(preload_source).get_identifier()
270                     )
271                 )
272             if categories:
273                 for category in categories:
274                     args.extend(("--category", category))
275             if not halt_on_failure:
276                 args.append("--continue-on-failure")
277             if preload_format:
278                 args.append("--preload-format={}".format(preload_format))
279             print("args: ", " ".join(args))
280             pytest.main(args=args)
281             result_queue.put((True, None))
282         except Exception:
283             result_queue.put((False, traceback.format_exc()))
284
285
286 class Dialog(Toplevel):
287     """
288     Adapted from http://www.effbot.org/tkinterbook/tkinter-dialog-windows.htm
289     """
290
291     def __init__(self, parent: Frame, title=None):
292         Toplevel.__init__(self, parent)
293         self.transient(parent)
294         if title:
295             self.title(title)
296         self.parent = parent
297         self.result = None
298         body = Frame(self)
299         self.initial_focus = self.body(body)
300         body.pack(padx=5, pady=5)
301         self.buttonbox()
302         self.grab_set()
303         if not self.initial_focus:
304             self.initial_focus = self
305         self.protocol("WM_DELETE_WINDOW", self.cancel)
306         self.geometry(
307             "+%d+%d" % (parent.winfo_rootx() + 600, parent.winfo_rooty() + 400)
308         )
309         self.initial_focus.focus_set()
310         self.wait_window(self)
311
312     def body(self, master):
313         raise NotImplementedError()
314
315     # noinspection PyAttributeOutsideInit
316     def buttonbox(self):
317         box = Frame(self)
318         self.accept = Button(
319             box,
320             text="Accept",
321             width=10,
322             state=DISABLED,
323             command=self.ok,
324             default=ACTIVE,
325         )
326         self.accept.pack(side=LEFT, padx=5, pady=5)
327         self.decline = Button(
328             box, text="Decline", width=10, state=DISABLED, command=self.cancel
329         )
330         self.decline.pack(side=LEFT, padx=5, pady=5)
331         self.bind("<Return>", self.ok)
332         self.bind("<Escape>", self.cancel)
333         box.pack()
334
335     # noinspection PyUnusedLocal
336     def ok(self, event=None):
337         self.withdraw()
338         self.update_idletasks()
339         self.apply()
340         self.cancel()
341
342     # noinspection PyUnusedLocal
343     def cancel(self, event=None):
344         self.parent.focus_set()
345         self.destroy()
346
347     def apply(self):
348         raise NotImplementedError()
349
350     def activate_buttons(self):
351         self.accept.configure(state=NORMAL)
352         self.decline.configure(state=NORMAL)
353
354
355 class TermsAndConditionsDialog(Dialog):
356     def __init__(self, parent, config: Config):
357         self.config = config
358         self.parent = parent
359         super().__init__(parent, config.terms_popup_title)
360
361     def body(self, master):
362         Label(master, text=self.config.terms_popup_message).grid(row=0, pady=5)
363         tc_link = Label(
364             master, text=self.config.terms_link_text, fg="blue", cursor="hand2"
365         )
366         ValidatorApp.underline(tc_link)
367         tc_link.bind("<Button-1>", self.open_terms)
368         tc_link.grid(row=1, pady=5)
369
370     # noinspection PyUnusedLocal
371     def open_terms(self, event):
372         webbrowser.open(self.config.terms_link_url)
373         self.activate_buttons()
374
375     def apply(self):
376         self.config.set_terms_accepted()
377
378
379 class ValidatorApp:
380     def __init__(self, config: Config = None):
381         """Constructs the GUI element of the Validation Tool"""
382         self.task = None
383         self.config = config or Config()
384
385         self._root = Tk()
386         self._root.title(self.config.app_name)
387         self._root.protocol("WM_DELETE_WINDOW", self.shutdown)
388
389         if self.config.terms_link_text:
390             menubar = Menu(self._root)
391             menubar.add_command(
392                 label=self.config.terms_link_text,
393                 command=lambda: webbrowser.open(self.config.terms_link_url),
394             )
395             self._root.config(menu=menubar)
396
397         parent_frame = Frame(self._root)
398         main_window = PanedWindow(parent_frame)
399         main_window.pack(fill=BOTH, expand=1)
400
401         control_panel = PanedWindow(
402             main_window, orient=HORIZONTAL, sashpad=4, sashrelief=RAISED
403         )
404         actions = Frame(control_panel)
405         control_panel.add(actions)
406         control_panel.paneconfigure(actions, minsize=350)
407
408         if self.config.disclaimer_text or self.config.requirement_link_text:
409             self.footer = self.create_footer(parent_frame)
410         parent_frame.pack(fill=BOTH, expand=True)
411
412         # profile start
413         number_of_categories = len(self.config.category_names)
414         category_frame = LabelFrame(actions, text="Additional Validation Categories:")
415         category_frame.grid(row=1, column=1, columnspan=3, pady=5, sticky="we")
416
417         self.categories = []
418
419         for x in range(0, number_of_categories):
420             category_name = self.config.category_names[x]
421             category_value = IntVar(value=0)
422             category_value._name = "category_{}".format(category_name.replace(" ", "_"))
423             # noinspection PyProtectedMember
424             category_value.set(self.config.get_category_value(category_value._name))
425             self.categories.append(category_value)
426             category_checkbox = Checkbutton(
427                 category_frame, text=category_name, variable=self.categories[x]
428             )
429             ToolTip(category_checkbox, self.config.get_description(category_name))
430             category_checkbox.grid(row=x + 1, column=1, columnspan=2, sticky="w")
431
432         settings_frame = LabelFrame(actions, text="Settings")
433         settings_row = 1
434         settings_frame.grid(row=3, column=1, columnspan=3, pady=10, sticky="we")
435
436         if self.config.preload_formats:
437             preload_format_label = Label(settings_frame, text="Preload Format:")
438             preload_format_label.grid(row=settings_row, column=1, sticky=W)
439             self.preload_format = StringVar(self._root, name="preload_format")
440             self.preload_format.set(self.config.default_preload_format)
441             preload_format_menu = OptionMenu(
442                 settings_frame, self.preload_format, *self.config.preload_formats
443             )
444             preload_format_menu.config(width=25)
445             preload_format_menu.grid(
446                 row=settings_row, column=2, columnspan=3, sticky=E, pady=5
447             )
448             settings_row += 1
449
450             preload_source_label = Label(settings_frame, text="Preload Source:")
451             preload_source_label.grid(row=settings_row, column=1, sticky=W)
452             self.preload_source = StringVar(self._root, name="preload_source")
453             self.preload_source.set(self.config.default_preload_source)
454             preload_source_menu = OptionMenu(
455                 settings_frame, self.preload_source, *self.config.preload_source_types
456             )
457             preload_source_menu.config(width=25)
458             preload_source_menu.grid(
459                 row=settings_row, column=2, columnspan=3, sticky=E, pady=5
460             )
461             settings_row += 1
462
463         report_format_label = Label(settings_frame, text="Report Format:")
464         report_format_label.grid(row=settings_row, column=1, sticky=W)
465         self.report_format = StringVar(self._root, name="report_format")
466         self.report_format.set(self.config.default_report_format)
467         report_format_menu = OptionMenu(
468             settings_frame, self.report_format, *self.config.report_formats
469         )
470         report_format_menu.config(width=25)
471         report_format_menu.grid(
472             row=settings_row, column=2, columnspan=3, sticky=E, pady=5
473         )
474         settings_row += 1
475
476         input_format_label = Label(settings_frame, text="Input Format:")
477         input_format_label.grid(row=settings_row, column=1, sticky=W)
478         self.input_format = StringVar(self._root, name="input_format")
479         self.input_format.set(self.config.default_input_format)
480         input_format_menu = OptionMenu(
481             settings_frame, self.input_format, *self.config.input_formats
482         )
483         input_format_menu.config(width=25)
484         input_format_menu.grid(
485             row=settings_row, column=2, columnspan=3, sticky=E, pady=5
486         )
487         settings_row += 1
488
489         self.halt_on_failure = BooleanVar(self._root, name="halt_on_failure")
490         self.halt_on_failure.set(self.config.default_halt_on_failure)
491         halt_on_failure_label = Label(
492             settings_frame, text="Halt on Basic Failures:", anchor=W, justify=LEFT
493         )
494         halt_on_failure_label.grid(row=settings_row, column=1, sticky=W, pady=5)
495         halt_checkbox = Checkbutton(
496             settings_frame, offvalue=False, onvalue=True, variable=self.halt_on_failure
497         )
498         halt_checkbox.grid(row=settings_row, column=2, columnspan=2, sticky=W, pady=5)
499         settings_row += 1
500
501         self.create_preloads = BooleanVar(self._root, name="create_preloads")
502         self.create_preloads.set(self.config.default_create_preloads)
503         create_preloads_label = Label(
504             settings_frame,
505             text="Create Preload from Datasource:",
506             anchor=W,
507             justify=LEFT,
508         )
509         create_preloads_label.grid(row=settings_row, column=1, sticky=W, pady=5)
510         create_preloads_checkbox = Checkbutton(
511             settings_frame,
512             offvalue=False,
513             onvalue=True,
514             variable=self.create_preloads,
515             command=self.set_env_dir_state,
516         )
517         create_preloads_checkbox.grid(
518             row=settings_row, column=2, columnspan=2, sticky=W, pady=5
519         )
520
521         directory_label = Label(actions, text="Template Location:")
522         directory_label.grid(row=4, column=1, pady=5, sticky=W)
523         self.template_source = StringVar(self._root, name="template_source")
524         directory_entry = Entry(actions, width=40, textvariable=self.template_source)
525         directory_entry.grid(row=4, column=2, pady=5, sticky=W)
526         directory_browse = Button(actions, text="...", command=self.ask_template_source)
527         directory_browse.grid(row=4, column=3, pady=5, sticky=W)
528
529         preload_config_label = Label(actions, text="Preload Datasource:")
530         preload_config_label.grid(row=5, column=1, pady=5, sticky=W)
531         self.preload_config = StringVar(self._root, name="preload_config")
532         preload_config_state = NORMAL if self.create_preloads.get() else DISABLED
533         self.preload_config_entry = Entry(
534             actions,
535             width=40,
536             textvariable=self.preload_config,
537             state=preload_config_state,
538         )
539         self.preload_config_entry.grid(row=5, column=2, pady=5, sticky=W)
540         preload_config_browse = Button(
541             actions, text="...", command=self.ask_preload_source
542         )
543         preload_config_browse.grid(row=5, column=3, pady=5, sticky=W)
544
545         validate_button = Button(
546             actions, text="Process Templates", command=self.validate
547         )
548         validate_button.grid(row=6, column=1, columnspan=2, pady=5)
549
550         self.result_panel = Frame(actions)
551         # We'll add these labels now, and then make them visible when the run completes
552         self.completion_label = Label(self.result_panel, text="Validation Complete!")
553         self.result_label = Label(
554             self.result_panel, text="View Report", fg="blue", cursor="hand2"
555         )
556         self.underline(self.result_label)
557         self.result_label.bind("<Button-1>", self.open_report)
558
559         self.preload_label = Label(
560             self.result_panel, text="View Preload Templates", fg="blue", cursor="hand2"
561         )
562         self.underline(self.preload_label)
563         self.preload_label.bind("<Button-1>", self.open_preloads)
564
565         self.result_panel.grid(row=7, column=1, columnspan=2)
566         control_panel.pack(fill=BOTH, expand=1)
567
568         main_window.add(control_panel)
569
570         self.log_panel = ScrolledText(main_window, wrap=WORD, width=120, height=20)
571         self.log_panel.configure(font=font.Font(family="Courier New", size="11"))
572         self.log_panel.pack(fill=BOTH, expand=1)
573
574         main_window.add(self.log_panel)
575
576         # Briefly add the completion and result labels so the window size includes
577         # room for them
578         self.completion_label.pack()
579         self.result_label.pack()  # Show report link
580         self.preload_label.pack()  # Show preload link
581         self._root.after_idle(
582             lambda: (
583                 self.completion_label.pack_forget(),
584                 self.result_label.pack_forget(),
585                 self.preload_label.pack_forget(),
586             )
587         )
588
589         self.config.watch(
590             *self.categories,
591             self.input_format,
592             self.report_format,
593             self.halt_on_failure,
594             self.preload_format,
595             self.create_preloads,
596             self.preload_source,
597         )
598         self.schedule(self.execute_pollers)
599         if self.config.terms_link_text and not self.config.are_terms_accepted:
600             TermsAndConditionsDialog(parent_frame, self.config)
601             if not self.config.are_terms_accepted:
602                 self.shutdown()
603
604     def create_footer(self, parent_frame):
605         footer = Frame(parent_frame)
606         disclaimer = Message(footer, text=self.config.disclaimer_text, anchor=CENTER)
607         disclaimer.grid(row=0, pady=2)
608         parent_frame.bind(
609             "<Configure>", lambda e: disclaimer.configure(width=e.width - 20)
610         )
611         if self.config.requirement_link_text:
612             requirement_link = Text(
613                 footer,
614                 height=1,
615                 bg=disclaimer.cget("bg"),
616                 relief=FLAT,
617                 font=disclaimer.cget("font"),
618             )
619             requirement_link.tag_configure("center", justify="center")
620             hyperlinks = HyperlinkManager(requirement_link)
621             requirement_link.insert(INSERT, "Validating: ")
622             requirement_link.insert(
623                 INSERT,
624                 self.config.requirement_link_text,
625                 hyperlinks.add(self.open_requirements),
626             )
627             requirement_link.tag_add("center", "1.0", "end")
628             requirement_link.config(state=DISABLED)
629             requirement_link.grid(row=1, pady=2)
630             ToolTip(requirement_link, self.config.requirement_link_url)
631         footer.grid_columnconfigure(0, weight=1)
632         footer.pack(fill=BOTH, expand=True)
633         return footer
634
635     def set_env_dir_state(self):
636         state = NORMAL if self.create_preloads.get() else DISABLED
637         if state == DISABLED:
638             self.preload_config.set("")
639         self.preload_config_entry.config(state=state)
640
641     def ask_template_source(self):
642         if self.input_format.get() == "ZIP File":
643             template_source = filedialog.askopenfilename(
644                 title="Select Archive",
645                 filetypes=(("ZIP Files", "*.zip"), ("All Files", "*")),
646             )
647         else:
648             template_source = filedialog.askdirectory()
649         self.template_source.set(template_source)
650
651     def ask_preload_source(self):
652         input_type = "DIR"
653         for source in PLUGIN_MGR.preload_sources:
654             if source.get_name() == self.preload_source.get():
655                 input_type = source.get_source_type()
656
657         if input_type == "DIR":
658             self.preload_config.set(filedialog.askdirectory())
659         else:
660             self.preload_config.set(
661                 filedialog.askopenfilename(
662                     title="Select Preload Datasource File",
663                     filetypes=(("All Files", "*"),),
664                 )
665             )
666
667     def validate(self):
668         """Run the pytest validations in a background process"""
669         if not self.delete_prior_report():
670             return
671
672         if not self.template_source.get():
673             self.ask_template_source()
674
675         template_dir = self.resolve_template_dir()
676
677         if template_dir:
678             self.kill_background_task()
679             self.clear_log()
680             self.completion_label.pack_forget()
681             self.result_label.pack_forget()
682             self.preload_label.pack_forget()
683             self.task = multiprocessing.Process(
684                 target=run_pytest,
685                 args=(
686                     template_dir,
687                     self.config.log_file,
688                     self.config.status_queue,
689                     self.categories_list(),
690                     self.report_format.get().lower(),
691                     self.halt_on_failure.get(),
692                     self.template_source.get(),
693                     self.preload_config.get(),
694                     self.preload_format.get(),
695                     self.preload_source.get(),
696                 ),
697             )
698             self.task.daemon = True
699             self.task.start()
700
701     @property
702     def title(self):
703         """Returns the text displayed in the title bar of the application"""
704         return self._root.title()
705
706     def execute_pollers(self):
707         """Call all methods that require periodic execution, and re-schedule
708         their execution for the next polling interval"""
709         try:
710             self.poll_log_file()
711             self.poll_status_queue()
712             self.poll_command_queue()
713         finally:
714             self.schedule(self.execute_pollers)
715
716     @staticmethod
717     def _drain_queue(q):
718         """Yields values from the queue until empty"""
719         while True:
720             try:
721                 yield q.get(block=False)
722             except queue.Empty:
723                 break
724
725     def poll_command_queue(self):
726         """Picks up command strings from the commmand queue, and
727         dispatches it for execution.  Only SHUTDOWN is supported
728         currently"""
729         for command in self._drain_queue(self.config.command_queue):
730             if command == "SHUTDOWN":
731                 self.shutdown()
732
733     def poll_status_queue(self):
734         """Checks for completion of the job, and then displays the View Report link
735         if it was successful or writes the exception to the ``log_panel`` if
736         it fails."""
737         for is_success, e in self._drain_queue(self.config.status_queue):
738             if is_success:
739                 self.completion_label.pack()
740                 self.result_label.pack()  # Show report link
741                 if hasattr(self, "preload_format"):
742                     self.preload_label.pack()  # Show preload link
743             else:
744                 self.log_panel.insert(END, str(e))
745
746     def poll_log_file(self):
747         """Reads captured stdout and stderr from the log queue and writes it to the
748         log panel."""
749         for line in self._drain_queue(self.config.log_queue):
750             self.log_panel.insert(END, line)
751             self.log_panel.see(END)
752
753     def schedule(self, func: Callable):
754         """Schedule the callable ``func`` to be executed according to
755         the polling_frequency"""
756         self._root.after(self.config.polling_frequency, func)
757
758     def clear_log(self):
759         """Removes all log entries from teh log panel"""
760         self.log_panel.delete("1.0", END)
761
762     def delete_prior_report(self) -> bool:
763         """Attempts to delete the current report, and pops up a warning message
764         to the user if it can't be deleted.  This will force the user to
765         close the report before re-running the validation.  Returns True if
766         the file was deleted or did not exist, or False otherwise"""
767         if not os.path.exists(self.report_file_path):
768             return True
769
770         try:
771             os.remove(self.report_file_path)
772             return True
773         except OSError:
774             messagebox.showerror(
775                 "Error",
776                 "Please close or rename the open report file before re-validating",
777             )
778             return False
779
780     @property
781     def report_file_path(self):
782         ext_mapping = {"csv": "csv", "html": "html", "excel": "xlsx"}
783         ext = ext_mapping.get(self.report_format.get().lower())
784         return os.path.join(PATH, OUT_DIR, "report.{}".format(ext))
785
786     # noinspection PyUnusedLocal
787     def open_report(self, event):
788         """Open the report in the user's default browser"""
789         path = Path(self.report_file_path).absolute().resolve().as_uri()
790         webbrowser.open_new(path)
791
792     def open_preloads(self, event):
793         """Open the report in the user's default browser"""
794         path = os.path.join(
795             PATH,
796             OUT_DIR,
797             "preloads",
798             self.config.get_subdir_for_preload(self.preload_format.get()),
799         )
800         if platform.system() == "Windows":
801             os.startfile(path)  # nosec
802         elif platform.system() == "Darwin":
803             subprocess.Popen(["open", path])  # nosec
804         else:
805             subprocess.Popen(["xdg-open", path])  # nosec
806
807     def open_requirements(self):
808         """Open the report in the user's default browser"""
809         webbrowser.open_new(self.config.requirement_link_url)
810
811     def start(self):
812         """Start the event loop of the application.  This method does not return"""
813         self._root.mainloop()
814
815     @staticmethod
816     def underline(label):
817         """Apply underline format to an existing label"""
818         f = font.Font(label, label.cget("font"))
819         f.configure(underline=True)
820         label.configure(font=f)
821
822     def kill_background_task(self):
823         if self.task and self.task.is_alive():
824             self.task.terminate()
825             for _ in self._drain_queue(self.config.log_queue):
826                 pass
827
828     def shutdown(self):
829         """Shutdown the application"""
830         self.kill_background_task()
831         self._root.destroy()
832
833     def check_template_source_is_valid(self):
834         """Verifies the value of template source exists and of valid type based
835         on input setting"""
836         if not self.template_source.get():
837             return False
838         template_path = Path(self.template_source.get())
839
840         if not template_path.exists():
841             messagebox.showerror(
842                 "Error",
843                 "Input does not exist. Please provide a valid file or directory.",
844             )
845             return False
846
847         if self.input_format.get() == "ZIP File":
848             if zipfile.is_zipfile(template_path):
849                 return True
850             else:
851                 messagebox.showerror(
852                     "Error", "Expected ZIP file, but input is not a valid ZIP file"
853                 )
854                 return False
855         else:
856             if template_path.is_dir():
857                 return True
858             else:
859                 messagebox.showerror(
860                     "Error", "Expected directory, but input is not a directory"
861                 )
862                 return False
863
864     def resolve_template_dir(self) -> str:
865         """Extracts the zip file to a temporary directory if needed, otherwise
866         returns the directory supplied to template source.  Returns empty string
867         if the template source isn't valid"""
868         if not self.check_template_source_is_valid():
869             return ""
870         if self.input_format.get() == "ZIP File":
871             temp_dir = tempfile.mkdtemp()
872             archive = zipfile.ZipFile(self.template_source.get())
873             archive.extractall(path=temp_dir)
874             return temp_dir
875         else:
876             return self.template_source.get()
877
878     def categories_list(self) -> list:
879         categories = []
880         selected_categories = self.categories
881         for x in range(0, len(selected_categories)):
882             if selected_categories[x].get():
883                 category = self.config.category_names[x]
884                 categories.append(self.config.get_category(category))
885         return categories
886
887
888 if __name__ == "__main__":
889     multiprocessing.freeze_support()  # needed for PyInstaller to work
890     ValidatorApp().start()