[VVP] Removing dynamic download of needs.json
[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 # ECOMP is a trademark and service mark of AT&T Intellectual Property.
39 #
40
41 """
42 A GUI that wraps the pytest validations scripts.
43
44 To make an executable for windows execute  the ``make_exe.bat`` to generate the
45 .exe and its associated files.  The the necessary files will be written to the
46 ``dist/vvp/`` directory.  This entire directory must be copied to the target machine.
47
48 NOTE: This script does require Python 3.6+
49 """
50 import appdirs
51 import os
52 import pytest
53 import sys
54 import version
55 import yaml
56 import contextlib
57 import multiprocessing
58 import queue
59 import tempfile
60 import webbrowser
61 import zipfile
62
63 from collections import MutableMapping
64 from configparser import ConfigParser
65 from multiprocessing import Queue
66 from pathlib import Path
67 from shutil import rmtree
68 from tkinter import (
69     filedialog,
70     font,
71     messagebox,
72     Tk,
73     PanedWindow,
74     BOTH,
75     HORIZONTAL,
76     RAISED,
77     Frame,
78     Label,
79     W,
80     StringVar,
81     OptionMenu,
82     LabelFrame,
83     E,
84     BooleanVar,
85     Entry,
86     Button,
87     WORD,
88     END,
89     Checkbutton,
90     IntVar,
91     Toplevel,
92 )
93 from tkinter.scrolledtext import ScrolledText
94 from typing import Optional, List, Dict, TextIO, Callable, Iterator
95
96 VERSION = version.VERSION
97 PATH = os.path.dirname(os.path.realpath(__file__))
98 OUT_DIR = "output"
99
100
101 class ToolTip(object):
102     """
103     create a tooltip for a given widget
104     """
105
106     def __init__(self, widget, text="widget info"):
107         self.waittime = 750  # miliseconds
108         self.wraplength = 180  # pixels
109         self.widget = widget
110         self.text = text
111         self.widget.bind("<Enter>", self.enter)
112         self.widget.bind("<Leave>", self.leave)
113         self.widget.bind("<ButtonPress>", self.leave)
114         self.id = None
115         self.tw = None
116
117     def enter(self, event=None):
118         self.schedule()
119
120     def leave(self, event=None):
121         self.unschedule()
122         self.hidetip()
123
124     def schedule(self):
125         self.unschedule()
126         self.id = self.widget.after(self.waittime, self.showtip)
127
128     def unschedule(self):
129         id = self.id
130         self.id = None
131         if id:
132             self.widget.after_cancel(id)
133
134     def showtip(self, event=None):
135         x = y = 0
136         x, y, cx, cy = self.widget.bbox("insert")
137         x += self.widget.winfo_rootx() + 25
138         y += self.widget.winfo_rooty() + 20
139         # creates a toplevel window
140         self.tw = Toplevel(self.widget)
141         # Leaves only the label and removes the app window
142         self.tw.wm_overrideredirect(True)
143         self.tw.wm_geometry("+%d+%d" % (x, y))
144         label = Label(
145             self.tw,
146             text=self.text,
147             justify="left",
148             background="#ffffff",
149             relief="solid",
150             borderwidth=1,
151             wraplength=self.wraplength,
152         )
153         label.pack(ipadx=1)
154
155     def hidetip(self):
156         tw = self.tw
157         self.tw = None
158         if tw:
159             tw.destroy()
160
161
162 class QueueWriter:
163     """``stdout`` and ``stderr`` will be written to this queue by pytest, and
164     pulled into the main GUI application"""
165
166     def __init__(self, log_queue: queue.Queue):
167         """Writes data to the provided queue.
168
169         :param log_queue: the queue instance to write to.
170         """
171         self.queue = log_queue
172
173     def write(self, data: str):
174         """Writes ``data`` to the queue """
175         self.queue.put(data)
176
177     # noinspection PyMethodMayBeStatic
178     def isatty(self) -> bool:
179         """Always returns ``False``"""
180         return False
181
182     def flush(self):
183         """No operation method to satisfy file-like behavior"""
184         pass
185
186
187 def get_plugins() -> Optional[List]:
188     """When running in a frozen bundle, plugins to be registered
189     explicitly. This method will return the required plugins to register
190     based on the run mode"""
191     if hasattr(sys, "frozen"):
192         import pytest_tap.plugin
193
194         return [pytest_tap.plugin]
195     else:
196         return None
197
198
199 def run_pytest(
200     template_dir: str,
201     log: TextIO,
202     result_queue: Queue,
203     categories: Optional[list],
204     verbosity: str,
205     report_format: str,
206     halt_on_failure: bool,
207     template_source: str,
208 ):
209     """Runs pytest using the given ``profile`` in a background process.  All
210     ``stdout`` and ``stderr`` are redirected to ``log``.  The result of the job
211     will be put on the ``completion_queue``
212
213     :param template_dir:        The directory containing the files to be validated.
214     :param log: `               `stderr`` and ``stdout`` of the pytest job will be
215                                 directed here
216     :param result_queue:        Completion status posted here.  See :class:`Config`
217                                 for more information.
218     :param categories:          list of optional categories. When provided, pytest
219                                 will collect and execute all tests that are
220                                 decorated with any of the passed categories, as
221                                 well as tests not decorated with a category.
222     :param verbosity:           Flag to be passed to pytest to control verbosity.
223                                 Options are '' (empty string), '-v' (verbose),
224                                 '-vv' (more verbose).
225     :param report_format:       Determines the style of report written.  Options are
226                                 csv, html, or excel
227     :param halt_on_failure:     Determines if validation will halt when basic failures
228                                 are encountered in the input files.  This can help
229                                 prevent a large number of errors from flooding the
230                                 report.
231     """
232     out_path = "{}/{}".format(PATH, OUT_DIR)
233     if os.path.exists(out_path):
234         rmtree(out_path, ignore_errors=True)
235     with contextlib.redirect_stderr(log), contextlib.redirect_stdout(log):
236         try:
237             args = [
238                 "--ignore=app_tests",
239                 "--capture=sys",
240                 verbosity,
241                 "--template-directory={}".format(template_dir),
242                 "--report-format={}".format(report_format),
243                 "--template-source={}".format(template_source),
244             ]
245             if categories:
246                 for category in categories:
247                     args.extend(("--category", category))
248             if not halt_on_failure:
249                 args.append("--continue-on-failure")
250             pytest.main(args=args, plugins=get_plugins())
251             result_queue.put((True, None))
252         except Exception as e:
253             result_queue.put((False, e))
254
255
256 class UserSettings(MutableMapping):
257     FILE_NAME = "UserSettings.ini"
258
259     def __init__(self):
260         user_config_dir = appdirs.AppDirs("org.onap.vvp", "ONAP").user_config_dir
261         if not os.path.exists(user_config_dir):
262             os.makedirs(user_config_dir, exist_ok=True)
263         self._settings_path = os.path.join(user_config_dir, self.FILE_NAME)
264         self._config = ConfigParser()
265         self._config.read(self._settings_path)
266
267     def __getitem__(self, k):
268         return self._config["DEFAULT"][k]
269
270     def __setitem__(self, k, v) -> None:
271         self._config["DEFAULT"][k] = v
272
273     def __delitem__(self, v) -> None:
274         del self._config["DEFAULT"][v]
275
276     def __len__(self) -> int:
277         return len(self._config["DEFAULT"])
278
279     def __iter__(self) -> Iterator:
280         return iter(self._config["DEFAULT"])
281
282     def save(self):
283         with open(self._settings_path, "w") as f:
284             self._config.write(f)
285
286
287 class Config:
288     """
289     Configuration for the Validation GUI Application
290
291     Attributes
292     ----------
293     ``log_queue``       Queue for the ``stdout`` and ``stderr` of
294                         the background job
295     ``log_file``        File-like object (write only!) that writes to
296                         the ``log_queue``
297     ``status_queue``    Job completion status of the background job is
298                         posted here as a tuple of (bool, Exception).
299                         The first parameter is True if the job completed
300                         successfully, and False otherwise.  If the job
301                         failed, then an Exception will be provided as the
302                         second element.
303     ``command_queue``   Used to send commands to the GUI.  Currently only
304                         used to send shutdown commands in tests.
305     """
306
307     DEFAULT_FILENAME = "vvp-config.yaml"
308     DEFAULT_POLLING_FREQUENCY = "1000"
309
310     def __init__(self, config: dict = None):
311         """Creates instance of application configuration.
312
313         :param config: override default configuration if provided."""
314         if config:
315             self._config = config
316         else:
317             with open(self.DEFAULT_FILENAME, "r") as f:
318                 self._config = yaml.load(f)
319         self._user_settings = UserSettings()
320         self._watched_variables = []
321         self._validate()
322         self._manager = multiprocessing.Manager()
323         self.log_queue = self._manager.Queue()
324         self.status_queue = self._manager.Queue()
325         self.log_file = QueueWriter(self.log_queue)
326         self.command_queue = self._manager.Queue()
327
328     def watch(self, *variables):
329         """Traces the variables and saves their settings for the user.  The
330         last settings will be used where available"""
331         self._watched_variables = variables
332         for var in self._watched_variables:
333             var.trace_add("write", self.save_settings)
334
335     def save_settings(self, *args):
336         """Save the value of all watched variables to user settings"""
337         for var in self._watched_variables:
338             self._user_settings[var._name] = str(var.get())
339         self._user_settings.save()
340
341     @property
342     def app_name(self) -> str:
343         """Name of the application (displayed in title bar)"""
344         app_name = self._config["ui"].get("app-name", "VNF Validation Tool")
345         return "{} - {}".format(app_name, VERSION)
346
347     @property
348     def category_names(self) -> List[str]:
349         """List of validation profile names for display in the UI"""
350         return [category["name"] for category in self._config["categories"]]
351
352     @property
353     def polling_frequency(self) -> int:
354         """Returns the frequency (in ms) the UI polls the queue communicating
355         with any background job"""
356         return int(
357             self._config["settings"].get(
358                 "polling-frequency", self.DEFAULT_POLLING_FREQUENCY
359             )
360         )
361
362     def default_verbosity(self, levels: Dict[str, str]) -> str:
363         requested_level = self._user_settings.get("verbosity") or self._config[
364             "settings"
365         ].get("default-verbosity", "Standard")
366         keys = [key for key in levels]
367         for key in levels:
368             if key.lower().startswith(requested_level.lower()):
369                 return key
370         raise RuntimeError(
371             "Invalid default-verbosity level {}. Valid"
372             "values are {}".format(requested_level, ", ".join(keys))
373         )
374
375     def get_description(self, category_name: str) -> str:
376         """Returns the description associated with the category name"""
377         return self._get_category(category_name)["description"]
378
379     def get_category(self, category_name: str) -> str:
380         """Returns the category associated with the category name"""
381         return self._get_category(category_name).get("category", "")
382
383     def get_category_value(self, category_name: str) -> str:
384         """Returns the saved value for a category name"""
385         return self._user_settings.get(category_name, 0)
386
387     def _get_category(self, category_name: str) -> Dict[str, str]:
388         """Returns the profile definition"""
389         for category in self._config["categories"]:
390             if category["name"] == category_name:
391                 return category
392         raise RuntimeError(
393             "Unexpected error: No category found in vvp-config.yaml "
394             "with a name of " + category_name
395         )
396
397     @property
398     def default_report_format(self):
399         return self._user_settings.get("report_format", "HTML")
400
401     @property
402     def report_formats(self):
403         return ["CSV", "Excel", "HTML"]
404
405     @property
406     def default_input_format(self):
407         requested_default = self._user_settings.get("input_format") or self._config[
408             "settings"
409         ].get("default-input-format")
410         if requested_default in self.input_formats:
411             return requested_default
412         else:
413             return self.input_formats[0]
414
415     @property
416     def input_formats(self):
417         return ["Directory (Uncompressed)", "ZIP File"]
418
419     @property
420     def default_halt_on_failure(self):
421         setting = self._user_settings.get("halt_on_failure", "True")
422         return setting.lower() == "true"
423
424     def _validate(self):
425         """Ensures the config file is properly formatted"""
426         categories = self._config["categories"]
427
428         # All profiles have required keys
429         expected_keys = {"name", "description"}
430         for category in categories:
431             actual_keys = set(category.keys())
432             missing_keys = expected_keys.difference(actual_keys)
433             if missing_keys:
434                 raise RuntimeError(
435                     "Error in vvp-config.yaml file: "
436                     "Required field missing in category. "
437                     "Missing: {} "
438                     "Categories: {}".format(",".join(missing_keys), category)
439                 )
440
441
442 class ValidatorApp:
443     VERBOSITY_LEVELS = {"Less": "", "Standard (-v)": "-v", "More (-vv)": "-vv"}
444
445     def __init__(self, config: Config = None):
446         """Constructs the GUI element of the Validation Tool"""
447         self.task = None
448         self.config = config or Config()
449
450         self._root = Tk()
451         self._root.title(self.config.app_name)
452         self._root.protocol("WM_DELETE_WINDOW", self.shutdown)
453
454         main_window = PanedWindow(self._root)
455         main_window.pack(fill=BOTH, expand=1)
456
457         control_panel = PanedWindow(
458             main_window, orient=HORIZONTAL, sashpad=4, sashrelief=RAISED
459         )
460         actions = Frame(control_panel)
461         control_panel.add(actions)
462         control_panel.paneconfigure(actions, minsize=250)
463
464         # profile start
465         number_of_categories = len(self.config.category_names)
466         category_frame = LabelFrame(actions, text="Additional Validation Categories:")
467         category_frame.grid(row=1, column=1, columnspan=3, pady=5, sticky="we")
468
469         self.categories = []
470
471         for x in range(0, number_of_categories):
472             category_name = self.config.category_names[x]
473             category_value = IntVar(value=0)
474             category_value._name = "category_{}".format(category_name.replace(" ", "_"))
475             category_value.set(self.config.get_category_value(category_value._name))
476             self.categories.append(category_value)
477             category_checkbox = Checkbutton(
478                 category_frame, text=category_name, variable=self.categories[x]
479             )
480             ToolTip(category_checkbox, self.config.get_description(category_name))
481             category_checkbox.grid(row=x + 1, column=1, columnspan=2, sticky="w")
482
483         settings_frame = LabelFrame(actions, text="Settings")
484         settings_frame.grid(row=3, column=1, columnspan=3, pady=10, sticky="we")
485         verbosity_label = Label(settings_frame, text="Verbosity:")
486         verbosity_label.grid(row=1, column=1, sticky=W)
487         self.verbosity = StringVar(self._root, name="verbosity")
488         self.verbosity.set(self.config.default_verbosity(self.VERBOSITY_LEVELS))
489         verbosity_menu = OptionMenu(
490             settings_frame, self.verbosity, *tuple(self.VERBOSITY_LEVELS.keys())
491         )
492         verbosity_menu.config(width=25)
493         verbosity_menu.grid(row=1, column=2, columnspan=3, sticky=E, pady=5)
494
495         report_format_label = Label(settings_frame, text="Report Format:")
496         report_format_label.grid(row=2, column=1, sticky=W)
497         self.report_format = StringVar(self._root, name="report_format")
498         self.report_format.set(self.config.default_report_format)
499         report_format_menu = OptionMenu(
500             settings_frame, self.report_format, *self.config.report_formats
501         )
502         report_format_menu.config(width=25)
503         report_format_menu.grid(row=2, column=2, columnspan=3, sticky=E, pady=5)
504
505         input_format_label = Label(settings_frame, text="Input Format:")
506         input_format_label.grid(row=3, column=1, sticky=W)
507         self.input_format = StringVar(self._root, name="input_format")
508         self.input_format.set(self.config.default_input_format)
509         input_format_menu = OptionMenu(
510             settings_frame, self.input_format, *self.config.input_formats
511         )
512         input_format_menu.config(width=25)
513         input_format_menu.grid(row=3, column=2, columnspan=3, sticky=E, pady=5)
514
515         self.halt_on_failure = BooleanVar(self._root, name="halt_on_failure")
516         self.halt_on_failure.set(self.config.default_halt_on_failure)
517         halt_on_failure_label = Label(settings_frame, text="Halt on Basic Failures:")
518         halt_on_failure_label.grid(row=4, column=1, sticky=E, pady=5)
519         halt_checkbox = Checkbutton(
520             settings_frame, offvalue=False, onvalue=True, variable=self.halt_on_failure
521         )
522         halt_checkbox.grid(row=4, column=2, columnspan=2, sticky=W, pady=5)
523
524         directory_label = Label(actions, text="Template Location:")
525         directory_label.grid(row=4, column=1, pady=5, sticky=W)
526         self.template_source = StringVar(self._root, name="template_source")
527         directory_entry = Entry(actions, width=40, textvariable=self.template_source)
528         directory_entry.grid(row=4, column=2, pady=5, sticky=W)
529         directory_browse = Button(actions, text="...", command=self.ask_template_source)
530         directory_browse.grid(row=4, column=3, pady=5, sticky=W)
531
532         validate = Button(actions, text="Validate Templates", command=self.validate)
533         validate.grid(row=5, column=1, columnspan=2, pady=5)
534
535         self.result_panel = Frame(actions)
536         # We'll add these labels now, and then make them visible when the run completes
537         self.completion_label = Label(self.result_panel, text="Validation Complete!")
538         self.result_label = Label(
539             self.result_panel, text="View Report", fg="blue", cursor="hand2"
540         )
541         self.underline(self.result_label)
542         self.result_label.bind("<Button-1>", self.open_report)
543         self.result_panel.grid(row=6, column=1, columnspan=2)
544         control_panel.pack(fill=BOTH, expand=1)
545
546         main_window.add(control_panel)
547
548         self.log_panel = ScrolledText(main_window, wrap=WORD, width=120, height=20)
549         self.log_panel.configure(font=font.Font(family="Courier New", size="11"))
550         self.log_panel.pack(fill=BOTH, expand=1)
551
552         main_window.add(self.log_panel)
553
554         # Briefly add the completion and result labels so the window size includes
555         # room for them
556         self.completion_label.pack()
557         self.result_label.pack()  # Show report link
558         self._root.after_idle(
559             lambda: (
560                 self.completion_label.pack_forget(),
561                 self.result_label.pack_forget(),
562             )
563         )
564
565         self.config.watch(
566             *self.categories,
567             self.verbosity,
568             self.input_format,
569             self.report_format,
570             self.halt_on_failure,
571         )
572         self.schedule(self.execute_pollers)
573
574     def ask_template_source(self):
575         if self.input_format.get() == "ZIP File":
576             template_source = filedialog.askopenfilename(
577                 title="Select Archive",
578                 filetypes=(("ZIP Files", "*.zip"), ("All Files", "*")),
579             )
580         else:
581             template_source = filedialog.askdirectory()
582         self.template_source.set(template_source)
583
584     def validate(self):
585         """Run the pytest validations in a background process"""
586         if not self.delete_prior_report():
587             return
588
589         if not self.template_source.get():
590             self.ask_template_source()
591
592         template_dir = self.resolve_template_dir()
593
594         if template_dir:
595             self.kill_background_task()
596             self.clear_log()
597             self.completion_label.pack_forget()
598             self.result_label.pack_forget()
599             self.task = multiprocessing.Process(
600                 target=run_pytest,
601                 args=(
602                     template_dir,
603                     self.config.log_file,
604                     self.config.status_queue,
605                     self.categories_list(),
606                     self.VERBOSITY_LEVELS[self.verbosity.get()],
607                     self.report_format.get().lower(),
608                     self.halt_on_failure.get(),
609                     self.template_source.get(),
610                 ),
611             )
612             self.task.daemon = True
613             self.task.start()
614
615     @property
616     def title(self):
617         """Returns the text displayed in the title bar of the application"""
618         return self._root.title()
619
620     def execute_pollers(self):
621         """Call all methods that require periodic execution, and re-schedule
622         their execution for the next polling interval"""
623         try:
624             self.poll_log_file()
625             self.poll_status_queue()
626             self.poll_command_queue()
627         finally:
628             self.schedule(self.execute_pollers)
629
630     @staticmethod
631     def _drain_queue(q):
632         """Yields values from the queue until empty"""
633         while True:
634             try:
635                 yield q.get(block=False)
636             except queue.Empty:
637                 break
638
639     def poll_command_queue(self):
640         """Picks up command strings from the commmand queue, and
641         dispatches it for execution.  Only SHUTDOWN is supported
642         currently"""
643         for command in self._drain_queue(self.config.command_queue):
644             if command == "SHUTDOWN":
645                 self.shutdown()
646
647     def poll_status_queue(self):
648         """Checks for completion of the job, and then displays the View Report link
649         if it was successful or writes the exception to the ``log_panel`` if
650         it fails."""
651         for is_success, e in self._drain_queue(self.config.status_queue):
652             if is_success:
653                 self.completion_label.pack()
654                 self.result_label.pack()  # Show report link
655             else:
656                 self.log_panel.insert(END, str(e))
657
658     def poll_log_file(self):
659         """Reads captured stdout and stderr from the log queue and writes it to the
660         log panel."""
661         for line in self._drain_queue(self.config.log_queue):
662             self.log_panel.insert(END, line)
663             self.log_panel.see(END)
664
665     def schedule(self, func: Callable):
666         """Schedule the callable ``func`` to be executed according to
667         the polling_frequency"""
668         self._root.after(self.config.polling_frequency, func)
669
670     def clear_log(self):
671         """Removes all log entries from teh log panel"""
672         self.log_panel.delete("1.0", END)
673
674     def delete_prior_report(self) -> bool:
675         """Attempts to delete the current report, and pops up a warning message
676         to the user if it can't be deleted.  This will force the user to
677         close the report before re-running the validation.  Returns True if
678         the file was deleted or did not exist, or False otherwise"""
679         if not os.path.exists(self.report_file_path):
680             return True
681
682         try:
683             os.remove(self.report_file_path)
684             return True
685         except OSError:
686             messagebox.showerror(
687                 "Error",
688                 "Please close or rename the open report file before re-validating",
689             )
690             return False
691
692     @property
693     def report_file_path(self):
694         ext_mapping = {"csv": "csv", "html": "html", "excel": "xlsx"}
695         ext = ext_mapping.get(self.report_format.get().lower())
696         return os.path.join(PATH, OUT_DIR, "report.{}".format(ext))
697
698     def open_report(self, event):
699         """Open the report in the user's default browser"""
700         webbrowser.open_new("file://{}".format(self.report_file_path))
701
702     def start(self):
703         """Start the event loop of the application.  This method does not return"""
704         self._root.mainloop()
705
706     @staticmethod
707     def underline(label):
708         """Apply underline format to an existing label"""
709         f = font.Font(label, label.cget("font"))
710         f.configure(underline=True)
711         label.configure(font=f)
712
713     def kill_background_task(self):
714         if self.task and self.task.is_alive():
715             self.task.terminate()
716             for _ in self._drain_queue(self.config.log_queue):
717                 pass
718
719     def shutdown(self):
720         """Shutdown the application"""
721         self.kill_background_task()
722         self._root.destroy()
723
724     def check_template_source_is_valid(self):
725         """Verifies the value of template source exists and of valid type based
726         on input setting"""
727         if not self.template_source.get():
728             return False
729         template_path = Path(self.template_source.get())
730
731         if not template_path.exists():
732             messagebox.showerror(
733                 "Error",
734                 "Input does not exist. Please provide a valid file or directory.",
735             )
736             return False
737
738         if self.input_format.get() == "ZIP File":
739             if zipfile.is_zipfile(template_path):
740                 return True
741             else:
742                 messagebox.showerror(
743                     "Error", "Expected ZIP file, but input is not a valid ZIP file"
744                 )
745                 return False
746         else:
747             if template_path.is_dir():
748                 return True
749             else:
750                 messagebox.showerror(
751                     "Error", "Expected directory, but input is not a directory"
752                 )
753                 return False
754
755     def resolve_template_dir(self) -> str:
756         """Extracts the zip file to a temporary directory if needed, otherwise
757         returns the directory supplied to template source.  Returns empty string
758         if the template source isn't valid"""
759         if not self.check_template_source_is_valid():
760             return ""
761         if self.input_format.get() == "ZIP File":
762             temp_dir = tempfile.mkdtemp()
763             archive = zipfile.ZipFile(self.template_source.get())
764             archive.extractall(path=temp_dir)
765             return temp_dir
766         else:
767             return self.template_source.get()
768
769     def categories_list(self) -> list:
770         categories = []
771         selected_categories = self.categories
772         for x in range(0, len(selected_categories)):
773             if selected_categories[x].get():
774                 category = self.config.category_names[x]
775                 categories.append(self.config.get_category(category))
776         return categories
777
778
779 if __name__ == "__main__":
780     multiprocessing.freeze_support()  # needed for PyInstaller to work
781     ValidatorApp().start()