2 # ============LICENSE_START====================================================
3 # org.onap.vvp/validation-scripts
4 # ===================================================================
5 # Copyright © 2019 AT&T Intellectual Property. All rights reserved.
6 # ===================================================================
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
13 # http://www.apache.org/licenses/LICENSE-2.0
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.
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
28 # https://creativecommons.org/licenses/by/4.0/
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.
36 # ============LICENSE_END============================================
41 A GUI that wraps the pytest validations scripts.
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.
47 NOTE: This script does require Python 3.6+
56 import multiprocessing
62 from collections import MutableMapping
63 from configparser import ConfigParser
64 from multiprocessing import Queue
65 from pathlib import Path
66 from shutil import rmtree
103 from tkinter.scrolledtext import ScrolledText
104 from typing import Optional, List, Dict, TextIO, Callable, Iterator
106 VERSION = version.VERSION
107 PATH = os.path.dirname(os.path.realpath(__file__))
111 class ToolTip(object):
113 create a tooltip for a given widget
116 def __init__(self, widget, text="widget info"):
117 self.waittime = 750 # milliseconds
118 self.wraplength = 300 # pixels
121 self.widget.bind("<Enter>", self.enter)
122 self.widget.bind("<Leave>", self.leave)
123 self.widget.bind("<ButtonPress>", self.leave)
127 # noinspection PyUnusedLocal
128 def enter(self, event=None):
131 # noinspection PyUnusedLocal
132 def leave(self, event=None):
138 self.id = self.widget.after(self.waittime, self.showtip)
140 def unschedule(self):
144 self.widget.after_cancel(orig_id)
146 # noinspection PyUnusedLocal
147 def showtip(self, event=None):
149 x, y, cx, cy = self.widget.bbox("insert")
150 x += self.widget.winfo_rootx() + 25
151 y += self.widget.winfo_rooty() + 20
152 # creates a top level window
153 self.tw = Toplevel(self.widget)
154 # Leaves only the label and removes the app window
155 self.tw.wm_overrideredirect(True)
156 self.tw.wm_geometry("+%d+%d" % (x, y))
161 background="#ffffff",
164 wraplength=self.wraplength,
175 class HyperlinkManager:
176 """Adapted from http://effbot.org/zone/tkinter-text-hyperlink.htm"""
178 def __init__(self, text):
181 self.text.tag_config("hyper", foreground="blue", underline=1)
182 self.text.tag_bind("hyper", "<Enter>", self._enter)
183 self.text.tag_bind("hyper", "<Leave>", self._leave)
184 self.text.tag_bind("hyper", "<Button-1>", self._click)
190 def add(self, action):
191 # add an action to the manager. returns tags to use in
192 # associated text widget
193 tag = "hyper-%d" % len(self.links)
194 self.links[tag] = action
197 # noinspection PyUnusedLocal
198 def _enter(self, event):
199 self.text.config(cursor="hand2")
201 # noinspection PyUnusedLocal
202 def _leave(self, event):
203 self.text.config(cursor="")
205 # noinspection PyUnusedLocal
206 def _click(self, event):
207 for tag in self.text.tag_names(CURRENT):
208 if tag[:6] == "hyper-":
214 """``stdout`` and ``stderr`` will be written to this queue by pytest, and
215 pulled into the main GUI application"""
217 def __init__(self, log_queue: queue.Queue):
218 """Writes data to the provided queue.
220 :param log_queue: the queue instance to write to.
222 self.queue = log_queue
224 def write(self, data: str):
225 """Writes ``data`` to the queue """
228 # noinspection PyMethodMayBeStatic
229 def isatty(self) -> bool:
230 """Always returns ``False``"""
234 """No operation method to satisfy file-like behavior"""
238 def get_plugins() -> Optional[List]:
239 """When running in a frozen bundle, plugins to be registered
240 explicitly. This method will return the required plugins to register
241 based on the run mode"""
242 if hasattr(sys, "frozen"):
243 import pytest_tap.plugin
245 return [pytest_tap.plugin]
254 categories: Optional[list],
257 halt_on_failure: bool,
258 template_source: str,
260 """Runs pytest using the given ``profile`` in a background process. All
261 ``stdout`` and ``stderr`` are redirected to ``log``. The result of the job
262 will be put on the ``completion_queue``
264 :param template_dir: The directory containing the files to be validated.
265 :param log: ` `stderr`` and ``stdout`` of the pytest job will be
267 :param result_queue: Completion status posted here. See :class:`Config`
268 for more information.
269 :param categories: list of optional categories. When provided, pytest
270 will collect and execute all tests that are
271 decorated with any of the passed categories, as
272 well as tests not decorated with a category.
273 :param verbosity: Flag to be passed to pytest to control verbosity.
274 Options are '' (empty string), '-v' (verbose),
275 '-vv' (more verbose).
276 :param report_format: Determines the style of report written. Options are
278 :param halt_on_failure: Determines if validation will halt when basic failures
279 are encountered in the input files. This can help
280 prevent a large number of errors from flooding the
282 :param template_source: The path or name of the template to show on the report
284 out_path = "{}/{}".format(PATH, OUT_DIR)
285 if os.path.exists(out_path):
286 rmtree(out_path, ignore_errors=True)
287 with contextlib.redirect_stderr(log), contextlib.redirect_stdout(log):
290 "--ignore=app_tests",
293 "--template-directory={}".format(template_dir),
294 "--report-format={}".format(report_format),
295 "--template-source={}".format(template_source),
298 for category in categories:
299 args.extend(("--category", category))
300 if not halt_on_failure:
301 args.append("--continue-on-failure")
302 pytest.main(args=args, plugins=get_plugins())
303 result_queue.put((True, None))
304 except Exception as e:
305 result_queue.put((False, e))
308 class UserSettings(MutableMapping):
309 FILE_NAME = "UserSettings.ini"
311 def __init__(self, namespace, owner):
312 user_config_dir = appdirs.AppDirs(namespace, owner).user_config_dir
313 if not os.path.exists(user_config_dir):
314 os.makedirs(user_config_dir, exist_ok=True)
315 self._settings_path = os.path.join(user_config_dir, self.FILE_NAME)
316 self._config = ConfigParser()
317 self._config.read(self._settings_path)
319 def __getitem__(self, k):
320 return self._config["DEFAULT"][k]
322 def __setitem__(self, k, v) -> None:
323 self._config["DEFAULT"][k] = v
325 def __delitem__(self, v) -> None:
326 del self._config["DEFAULT"][v]
328 def __len__(self) -> int:
329 return len(self._config["DEFAULT"])
331 def __iter__(self) -> Iterator:
332 return iter(self._config["DEFAULT"])
335 with open(self._settings_path, "w") as f:
336 self._config.write(f)
341 Configuration for the Validation GUI Application
345 ``log_queue`` Queue for the ``stdout`` and ``stderr` of
347 ``log_file`` File-like object (write only!) that writes to
349 ``status_queue`` Job completion status of the background job is
350 posted here as a tuple of (bool, Exception).
351 The first parameter is True if the job completed
352 successfully, and False otherwise. If the job
353 failed, then an Exception will be provided as the
355 ``command_queue`` Used to send commands to the GUI. Currently only
356 used to send shutdown commands in tests.
359 DEFAULT_FILENAME = "vvp-config.yaml"
360 DEFAULT_POLLING_FREQUENCY = "1000"
362 def __init__(self, config: dict = None):
363 """Creates instance of application configuration.
365 :param config: override default configuration if provided."""
367 self._config = config
369 with open(self.DEFAULT_FILENAME, "r") as f:
370 self._config = yaml.load(f)
371 self._user_settings = UserSettings(
372 self._config["namespace"], self._config["owner"]
374 self._watched_variables = []
376 self._manager = multiprocessing.Manager()
377 self.log_queue = self._manager.Queue()
378 self.status_queue = self._manager.Queue()
379 self.log_file = QueueWriter(self.log_queue)
380 self.command_queue = self._manager.Queue()
382 def watch(self, *variables):
383 """Traces the variables and saves their settings for the user. The
384 last settings will be used where available"""
385 self._watched_variables = variables
386 for var in self._watched_variables:
387 var.trace_add("write", self.save_settings)
389 # noinspection PyProtectedMember,PyUnusedLocal
390 def save_settings(self, *args):
391 """Save the value of all watched variables to user settings"""
392 for var in self._watched_variables:
393 self._user_settings[var._name] = str(var.get())
394 self._user_settings.save()
397 def app_name(self) -> str:
398 """Name of the application (displayed in title bar)"""
399 app_name = self._config["ui"].get("app-name", "VNF Validation Tool")
400 return "{} - {}".format(app_name, VERSION)
403 def category_names(self) -> List[str]:
404 """List of validation profile names for display in the UI"""
405 return [category["name"] for category in self._config["categories"]]
408 def polling_frequency(self) -> int:
409 """Returns the frequency (in ms) the UI polls the queue communicating
410 with any background job"""
412 self._config["settings"].get(
413 "polling-frequency", self.DEFAULT_POLLING_FREQUENCY
418 def disclaimer_text(self) -> str:
419 return self._config["ui"].get("disclaimer-text", "")
422 def requirement_link_text(self) -> str:
423 return self._config["ui"].get("requirement-link-text", "")
426 def requirement_link_url(self) -> str:
427 path = self._config["ui"].get("requirement-link-url", "")
428 return "file://{}".format(os.path.join(PATH, path))
431 def terms(self) -> dict:
432 return self._config.get("terms", {})
435 def terms_link_url(self) -> Optional[str]:
436 return self.terms.get("path")
439 def terms_link_text(self):
440 return self.terms.get("popup-link-text")
443 def terms_version(self) -> Optional[str]:
444 return self.terms.get("version")
447 def terms_popup_title(self) -> Optional[str]:
448 return self.terms.get("popup-title")
451 def terms_popup_message(self) -> Optional[str]:
452 return self.terms.get("popup-msg-text")
455 def are_terms_accepted(self) -> bool:
456 version = "terms-{}".format(self.terms_version)
457 return self._user_settings.get(version, "False") == "True"
459 def set_terms_accepted(self):
460 version = "terms-{}".format(self.terms_version)
461 self._user_settings[version] = "True"
462 self._user_settings.save()
464 def default_verbosity(self, levels: Dict[str, str]) -> str:
465 requested_level = self._user_settings.get("verbosity") or self._config[
467 ].get("default-verbosity", "Standard")
468 keys = [key for key in levels]
470 if key.lower().startswith(requested_level.lower()):
473 "Invalid default-verbosity level {}. Valid "
474 "values are {}".format(requested_level, ", ".join(keys))
477 def get_description(self, category_name: str) -> str:
478 """Returns the description associated with the category name"""
479 return self._get_category(category_name)["description"]
481 def get_category(self, category_name: str) -> str:
482 """Returns the category associated with the category name"""
483 return self._get_category(category_name).get("category", "")
485 def get_category_value(self, category_name: str) -> str:
486 """Returns the saved value for a category name"""
487 return self._user_settings.get(category_name, 0)
489 def _get_category(self, category_name: str) -> Dict[str, str]:
490 """Returns the profile definition"""
491 for category in self._config["categories"]:
492 if category["name"] == category_name:
495 "Unexpected error: No category found in vvp-config.yaml "
496 "with a name of " + category_name
500 def default_report_format(self):
501 return self._user_settings.get("report_format", "HTML")
504 def report_formats(self):
505 return ["CSV", "Excel", "HTML"]
508 def default_input_format(self):
509 requested_default = self._user_settings.get("input_format") or self._config[
511 ].get("default-input-format")
512 if requested_default in self.input_formats:
513 return requested_default
515 return self.input_formats[0]
518 def input_formats(self):
519 return ["Directory (Uncompressed)", "ZIP File"]
522 def default_halt_on_failure(self):
523 setting = self._user_settings.get("halt_on_failure", "True")
524 return setting.lower() == "true"
527 """Ensures the config file is properly formatted"""
528 categories = self._config["categories"]
530 # All profiles have required keys
531 expected_keys = {"name", "description"}
532 for category in categories:
533 actual_keys = set(category.keys())
534 missing_keys = expected_keys.difference(actual_keys)
537 "Error in vvp-config.yaml file: "
538 "Required field missing in category. "
540 "Categories: {}".format(",".join(missing_keys), category)
548 class Dialog(Toplevel):
550 Adapted from http://www.effbot.org/tkinterbook/tkinter-dialog-windows.htm
553 def __init__(self, parent: Frame, title=None):
554 Toplevel.__init__(self, parent)
555 self.transient(parent)
561 self.initial_focus = self.body(body)
562 body.pack(padx=5, pady=5)
565 if not self.initial_focus:
566 self.initial_focus = self
567 self.protocol("WM_DELETE_WINDOW", self.cancel)
569 "+%d+%d" % (parent.winfo_rootx() + 600, parent.winfo_rooty() + 400)
571 self.initial_focus.focus_set()
572 self.wait_window(self)
574 def body(self, master):
575 raise NotImplementedError()
577 # noinspection PyAttributeOutsideInit
580 self.accept = Button(
588 self.accept.pack(side=LEFT, padx=5, pady=5)
589 self.decline = Button(
590 box, text="Decline", width=10, state=DISABLED, command=self.cancel
592 self.decline.pack(side=LEFT, padx=5, pady=5)
593 self.bind("<Return>", self.ok)
594 self.bind("<Escape>", self.cancel)
597 # noinspection PyUnusedLocal
598 def ok(self, event=None):
600 self.initial_focus.focus_set() # put focus back
603 self.update_idletasks()
607 # noinspection PyUnusedLocal
608 def cancel(self, event=None):
609 self.parent.focus_set()
613 raise NotImplementedError()
615 def activate_buttons(self):
616 self.accept.configure(state=NORMAL)
617 self.decline.configure(state=NORMAL)
620 class TermsAndConditionsDialog(Dialog):
621 def __init__(self, parent, config: Config):
624 super().__init__(parent, config.terms_popup_title)
626 def body(self, master):
627 Label(master, text=self.config.terms_popup_message).grid(row=0, pady=5)
629 master, text=self.config.terms_link_text, fg="blue", cursor="hand2"
631 ValidatorApp.underline(tc_link)
632 tc_link.bind("<Button-1>", self.open_terms)
633 tc_link.grid(row=1, pady=5)
635 # noinspection PyUnusedLocal
636 def open_terms(self, event):
637 webbrowser.open(self.config.terms_link_url)
638 self.activate_buttons()
641 self.config.set_terms_accepted()
645 VERBOSITY_LEVELS = {"Less": "", "Standard (-v)": "-v", "More (-vv)": "-vv"}
647 def __init__(self, config: Config = None):
648 """Constructs the GUI element of the Validation Tool"""
650 self.config = config or Config()
653 self._root.title(self.config.app_name)
654 self._root.protocol("WM_DELETE_WINDOW", self.shutdown)
656 if self.config.terms_link_text:
657 menubar = Menu(self._root)
659 label=self.config.terms_link_text,
660 command=lambda: webbrowser.open(self.config.terms_link_url),
662 self._root.config(menu=menubar)
664 parent_frame = Frame(self._root)
665 main_window = PanedWindow(parent_frame)
666 main_window.pack(fill=BOTH, expand=1)
668 control_panel = PanedWindow(
669 main_window, orient=HORIZONTAL, sashpad=4, sashrelief=RAISED
671 actions = Frame(control_panel)
672 control_panel.add(actions)
673 control_panel.paneconfigure(actions, minsize=250)
675 if self.config.disclaimer_text or self.config.requirement_link_text:
676 self.footer = self.create_footer(parent_frame)
677 parent_frame.pack(fill=BOTH, expand=True)
680 number_of_categories = len(self.config.category_names)
681 category_frame = LabelFrame(actions, text="Additional Validation Categories:")
682 category_frame.grid(row=1, column=1, columnspan=3, pady=5, sticky="we")
686 for x in range(0, number_of_categories):
687 category_name = self.config.category_names[x]
688 category_value = IntVar(value=0)
689 category_value._name = "category_{}".format(category_name.replace(" ", "_"))
690 # noinspection PyProtectedMember
691 category_value.set(self.config.get_category_value(category_value._name))
692 self.categories.append(category_value)
693 category_checkbox = Checkbutton(
694 category_frame, text=category_name, variable=self.categories[x]
696 ToolTip(category_checkbox, self.config.get_description(category_name))
697 category_checkbox.grid(row=x + 1, column=1, columnspan=2, sticky="w")
699 settings_frame = LabelFrame(actions, text="Settings")
700 settings_frame.grid(row=3, column=1, columnspan=3, pady=10, sticky="we")
701 verbosity_label = Label(settings_frame, text="Verbosity:")
702 verbosity_label.grid(row=1, column=1, sticky=W)
703 self.verbosity = StringVar(self._root, name="verbosity")
704 self.verbosity.set(self.config.default_verbosity(self.VERBOSITY_LEVELS))
705 verbosity_menu = OptionMenu(
706 settings_frame, self.verbosity, *tuple(self.VERBOSITY_LEVELS.keys())
708 verbosity_menu.config(width=25)
709 verbosity_menu.grid(row=1, column=2, columnspan=3, sticky=E, pady=5)
711 report_format_label = Label(settings_frame, text="Report Format:")
712 report_format_label.grid(row=2, column=1, sticky=W)
713 self.report_format = StringVar(self._root, name="report_format")
714 self.report_format.set(self.config.default_report_format)
715 report_format_menu = OptionMenu(
716 settings_frame, self.report_format, *self.config.report_formats
718 report_format_menu.config(width=25)
719 report_format_menu.grid(row=2, column=2, columnspan=3, sticky=E, pady=5)
721 input_format_label = Label(settings_frame, text="Input Format:")
722 input_format_label.grid(row=3, column=1, sticky=W)
723 self.input_format = StringVar(self._root, name="input_format")
724 self.input_format.set(self.config.default_input_format)
725 input_format_menu = OptionMenu(
726 settings_frame, self.input_format, *self.config.input_formats
728 input_format_menu.config(width=25)
729 input_format_menu.grid(row=3, column=2, columnspan=3, sticky=E, pady=5)
731 self.halt_on_failure = BooleanVar(self._root, name="halt_on_failure")
732 self.halt_on_failure.set(self.config.default_halt_on_failure)
733 halt_on_failure_label = Label(settings_frame, text="Halt on Basic Failures:")
734 halt_on_failure_label.grid(row=4, column=1, sticky=E, pady=5)
735 halt_checkbox = Checkbutton(
736 settings_frame, offvalue=False, onvalue=True, variable=self.halt_on_failure
738 halt_checkbox.grid(row=4, column=2, columnspan=2, sticky=W, pady=5)
740 directory_label = Label(actions, text="Template Location:")
741 directory_label.grid(row=4, column=1, pady=5, sticky=W)
742 self.template_source = StringVar(self._root, name="template_source")
743 directory_entry = Entry(actions, width=40, textvariable=self.template_source)
744 directory_entry.grid(row=4, column=2, pady=5, sticky=W)
745 directory_browse = Button(actions, text="...", command=self.ask_template_source)
746 directory_browse.grid(row=4, column=3, pady=5, sticky=W)
748 validate_button = Button(
749 actions, text="Validate Templates", command=self.validate
751 validate_button.grid(row=5, column=1, columnspan=2, pady=5)
753 self.result_panel = Frame(actions)
754 # We'll add these labels now, and then make them visible when the run completes
755 self.completion_label = Label(self.result_panel, text="Validation Complete!")
756 self.result_label = Label(
757 self.result_panel, text="View Report", fg="blue", cursor="hand2"
759 self.underline(self.result_label)
760 self.result_label.bind("<Button-1>", self.open_report)
761 self.result_panel.grid(row=6, column=1, columnspan=2)
762 control_panel.pack(fill=BOTH, expand=1)
764 main_window.add(control_panel)
766 self.log_panel = ScrolledText(main_window, wrap=WORD, width=120, height=20)
767 self.log_panel.configure(font=font.Font(family="Courier New", size="11"))
768 self.log_panel.pack(fill=BOTH, expand=1)
770 main_window.add(self.log_panel)
772 # Briefly add the completion and result labels so the window size includes
774 self.completion_label.pack()
775 self.result_label.pack() # Show report link
776 self._root.after_idle(
778 self.completion_label.pack_forget(),
779 self.result_label.pack_forget(),
788 self.halt_on_failure,
790 self.schedule(self.execute_pollers)
791 if self.config.terms_link_text and not self.config.are_terms_accepted:
792 TermsAndConditionsDialog(parent_frame, self.config)
793 if not self.config.are_terms_accepted:
796 def create_footer(self, parent_frame):
797 footer = Frame(parent_frame)
798 disclaimer = Message(
799 footer, text=self.config.disclaimer_text, anchor=CENTER
801 disclaimer.grid(row=0, pady=2)
803 "<Configure>", lambda e: disclaimer.configure(width=e.width - 20)
805 if self.config.requirement_link_text:
806 requirement_link = Text(
809 bg=disclaimer.cget("bg"),
811 font=disclaimer.cget("font"),
813 requirement_link.tag_configure("center", justify="center")
814 hyperlinks = HyperlinkManager(requirement_link)
815 requirement_link.insert(INSERT, "Validating: ")
816 requirement_link.insert(
818 self.config.requirement_link_text,
819 hyperlinks.add(self.open_requirements),
821 requirement_link.tag_add("center", "1.0", "end")
822 requirement_link.config(state=DISABLED)
823 requirement_link.grid(row=1, pady=2)
824 ToolTip(requirement_link, self.config.requirement_link_url)
825 footer.grid_columnconfigure(0, weight=1)
826 footer.pack(fill=BOTH, expand=True)
829 def ask_template_source(self):
830 if self.input_format.get() == "ZIP File":
831 template_source = filedialog.askopenfilename(
832 title="Select Archive",
833 filetypes=(("ZIP Files", "*.zip"), ("All Files", "*")),
836 template_source = filedialog.askdirectory()
837 self.template_source.set(template_source)
840 """Run the pytest validations in a background process"""
841 if not self.delete_prior_report():
844 if not self.template_source.get():
845 self.ask_template_source()
847 template_dir = self.resolve_template_dir()
850 self.kill_background_task()
852 self.completion_label.pack_forget()
853 self.result_label.pack_forget()
854 self.task = multiprocessing.Process(
858 self.config.log_file,
859 self.config.status_queue,
860 self.categories_list(),
861 self.VERBOSITY_LEVELS[self.verbosity.get()],
862 self.report_format.get().lower(),
863 self.halt_on_failure.get(),
864 self.template_source.get(),
867 self.task.daemon = True
872 """Returns the text displayed in the title bar of the application"""
873 return self._root.title()
875 def execute_pollers(self):
876 """Call all methods that require periodic execution, and re-schedule
877 their execution for the next polling interval"""
880 self.poll_status_queue()
881 self.poll_command_queue()
883 self.schedule(self.execute_pollers)
887 """Yields values from the queue until empty"""
890 yield q.get(block=False)
894 def poll_command_queue(self):
895 """Picks up command strings from the commmand queue, and
896 dispatches it for execution. Only SHUTDOWN is supported
898 for command in self._drain_queue(self.config.command_queue):
899 if command == "SHUTDOWN":
902 def poll_status_queue(self):
903 """Checks for completion of the job, and then displays the View Report link
904 if it was successful or writes the exception to the ``log_panel`` if
906 for is_success, e in self._drain_queue(self.config.status_queue):
908 self.completion_label.pack()
909 self.result_label.pack() # Show report link
911 self.log_panel.insert(END, str(e))
913 def poll_log_file(self):
914 """Reads captured stdout and stderr from the log queue and writes it to the
916 for line in self._drain_queue(self.config.log_queue):
917 self.log_panel.insert(END, line)
918 self.log_panel.see(END)
920 def schedule(self, func: Callable):
921 """Schedule the callable ``func`` to be executed according to
922 the polling_frequency"""
923 self._root.after(self.config.polling_frequency, func)
926 """Removes all log entries from teh log panel"""
927 self.log_panel.delete("1.0", END)
929 def delete_prior_report(self) -> bool:
930 """Attempts to delete the current report, and pops up a warning message
931 to the user if it can't be deleted. This will force the user to
932 close the report before re-running the validation. Returns True if
933 the file was deleted or did not exist, or False otherwise"""
934 if not os.path.exists(self.report_file_path):
938 os.remove(self.report_file_path)
941 messagebox.showerror(
943 "Please close or rename the open report file before re-validating",
948 def report_file_path(self):
949 ext_mapping = {"csv": "csv", "html": "html", "excel": "xlsx"}
950 ext = ext_mapping.get(self.report_format.get().lower())
951 return os.path.join(PATH, OUT_DIR, "report.{}".format(ext))
953 # noinspection PyUnusedLocal
954 def open_report(self, event):
955 """Open the report in the user's default browser"""
956 webbrowser.open_new("file://{}".format(self.report_file_path))
958 def open_requirements(self):
959 """Open the report in the user's default browser"""
960 webbrowser.open_new(self.config.requirement_link_url)
963 """Start the event loop of the application. This method does not return"""
964 self._root.mainloop()
967 def underline(label):
968 """Apply underline format to an existing label"""
969 f = font.Font(label, label.cget("font"))
970 f.configure(underline=True)
971 label.configure(font=f)
973 def kill_background_task(self):
974 if self.task and self.task.is_alive():
975 self.task.terminate()
976 for _ in self._drain_queue(self.config.log_queue):
980 """Shutdown the application"""
981 self.kill_background_task()
984 def check_template_source_is_valid(self):
985 """Verifies the value of template source exists and of valid type based
987 if not self.template_source.get():
989 template_path = Path(self.template_source.get())
991 if not template_path.exists():
992 messagebox.showerror(
994 "Input does not exist. Please provide a valid file or directory.",
998 if self.input_format.get() == "ZIP File":
999 if zipfile.is_zipfile(template_path):
1002 messagebox.showerror(
1003 "Error", "Expected ZIP file, but input is not a valid ZIP file"
1007 if template_path.is_dir():
1010 messagebox.showerror(
1011 "Error", "Expected directory, but input is not a directory"
1015 def resolve_template_dir(self) -> str:
1016 """Extracts the zip file to a temporary directory if needed, otherwise
1017 returns the directory supplied to template source. Returns empty string
1018 if the template source isn't valid"""
1019 if not self.check_template_source_is_valid():
1021 if self.input_format.get() == "ZIP File":
1022 temp_dir = tempfile.mkdtemp()
1023 archive = zipfile.ZipFile(self.template_source.get())
1024 archive.extractall(path=temp_dir)
1027 return self.template_source.get()
1029 def categories_list(self) -> list:
1031 selected_categories = self.categories
1032 for x in range(0, len(selected_categories)):
1033 if selected_categories[x].get():
1034 category = self.config.category_names[x]
1035 categories.append(self.config.get_category(category))
1039 if __name__ == "__main__":
1040 multiprocessing.freeze_support() # needed for PyInstaller to work
1041 ValidatorApp().start()