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