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.safe_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 if not path.startswith("http"):
429 path = "file://{}".format(os.path.join(PATH, path))
433 def terms(self) -> dict:
434 return self._config.get("terms", {})
437 def terms_link_url(self) -> Optional[str]:
438 return self.terms.get("path")
441 def terms_link_text(self):
442 return self.terms.get("popup-link-text")
445 def terms_version(self) -> Optional[str]:
446 return self.terms.get("version")
449 def terms_popup_title(self) -> Optional[str]:
450 return self.terms.get("popup-title")
453 def terms_popup_message(self) -> Optional[str]:
454 return self.terms.get("popup-msg-text")
457 def are_terms_accepted(self) -> bool:
458 version = "terms-{}".format(self.terms_version)
459 return self._user_settings.get(version, "False") == "True"
461 def set_terms_accepted(self):
462 version = "terms-{}".format(self.terms_version)
463 self._user_settings[version] = "True"
464 self._user_settings.save()
466 def default_verbosity(self, levels: Dict[str, str]) -> str:
467 requested_level = self._user_settings.get("verbosity") or self._config[
469 ].get("default-verbosity", "Standard")
470 keys = [key for key in levels]
472 if key.lower().startswith(requested_level.lower()):
475 "Invalid default-verbosity level {}. Valid "
476 "values are {}".format(requested_level, ", ".join(keys))
479 def get_description(self, category_name: str) -> str:
480 """Returns the description associated with the category name"""
481 return self._get_category(category_name)["description"]
483 def get_category(self, category_name: str) -> str:
484 """Returns the category associated with the category name"""
485 return self._get_category(category_name).get("category", "")
487 def get_category_value(self, category_name: str) -> str:
488 """Returns the saved value for a category name"""
489 return self._user_settings.get(category_name, 0)
491 def _get_category(self, category_name: str) -> Dict[str, str]:
492 """Returns the profile definition"""
493 for category in self._config["categories"]:
494 if category["name"] == category_name:
497 "Unexpected error: No category found in vvp-config.yaml "
498 "with a name of " + category_name
502 def default_report_format(self):
503 return self._user_settings.get("report_format", "HTML")
506 def report_formats(self):
507 return ["CSV", "Excel", "HTML"]
510 def default_input_format(self):
511 requested_default = self._user_settings.get("input_format") or self._config[
513 ].get("default-input-format")
514 if requested_default in self.input_formats:
515 return requested_default
517 return self.input_formats[0]
520 def input_formats(self):
521 return ["Directory (Uncompressed)", "ZIP File"]
524 def default_halt_on_failure(self):
525 setting = self._user_settings.get("halt_on_failure", "True")
526 return setting.lower() == "true"
529 """Ensures the config file is properly formatted"""
530 categories = self._config["categories"]
532 # All profiles have required keys
533 expected_keys = {"name", "description"}
534 for category in categories:
535 actual_keys = set(category.keys())
536 missing_keys = expected_keys.difference(actual_keys)
539 "Error in vvp-config.yaml file: "
540 "Required field missing in category. "
542 "Categories: {}".format(",".join(missing_keys), category)
550 class Dialog(Toplevel):
552 Adapted from http://www.effbot.org/tkinterbook/tkinter-dialog-windows.htm
555 def __init__(self, parent: Frame, title=None):
556 Toplevel.__init__(self, parent)
557 self.transient(parent)
563 self.initial_focus = self.body(body)
564 body.pack(padx=5, pady=5)
567 if not self.initial_focus:
568 self.initial_focus = self
569 self.protocol("WM_DELETE_WINDOW", self.cancel)
571 "+%d+%d" % (parent.winfo_rootx() + 600, parent.winfo_rooty() + 400)
573 self.initial_focus.focus_set()
574 self.wait_window(self)
576 def body(self, master):
577 raise NotImplementedError()
579 # noinspection PyAttributeOutsideInit
582 self.accept = Button(
590 self.accept.pack(side=LEFT, padx=5, pady=5)
591 self.decline = Button(
592 box, text="Decline", width=10, state=DISABLED, command=self.cancel
594 self.decline.pack(side=LEFT, padx=5, pady=5)
595 self.bind("<Return>", self.ok)
596 self.bind("<Escape>", self.cancel)
599 # noinspection PyUnusedLocal
600 def ok(self, event=None):
602 self.initial_focus.focus_set() # put focus back
605 self.update_idletasks()
609 # noinspection PyUnusedLocal
610 def cancel(self, event=None):
611 self.parent.focus_set()
615 raise NotImplementedError()
617 def activate_buttons(self):
618 self.accept.configure(state=NORMAL)
619 self.decline.configure(state=NORMAL)
622 class TermsAndConditionsDialog(Dialog):
623 def __init__(self, parent, config: Config):
626 super().__init__(parent, config.terms_popup_title)
628 def body(self, master):
629 Label(master, text=self.config.terms_popup_message).grid(row=0, pady=5)
631 master, text=self.config.terms_link_text, fg="blue", cursor="hand2"
633 ValidatorApp.underline(tc_link)
634 tc_link.bind("<Button-1>", self.open_terms)
635 tc_link.grid(row=1, pady=5)
637 # noinspection PyUnusedLocal
638 def open_terms(self, event):
639 webbrowser.open(self.config.terms_link_url)
640 self.activate_buttons()
643 self.config.set_terms_accepted()
647 VERBOSITY_LEVELS = {"Less": "", "Standard (-v)": "-v", "More (-vv)": "-vv"}
649 def __init__(self, config: Config = None):
650 """Constructs the GUI element of the Validation Tool"""
652 self.config = config or Config()
655 self._root.title(self.config.app_name)
656 self._root.protocol("WM_DELETE_WINDOW", self.shutdown)
658 if self.config.terms_link_text:
659 menubar = Menu(self._root)
661 label=self.config.terms_link_text,
662 command=lambda: webbrowser.open(self.config.terms_link_url),
664 self._root.config(menu=menubar)
666 parent_frame = Frame(self._root)
667 main_window = PanedWindow(parent_frame)
668 main_window.pack(fill=BOTH, expand=1)
670 control_panel = PanedWindow(
671 main_window, orient=HORIZONTAL, sashpad=4, sashrelief=RAISED
673 actions = Frame(control_panel)
674 control_panel.add(actions)
675 control_panel.paneconfigure(actions, minsize=250)
677 if self.config.disclaimer_text or self.config.requirement_link_text:
678 self.footer = self.create_footer(parent_frame)
679 parent_frame.pack(fill=BOTH, expand=True)
682 number_of_categories = len(self.config.category_names)
683 category_frame = LabelFrame(actions, text="Additional Validation Categories:")
684 category_frame.grid(row=1, column=1, columnspan=3, pady=5, sticky="we")
688 for x in range(0, number_of_categories):
689 category_name = self.config.category_names[x]
690 category_value = IntVar(value=0)
691 category_value._name = "category_{}".format(category_name.replace(" ", "_"))
692 # noinspection PyProtectedMember
693 category_value.set(self.config.get_category_value(category_value._name))
694 self.categories.append(category_value)
695 category_checkbox = Checkbutton(
696 category_frame, text=category_name, variable=self.categories[x]
698 ToolTip(category_checkbox, self.config.get_description(category_name))
699 category_checkbox.grid(row=x + 1, column=1, columnspan=2, sticky="w")
701 settings_frame = LabelFrame(actions, text="Settings")
702 settings_frame.grid(row=3, column=1, columnspan=3, pady=10, sticky="we")
703 verbosity_label = Label(settings_frame, text="Verbosity:")
704 verbosity_label.grid(row=1, column=1, sticky=W)
705 self.verbosity = StringVar(self._root, name="verbosity")
706 self.verbosity.set(self.config.default_verbosity(self.VERBOSITY_LEVELS))
707 verbosity_menu = OptionMenu(
708 settings_frame, self.verbosity, *tuple(self.VERBOSITY_LEVELS.keys())
710 verbosity_menu.config(width=25)
711 verbosity_menu.grid(row=1, column=2, columnspan=3, sticky=E, pady=5)
713 report_format_label = Label(settings_frame, text="Report Format:")
714 report_format_label.grid(row=2, column=1, sticky=W)
715 self.report_format = StringVar(self._root, name="report_format")
716 self.report_format.set(self.config.default_report_format)
717 report_format_menu = OptionMenu(
718 settings_frame, self.report_format, *self.config.report_formats
720 report_format_menu.config(width=25)
721 report_format_menu.grid(row=2, column=2, columnspan=3, sticky=E, pady=5)
723 input_format_label = Label(settings_frame, text="Input Format:")
724 input_format_label.grid(row=3, column=1, sticky=W)
725 self.input_format = StringVar(self._root, name="input_format")
726 self.input_format.set(self.config.default_input_format)
727 input_format_menu = OptionMenu(
728 settings_frame, self.input_format, *self.config.input_formats
730 input_format_menu.config(width=25)
731 input_format_menu.grid(row=3, column=2, columnspan=3, sticky=E, pady=5)
733 self.halt_on_failure = BooleanVar(self._root, name="halt_on_failure")
734 self.halt_on_failure.set(self.config.default_halt_on_failure)
735 halt_on_failure_label = Label(settings_frame, text="Halt on Basic Failures:")
736 halt_on_failure_label.grid(row=4, column=1, sticky=E, pady=5)
737 halt_checkbox = Checkbutton(
738 settings_frame, offvalue=False, onvalue=True, variable=self.halt_on_failure
740 halt_checkbox.grid(row=4, column=2, columnspan=2, sticky=W, pady=5)
742 directory_label = Label(actions, text="Template Location:")
743 directory_label.grid(row=4, column=1, pady=5, sticky=W)
744 self.template_source = StringVar(self._root, name="template_source")
745 directory_entry = Entry(actions, width=40, textvariable=self.template_source)
746 directory_entry.grid(row=4, column=2, pady=5, sticky=W)
747 directory_browse = Button(actions, text="...", command=self.ask_template_source)
748 directory_browse.grid(row=4, column=3, pady=5, sticky=W)
750 validate_button = Button(
751 actions, text="Validate Templates", command=self.validate
753 validate_button.grid(row=5, column=1, columnspan=2, pady=5)
755 self.result_panel = Frame(actions)
756 # We'll add these labels now, and then make them visible when the run completes
757 self.completion_label = Label(self.result_panel, text="Validation Complete!")
758 self.result_label = Label(
759 self.result_panel, text="View Report", fg="blue", cursor="hand2"
761 self.underline(self.result_label)
762 self.result_label.bind("<Button-1>", self.open_report)
763 self.result_panel.grid(row=6, column=1, columnspan=2)
764 control_panel.pack(fill=BOTH, expand=1)
766 main_window.add(control_panel)
768 self.log_panel = ScrolledText(main_window, wrap=WORD, width=120, height=20)
769 self.log_panel.configure(font=font.Font(family="Courier New", size="11"))
770 self.log_panel.pack(fill=BOTH, expand=1)
772 main_window.add(self.log_panel)
774 # Briefly add the completion and result labels so the window size includes
776 self.completion_label.pack()
777 self.result_label.pack() # Show report link
778 self._root.after_idle(
780 self.completion_label.pack_forget(),
781 self.result_label.pack_forget(),
790 self.halt_on_failure,
792 self.schedule(self.execute_pollers)
793 if self.config.terms_link_text and not self.config.are_terms_accepted:
794 TermsAndConditionsDialog(parent_frame, self.config)
795 if not self.config.are_terms_accepted:
798 def create_footer(self, parent_frame):
799 footer = Frame(parent_frame)
800 disclaimer = Message(
801 footer, text=self.config.disclaimer_text, anchor=CENTER
803 disclaimer.grid(row=0, pady=2)
805 "<Configure>", lambda e: disclaimer.configure(width=e.width - 20)
807 if self.config.requirement_link_text:
808 requirement_link = Text(
811 bg=disclaimer.cget("bg"),
813 font=disclaimer.cget("font"),
815 requirement_link.tag_configure("center", justify="center")
816 hyperlinks = HyperlinkManager(requirement_link)
817 requirement_link.insert(INSERT, "Validating: ")
818 requirement_link.insert(
820 self.config.requirement_link_text,
821 hyperlinks.add(self.open_requirements),
823 requirement_link.tag_add("center", "1.0", "end")
824 requirement_link.config(state=DISABLED)
825 requirement_link.grid(row=1, pady=2)
826 ToolTip(requirement_link, self.config.requirement_link_url)
827 footer.grid_columnconfigure(0, weight=1)
828 footer.pack(fill=BOTH, expand=True)
831 def ask_template_source(self):
832 if self.input_format.get() == "ZIP File":
833 template_source = filedialog.askopenfilename(
834 title="Select Archive",
835 filetypes=(("ZIP Files", "*.zip"), ("All Files", "*")),
838 template_source = filedialog.askdirectory()
839 self.template_source.set(template_source)
842 """Run the pytest validations in a background process"""
843 if not self.delete_prior_report():
846 if not self.template_source.get():
847 self.ask_template_source()
849 template_dir = self.resolve_template_dir()
852 self.kill_background_task()
854 self.completion_label.pack_forget()
855 self.result_label.pack_forget()
856 self.task = multiprocessing.Process(
860 self.config.log_file,
861 self.config.status_queue,
862 self.categories_list(),
863 self.VERBOSITY_LEVELS[self.verbosity.get()],
864 self.report_format.get().lower(),
865 self.halt_on_failure.get(),
866 self.template_source.get(),
869 self.task.daemon = True
874 """Returns the text displayed in the title bar of the application"""
875 return self._root.title()
877 def execute_pollers(self):
878 """Call all methods that require periodic execution, and re-schedule
879 their execution for the next polling interval"""
882 self.poll_status_queue()
883 self.poll_command_queue()
885 self.schedule(self.execute_pollers)
889 """Yields values from the queue until empty"""
892 yield q.get(block=False)
896 def poll_command_queue(self):
897 """Picks up command strings from the commmand queue, and
898 dispatches it for execution. Only SHUTDOWN is supported
900 for command in self._drain_queue(self.config.command_queue):
901 if command == "SHUTDOWN":
904 def poll_status_queue(self):
905 """Checks for completion of the job, and then displays the View Report link
906 if it was successful or writes the exception to the ``log_panel`` if
908 for is_success, e in self._drain_queue(self.config.status_queue):
910 self.completion_label.pack()
911 self.result_label.pack() # Show report link
913 self.log_panel.insert(END, str(e))
915 def poll_log_file(self):
916 """Reads captured stdout and stderr from the log queue and writes it to the
918 for line in self._drain_queue(self.config.log_queue):
919 self.log_panel.insert(END, line)
920 self.log_panel.see(END)
922 def schedule(self, func: Callable):
923 """Schedule the callable ``func`` to be executed according to
924 the polling_frequency"""
925 self._root.after(self.config.polling_frequency, func)
928 """Removes all log entries from teh log panel"""
929 self.log_panel.delete("1.0", END)
931 def delete_prior_report(self) -> bool:
932 """Attempts to delete the current report, and pops up a warning message
933 to the user if it can't be deleted. This will force the user to
934 close the report before re-running the validation. Returns True if
935 the file was deleted or did not exist, or False otherwise"""
936 if not os.path.exists(self.report_file_path):
940 os.remove(self.report_file_path)
943 messagebox.showerror(
945 "Please close or rename the open report file before re-validating",
950 def report_file_path(self):
951 ext_mapping = {"csv": "csv", "html": "html", "excel": "xlsx"}
952 ext = ext_mapping.get(self.report_format.get().lower())
953 return os.path.join(PATH, OUT_DIR, "report.{}".format(ext))
955 # noinspection PyUnusedLocal
956 def open_report(self, event):
957 """Open the report in the user's default browser"""
958 webbrowser.open_new("file://{}".format(self.report_file_path))
960 def open_requirements(self):
961 """Open the report in the user's default browser"""
962 webbrowser.open_new(self.config.requirement_link_url)
965 """Start the event loop of the application. This method does not return"""
966 self._root.mainloop()
969 def underline(label):
970 """Apply underline format to an existing label"""
971 f = font.Font(label, label.cget("font"))
972 f.configure(underline=True)
973 label.configure(font=f)
975 def kill_background_task(self):
976 if self.task and self.task.is_alive():
977 self.task.terminate()
978 for _ in self._drain_queue(self.config.log_queue):
982 """Shutdown the application"""
983 self.kill_background_task()
986 def check_template_source_is_valid(self):
987 """Verifies the value of template source exists and of valid type based
989 if not self.template_source.get():
991 template_path = Path(self.template_source.get())
993 if not template_path.exists():
994 messagebox.showerror(
996 "Input does not exist. Please provide a valid file or directory.",
1000 if self.input_format.get() == "ZIP File":
1001 if zipfile.is_zipfile(template_path):
1004 messagebox.showerror(
1005 "Error", "Expected ZIP file, but input is not a valid ZIP file"
1009 if template_path.is_dir():
1012 messagebox.showerror(
1013 "Error", "Expected directory, but input is not a directory"
1017 def resolve_template_dir(self) -> str:
1018 """Extracts the zip file to a temporary directory if needed, otherwise
1019 returns the directory supplied to template source. Returns empty string
1020 if the template source isn't valid"""
1021 if not self.check_template_source_is_valid():
1023 if self.input_format.get() == "ZIP File":
1024 temp_dir = tempfile.mkdtemp()
1025 archive = zipfile.ZipFile(self.template_source.get())
1026 archive.extractall(path=temp_dir)
1029 return self.template_source.get()
1031 def categories_list(self) -> list:
1033 selected_categories = self.categories
1034 for x in range(0, len(selected_categories)):
1035 if selected_categories[x].get():
1036 category = self.config.category_names[x]
1037 categories.append(self.config.get_category(category))
1041 if __name__ == "__main__":
1042 multiprocessing.freeze_support() # needed for PyInstaller to work
1043 ValidatorApp().start()