cc2c66f54d5e9f12c44677331f608d6edad7ca61
[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(0)
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         self.env_dir_entry = Entry(
511             actions, width=40, textvariable=self.env_dir, state=DISABLED
512         )
513         self.env_dir_entry.grid(row=5, column=2, pady=5, sticky=W)
514         env_dir_browse = Button(actions, text="...", command=self.ask_env_dir_source)
515         env_dir_browse.grid(row=5, column=3, pady=5, sticky=W)
516
517         validate_button = Button(
518             actions, text="Process Templates", command=self.validate
519         )
520         validate_button.grid(row=6, column=1, columnspan=2, pady=5)
521
522         self.result_panel = Frame(actions)
523         # We'll add these labels now, and then make them visible when the run completes
524         self.completion_label = Label(self.result_panel, text="Validation Complete!")
525         self.result_label = Label(
526             self.result_panel, text="View Report", fg="blue", cursor="hand2"
527         )
528         self.underline(self.result_label)
529         self.result_label.bind("<Button-1>", self.open_report)
530
531         self.preload_label = Label(
532             self.result_panel, text="View Preload Templates", fg="blue", cursor="hand2"
533         )
534         self.underline(self.preload_label)
535         self.preload_label.bind("<Button-1>", self.open_preloads)
536
537         self.result_panel.grid(row=7, column=1, columnspan=2)
538         control_panel.pack(fill=BOTH, expand=1)
539
540         main_window.add(control_panel)
541
542         self.log_panel = ScrolledText(main_window, wrap=WORD, width=120, height=20)
543         self.log_panel.configure(font=font.Font(family="Courier New", size="11"))
544         self.log_panel.pack(fill=BOTH, expand=1)
545
546         main_window.add(self.log_panel)
547
548         # Briefly add the completion and result labels so the window size includes
549         # room for them
550         self.completion_label.pack()
551         self.result_label.pack()  # Show report link
552         self.preload_label.pack()  # Show preload link
553         self._root.after_idle(
554             lambda: (
555                 self.completion_label.pack_forget(),
556                 self.result_label.pack_forget(),
557                 self.preload_label.pack_forget(),
558             )
559         )
560
561         self.config.watch(
562             *self.categories,
563             self.input_format,
564             self.report_format,
565             self.halt_on_failure,
566         )
567         if self.config.preload_formats:
568             self.config.watch(self.preload_format)
569         self.schedule(self.execute_pollers)
570         if self.config.terms_link_text and not self.config.are_terms_accepted:
571             TermsAndConditionsDialog(parent_frame, self.config)
572             if not self.config.are_terms_accepted:
573                 self.shutdown()
574
575     def create_footer(self, parent_frame):
576         footer = Frame(parent_frame)
577         disclaimer = Message(footer, text=self.config.disclaimer_text, anchor=CENTER)
578         disclaimer.grid(row=0, pady=2)
579         parent_frame.bind(
580             "<Configure>", lambda e: disclaimer.configure(width=e.width - 20)
581         )
582         if self.config.requirement_link_text:
583             requirement_link = Text(
584                 footer,
585                 height=1,
586                 bg=disclaimer.cget("bg"),
587                 relief=FLAT,
588                 font=disclaimer.cget("font"),
589             )
590             requirement_link.tag_configure("center", justify="center")
591             hyperlinks = HyperlinkManager(requirement_link)
592             requirement_link.insert(INSERT, "Validating: ")
593             requirement_link.insert(
594                 INSERT,
595                 self.config.requirement_link_text,
596                 hyperlinks.add(self.open_requirements),
597             )
598             requirement_link.tag_add("center", "1.0", "end")
599             requirement_link.config(state=DISABLED)
600             requirement_link.grid(row=1, pady=2)
601             ToolTip(requirement_link, self.config.requirement_link_url)
602         footer.grid_columnconfigure(0, weight=1)
603         footer.pack(fill=BOTH, expand=True)
604         return footer
605
606     def set_env_dir_state(self):
607         state = NORMAL if self.create_preloads.get() else DISABLED
608         self.env_dir_entry.config(state=state)
609
610     def ask_template_source(self):
611         if self.input_format.get() == "ZIP File":
612             template_source = filedialog.askopenfilename(
613                 title="Select Archive",
614                 filetypes=(("ZIP Files", "*.zip"), ("All Files", "*")),
615             )
616         else:
617             template_source = filedialog.askdirectory()
618         self.template_source.set(template_source)
619
620     def ask_env_dir_source(self):
621         self.env_dir.set(filedialog.askdirectory())
622
623     def validate(self):
624         """Run the pytest validations in a background process"""
625         if not self.delete_prior_report():
626             return
627
628         if not self.template_source.get():
629             self.ask_template_source()
630
631         template_dir = self.resolve_template_dir()
632
633         if template_dir:
634             self.kill_background_task()
635             self.clear_log()
636             self.completion_label.pack_forget()
637             self.result_label.pack_forget()
638             self.preload_label.pack_forget()
639             self.task = multiprocessing.Process(
640                 target=run_pytest,
641                 args=(
642                     template_dir,
643                     self.config.log_file,
644                     self.config.status_queue,
645                     self.categories_list(),
646                     self.report_format.get().lower(),
647                     self.halt_on_failure.get(),
648                     self.template_source.get(),
649                     self.env_dir.get(),
650                     self.preload_format.get(),
651                 ),
652             )
653             self.task.daemon = True
654             self.task.start()
655
656     @property
657     def title(self):
658         """Returns the text displayed in the title bar of the application"""
659         return self._root.title()
660
661     def execute_pollers(self):
662         """Call all methods that require periodic execution, and re-schedule
663         their execution for the next polling interval"""
664         try:
665             self.poll_log_file()
666             self.poll_status_queue()
667             self.poll_command_queue()
668         finally:
669             self.schedule(self.execute_pollers)
670
671     @staticmethod
672     def _drain_queue(q):
673         """Yields values from the queue until empty"""
674         while True:
675             try:
676                 yield q.get(block=False)
677             except queue.Empty:
678                 break
679
680     def poll_command_queue(self):
681         """Picks up command strings from the commmand queue, and
682         dispatches it for execution.  Only SHUTDOWN is supported
683         currently"""
684         for command in self._drain_queue(self.config.command_queue):
685             if command == "SHUTDOWN":
686                 self.shutdown()
687
688     def poll_status_queue(self):
689         """Checks for completion of the job, and then displays the View Report link
690         if it was successful or writes the exception to the ``log_panel`` if
691         it fails."""
692         for is_success, e in self._drain_queue(self.config.status_queue):
693             if is_success:
694                 self.completion_label.pack()
695                 self.result_label.pack()  # Show report link
696                 if hasattr(self, "preload_format"):
697                     self.preload_label.pack()  # Show preload link
698             else:
699                 self.log_panel.insert(END, str(e))
700
701     def poll_log_file(self):
702         """Reads captured stdout and stderr from the log queue and writes it to the
703         log panel."""
704         for line in self._drain_queue(self.config.log_queue):
705             self.log_panel.insert(END, line)
706             self.log_panel.see(END)
707
708     def schedule(self, func: Callable):
709         """Schedule the callable ``func`` to be executed according to
710         the polling_frequency"""
711         self._root.after(self.config.polling_frequency, func)
712
713     def clear_log(self):
714         """Removes all log entries from teh log panel"""
715         self.log_panel.delete("1.0", END)
716
717     def delete_prior_report(self) -> bool:
718         """Attempts to delete the current report, and pops up a warning message
719         to the user if it can't be deleted.  This will force the user to
720         close the report before re-running the validation.  Returns True if
721         the file was deleted or did not exist, or False otherwise"""
722         if not os.path.exists(self.report_file_path):
723             return True
724
725         try:
726             os.remove(self.report_file_path)
727             return True
728         except OSError:
729             messagebox.showerror(
730                 "Error",
731                 "Please close or rename the open report file before re-validating",
732             )
733             return False
734
735     @property
736     def report_file_path(self):
737         ext_mapping = {"csv": "csv", "html": "html", "excel": "xlsx"}
738         ext = ext_mapping.get(self.report_format.get().lower())
739         return os.path.join(PATH, OUT_DIR, "report.{}".format(ext))
740
741     # noinspection PyUnusedLocal
742     def open_report(self, event):
743         """Open the report in the user's default browser"""
744         path = Path(self.report_file_path).absolute().resolve().as_uri()
745         webbrowser.open_new(path)
746
747     def open_preloads(self, event):
748         """Open the report in the user's default browser"""
749         path = os.path.join(
750             PATH,
751             OUT_DIR,
752             "preloads",
753             self.config.get_subdir_for_preload(self.preload_format.get()),
754         )
755         if platform.system() == "Windows":
756             os.startfile(path)  # nosec
757         elif platform.system() == "Darwin":
758             subprocess.Popen(["open", path])  # nosec
759         else:
760             subprocess.Popen(["xdg-open", path])  # nosec
761
762     def open_requirements(self):
763         """Open the report in the user's default browser"""
764         webbrowser.open_new(self.config.requirement_link_url)
765
766     def start(self):
767         """Start the event loop of the application.  This method does not return"""
768         self._root.mainloop()
769
770     @staticmethod
771     def underline(label):
772         """Apply underline format to an existing label"""
773         f = font.Font(label, label.cget("font"))
774         f.configure(underline=True)
775         label.configure(font=f)
776
777     def kill_background_task(self):
778         if self.task and self.task.is_alive():
779             self.task.terminate()
780             for _ in self._drain_queue(self.config.log_queue):
781                 pass
782
783     def shutdown(self):
784         """Shutdown the application"""
785         self.kill_background_task()
786         self._root.destroy()
787
788     def check_template_source_is_valid(self):
789         """Verifies the value of template source exists and of valid type based
790         on input setting"""
791         if not self.template_source.get():
792             return False
793         template_path = Path(self.template_source.get())
794
795         if not template_path.exists():
796             messagebox.showerror(
797                 "Error",
798                 "Input does not exist. Please provide a valid file or directory.",
799             )
800             return False
801
802         if self.input_format.get() == "ZIP File":
803             if zipfile.is_zipfile(template_path):
804                 return True
805             else:
806                 messagebox.showerror(
807                     "Error", "Expected ZIP file, but input is not a valid ZIP file"
808                 )
809                 return False
810         else:
811             if template_path.is_dir():
812                 return True
813             else:
814                 messagebox.showerror(
815                     "Error", "Expected directory, but input is not a directory"
816                 )
817                 return False
818
819     def resolve_template_dir(self) -> str:
820         """Extracts the zip file to a temporary directory if needed, otherwise
821         returns the directory supplied to template source.  Returns empty string
822         if the template source isn't valid"""
823         if not self.check_template_source_is_valid():
824             return ""
825         if self.input_format.get() == "ZIP File":
826             temp_dir = tempfile.mkdtemp()
827             archive = zipfile.ZipFile(self.template_source.get())
828             archive.extractall(path=temp_dir)
829             return temp_dir
830         else:
831             return self.template_source.get()
832
833     def categories_list(self) -> list:
834         categories = []
835         selected_categories = self.categories
836         for x in range(0, len(selected_categories)):
837             if selected_categories[x].get():
838                 category = self.config.category_names[x]
839                 categories.append(self.config.get_category(category))
840         return categories
841
842
843 if __name__ == "__main__":
844     multiprocessing.freeze_support()  # needed for PyInstaller to work
845     ValidatorApp().start()