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+
55 import multiprocessing
61 import subprocess # nosec
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
104 from tkinter.scrolledtext import ScrolledText
105 from typing import Optional, List, Dict, TextIO, Callable, Iterator
109 VERSION = version.VERSION
110 PATH = os.path.dirname(os.path.realpath(__file__))
114 class ToolTip(object):
116 create a tooltip for a given widget
119 def __init__(self, widget, text="widget info"):
120 self.waittime = 750 # milliseconds
121 self.wraplength = 300 # pixels
124 self.widget.bind("<Enter>", self.enter)
125 self.widget.bind("<Leave>", self.leave)
126 self.widget.bind("<ButtonPress>", self.leave)
130 # noinspection PyUnusedLocal
131 def enter(self, event=None):
134 # noinspection PyUnusedLocal
135 def leave(self, event=None):
141 self.id = self.widget.after(self.waittime, self.showtip)
143 def unschedule(self):
147 self.widget.after_cancel(orig_id)
149 # noinspection PyUnusedLocal
150 def showtip(self, event=None):
152 x, y, cx, cy = self.widget.bbox("insert")
153 x += self.widget.winfo_rootx() + 25
154 y += self.widget.winfo_rooty() + 20
155 # creates a top level window
156 self.tw = Toplevel(self.widget)
157 # Leaves only the label and removes the app window
158 self.tw.wm_overrideredirect(True)
159 self.tw.wm_geometry("+%d+%d" % (x, y))
164 background="#ffffff",
167 wraplength=self.wraplength,
178 class HyperlinkManager:
179 """Adapted from http://effbot.org/zone/tkinter-text-hyperlink.htm"""
181 def __init__(self, text):
184 self.text.tag_config("hyper", foreground="blue", underline=1)
185 self.text.tag_bind("hyper", "<Enter>", self._enter)
186 self.text.tag_bind("hyper", "<Leave>", self._leave)
187 self.text.tag_bind("hyper", "<Button-1>", self._click)
193 def add(self, action):
194 # add an action to the manager. returns tags to use in
195 # associated text widget
196 tag = "hyper-%d" % len(self.links)
197 self.links[tag] = action
200 # noinspection PyUnusedLocal
201 def _enter(self, event):
202 self.text.config(cursor="hand2")
204 # noinspection PyUnusedLocal
205 def _leave(self, event):
206 self.text.config(cursor="")
208 # noinspection PyUnusedLocal
209 def _click(self, event):
210 for tag in self.text.tag_names(CURRENT):
211 if tag[:6] == "hyper-":
217 """``stdout`` and ``stderr`` will be written to this queue by pytest, and
218 pulled into the main GUI application"""
220 def __init__(self, log_queue: queue.Queue):
221 """Writes data to the provided queue.
223 :param log_queue: the queue instance to write to.
225 self.queue = log_queue
227 def write(self, data: str):
228 """Writes ``data`` to the queue """
231 # noinspection PyMethodMayBeStatic
232 def isatty(self) -> bool:
233 """Always returns ``False``"""
237 """No operation method to satisfy file-like behavior"""
245 categories: Optional[list],
248 halt_on_failure: bool,
249 template_source: str,
251 """Runs pytest using the given ``profile`` in a background process. All
252 ``stdout`` and ``stderr`` are redirected to ``log``. The result of the job
253 will be put on the ``completion_queue``
255 :param template_dir: The directory containing the files to be validated.
256 :param log: ` `stderr`` and ``stdout`` of the pytest job will be
258 :param result_queue: Completion status posted here. See :class:`Config`
259 for more information.
260 :param categories: list of optional categories. When provided, pytest
261 will collect and execute all tests that are
262 decorated with any of the passed categories, as
263 well as tests not decorated with a category.
264 :param verbosity: Flag to be passed to pytest to control verbosity.
265 Options are '' (empty string), '-v' (verbose),
266 '-vv' (more verbose).
267 :param report_format: Determines the style of report written. Options are
269 :param halt_on_failure: Determines if validation will halt when basic failures
270 are encountered in the input files. This can help
271 prevent a large number of errors from flooding the
273 :param template_source: The path or name of the template to show on the report
275 out_path = "{}/{}".format(PATH, OUT_DIR)
276 if os.path.exists(out_path):
277 rmtree(out_path, ignore_errors=True)
278 with contextlib.redirect_stderr(log), contextlib.redirect_stdout(log):
281 "--ignore=app_tests",
284 "--template-directory={}".format(template_dir),
285 "--report-format={}".format(report_format),
286 "--template-source={}".format(template_source),
289 for category in categories:
290 args.extend(("--category", category))
291 if not halt_on_failure:
292 args.append("--continue-on-failure")
293 pytest.main(args=args)
294 result_queue.put((True, None))
295 except Exception as e:
296 result_queue.put((False, e))
299 class UserSettings(MutableMapping):
300 FILE_NAME = "UserSettings.ini"
302 def __init__(self, namespace, owner):
303 user_config_dir = appdirs.AppDirs(namespace, owner).user_config_dir
304 if not os.path.exists(user_config_dir):
305 os.makedirs(user_config_dir, exist_ok=True)
306 self._settings_path = os.path.join(user_config_dir, self.FILE_NAME)
307 self._config = ConfigParser()
308 self._config.read(self._settings_path)
310 def __getitem__(self, k):
311 return self._config["DEFAULT"][k]
313 def __setitem__(self, k, v) -> None:
314 self._config["DEFAULT"][k] = v
316 def __delitem__(self, v) -> None:
317 del self._config["DEFAULT"][v]
319 def __len__(self) -> int:
320 return len(self._config["DEFAULT"])
322 def __iter__(self) -> Iterator:
323 return iter(self._config["DEFAULT"])
326 with open(self._settings_path, "w") as f:
327 self._config.write(f)
332 Configuration for the Validation GUI Application
336 ``log_queue`` Queue for the ``stdout`` and ``stderr` of
338 ``log_file`` File-like object (write only!) that writes to
340 ``status_queue`` Job completion status of the background job is
341 posted here as a tuple of (bool, Exception).
342 The first parameter is True if the job completed
343 successfully, and False otherwise. If the job
344 failed, then an Exception will be provided as the
346 ``command_queue`` Used to send commands to the GUI. Currently only
347 used to send shutdown commands in tests.
350 DEFAULT_FILENAME = "vvp-config.yaml"
351 DEFAULT_POLLING_FREQUENCY = "1000"
353 def __init__(self, config: dict = None):
354 """Creates instance of application configuration.
356 :param config: override default configuration if provided."""
358 self._config = config
360 with open(self.DEFAULT_FILENAME, "r") as f:
361 self._config = yaml.safe_load(f)
362 self._user_settings = UserSettings(
363 self._config["namespace"], self._config["owner"]
365 self._watched_variables = []
367 self._manager = multiprocessing.Manager()
368 self.log_queue = self._manager.Queue()
369 self.status_queue = self._manager.Queue()
370 self.log_file = QueueWriter(self.log_queue)
371 self.command_queue = self._manager.Queue()
373 def watch(self, *variables):
374 """Traces the variables and saves their settings for the user. The
375 last settings will be used where available"""
376 self._watched_variables = variables
377 for var in self._watched_variables:
378 var.trace_add("write", self.save_settings)
380 # noinspection PyProtectedMember,PyUnusedLocal
381 def save_settings(self, *args):
382 """Save the value of all watched variables to user settings"""
383 for var in self._watched_variables:
384 self._user_settings[var._name] = str(var.get())
385 self._user_settings.save()
388 def app_name(self) -> str:
389 """Name of the application (displayed in title bar)"""
390 app_name = self._config["ui"].get("app-name", "VNF Validation Tool")
391 return "{} - {}".format(app_name, VERSION)
394 def category_names(self) -> List[str]:
395 """List of validation profile names for display in the UI"""
396 return [category["name"] for category in self._config["categories"]]
399 def polling_frequency(self) -> int:
400 """Returns the frequency (in ms) the UI polls the queue communicating
401 with any background job"""
403 self._config["settings"].get(
404 "polling-frequency", self.DEFAULT_POLLING_FREQUENCY
409 def disclaimer_text(self) -> str:
410 return self._config["ui"].get("disclaimer-text", "")
413 def requirement_link_text(self) -> str:
414 return self._config["ui"].get("requirement-link-text", "")
417 def requirement_link_url(self) -> str:
418 path = self._config["ui"].get("requirement-link-url", "")
419 if not path.startswith("http"):
420 path = "file://{}".format(os.path.join(PATH, path))
424 def terms(self) -> dict:
425 return self._config.get("terms", {})
428 def terms_link_url(self) -> Optional[str]:
429 return self.terms.get("path")
432 def terms_link_text(self):
433 return self.terms.get("popup-link-text")
436 def terms_version(self) -> Optional[str]:
437 return self.terms.get("version")
440 def terms_popup_title(self) -> Optional[str]:
441 return self.terms.get("popup-title")
444 def terms_popup_message(self) -> Optional[str]:
445 return self.terms.get("popup-msg-text")
448 def are_terms_accepted(self) -> bool:
449 version = "terms-{}".format(self.terms_version)
450 return self._user_settings.get(version, "False") == "True"
452 def set_terms_accepted(self):
453 version = "terms-{}".format(self.terms_version)
454 self._user_settings[version] = "True"
455 self._user_settings.save()
457 def default_verbosity(self, levels: Dict[str, str]) -> str:
458 requested_level = self._user_settings.get("verbosity") or self._config[
460 ].get("default-verbosity", "Standard")
461 keys = [key for key in levels]
463 if key.lower().startswith(requested_level.lower()):
466 "Invalid default-verbosity level {}. Valid "
467 "values are {}".format(requested_level, ", ".join(keys))
470 def get_description(self, category_name: str) -> str:
471 """Returns the description associated with the category name"""
472 return self._get_category(category_name)["description"]
474 def get_category(self, category_name: str) -> str:
475 """Returns the category associated with the category name"""
476 return self._get_category(category_name).get("category", "")
478 def get_category_value(self, category_name: str) -> str:
479 """Returns the saved value for a category name"""
480 return self._user_settings.get(category_name, 0)
482 def _get_category(self, category_name: str) -> Dict[str, str]:
483 """Returns the profile definition"""
484 for category in self._config["categories"]:
485 if category["name"] == category_name:
488 "Unexpected error: No category found in vvp-config.yaml "
489 "with a name of " + category_name
493 def default_report_format(self):
494 return self._user_settings.get("report_format", "HTML")
497 def report_formats(self):
498 return ["CSV", "Excel", "HTML"]
501 def preload_formats(self):
502 excluded = self._config.get("excluded-preloads", [])
503 formats = (cls.format_name() for cls in preload.get_generator_plugins())
504 return [f for f in formats if f not in excluded]
507 def default_preload_format(self):
508 default = self._user_settings.get("preload_format")
509 if default and default in self.preload_formats:
512 return self.preload_formats[0]
515 def get_subdir_for_preload(preload_format):
516 for gen in preload.get_generator_plugins():
517 if gen.format_name() == preload_format:
518 return gen.output_sub_dir()
522 def default_input_format(self):
523 requested_default = self._user_settings.get("input_format") or self._config[
525 ].get("default-input-format")
526 if requested_default in self.input_formats:
527 return requested_default
529 return self.input_formats[0]
532 def input_formats(self):
533 return ["Directory (Uncompressed)", "ZIP File"]
536 def default_halt_on_failure(self):
537 setting = self._user_settings.get("halt_on_failure", "True")
538 return setting.lower() == "true"
541 """Ensures the config file is properly formatted"""
542 categories = self._config["categories"]
544 # All profiles have required keys
545 expected_keys = {"name", "description"}
546 for category in categories:
547 actual_keys = set(category.keys())
548 missing_keys = expected_keys.difference(actual_keys)
551 "Error in vvp-config.yaml file: "
552 "Required field missing in category. "
554 "Categories: {}".format(",".join(missing_keys), category)
562 class Dialog(Toplevel):
564 Adapted from http://www.effbot.org/tkinterbook/tkinter-dialog-windows.htm
567 def __init__(self, parent: Frame, title=None):
568 Toplevel.__init__(self, parent)
569 self.transient(parent)
575 self.initial_focus = self.body(body)
576 body.pack(padx=5, pady=5)
579 if not self.initial_focus:
580 self.initial_focus = self
581 self.protocol("WM_DELETE_WINDOW", self.cancel)
583 "+%d+%d" % (parent.winfo_rootx() + 600, parent.winfo_rooty() + 400)
585 self.initial_focus.focus_set()
586 self.wait_window(self)
588 def body(self, master):
589 raise NotImplementedError()
591 # noinspection PyAttributeOutsideInit
594 self.accept = Button(
602 self.accept.pack(side=LEFT, padx=5, pady=5)
603 self.decline = Button(
604 box, text="Decline", width=10, state=DISABLED, command=self.cancel
606 self.decline.pack(side=LEFT, padx=5, pady=5)
607 self.bind("<Return>", self.ok)
608 self.bind("<Escape>", self.cancel)
611 # noinspection PyUnusedLocal
612 def ok(self, event=None):
614 self.initial_focus.focus_set() # put focus back
617 self.update_idletasks()
621 # noinspection PyUnusedLocal
622 def cancel(self, event=None):
623 self.parent.focus_set()
627 raise NotImplementedError()
629 def activate_buttons(self):
630 self.accept.configure(state=NORMAL)
631 self.decline.configure(state=NORMAL)
634 class TermsAndConditionsDialog(Dialog):
635 def __init__(self, parent, config: Config):
638 super().__init__(parent, config.terms_popup_title)
640 def body(self, master):
641 Label(master, text=self.config.terms_popup_message).grid(row=0, pady=5)
643 master, text=self.config.terms_link_text, fg="blue", cursor="hand2"
645 ValidatorApp.underline(tc_link)
646 tc_link.bind("<Button-1>", self.open_terms)
647 tc_link.grid(row=1, pady=5)
649 # noinspection PyUnusedLocal
650 def open_terms(self, event):
651 webbrowser.open(self.config.terms_link_url)
652 self.activate_buttons()
655 self.config.set_terms_accepted()
659 VERBOSITY_LEVELS = {"Less": "", "Standard (-v)": "-v", "More (-vv)": "-vv"}
661 def __init__(self, config: Config = None):
662 """Constructs the GUI element of the Validation Tool"""
664 self.config = config or Config()
667 self._root.title(self.config.app_name)
668 self._root.protocol("WM_DELETE_WINDOW", self.shutdown)
670 if self.config.terms_link_text:
671 menubar = Menu(self._root)
673 label=self.config.terms_link_text,
674 command=lambda: webbrowser.open(self.config.terms_link_url),
676 self._root.config(menu=menubar)
678 parent_frame = Frame(self._root)
679 main_window = PanedWindow(parent_frame)
680 main_window.pack(fill=BOTH, expand=1)
682 control_panel = PanedWindow(
683 main_window, orient=HORIZONTAL, sashpad=4, sashrelief=RAISED
685 actions = Frame(control_panel)
686 control_panel.add(actions)
687 control_panel.paneconfigure(actions, minsize=250)
689 if self.config.disclaimer_text or self.config.requirement_link_text:
690 self.footer = self.create_footer(parent_frame)
691 parent_frame.pack(fill=BOTH, expand=True)
694 number_of_categories = len(self.config.category_names)
695 category_frame = LabelFrame(actions, text="Additional Validation Categories:")
696 category_frame.grid(row=1, column=1, columnspan=3, pady=5, sticky="we")
700 for x in range(0, number_of_categories):
701 category_name = self.config.category_names[x]
702 category_value = IntVar(value=0)
703 category_value._name = "category_{}".format(category_name.replace(" ", "_"))
704 # noinspection PyProtectedMember
705 category_value.set(self.config.get_category_value(category_value._name))
706 self.categories.append(category_value)
707 category_checkbox = Checkbutton(
708 category_frame, text=category_name, variable=self.categories[x]
710 ToolTip(category_checkbox, self.config.get_description(category_name))
711 category_checkbox.grid(row=x + 1, column=1, columnspan=2, sticky="w")
713 settings_frame = LabelFrame(actions, text="Settings")
715 settings_frame.grid(row=3, column=1, columnspan=3, pady=10, sticky="we")
716 verbosity_label = Label(settings_frame, text="Verbosity:")
717 verbosity_label.grid(row=settings_row, column=1, sticky=W)
718 self.verbosity = StringVar(self._root, name="verbosity")
719 self.verbosity.set(self.config.default_verbosity(self.VERBOSITY_LEVELS))
720 verbosity_menu = OptionMenu(
721 settings_frame, self.verbosity, *tuple(self.VERBOSITY_LEVELS.keys())
723 verbosity_menu.config(width=25)
724 verbosity_menu.grid(row=settings_row, column=2, columnspan=3, sticky=E, pady=5)
727 if self.config.preload_formats:
728 preload_format_label = Label(settings_frame, text="Preload Template:")
729 preload_format_label.grid(row=settings_row, column=1, sticky=W)
730 self.preload_format = StringVar(self._root, name="preload_format")
731 self.preload_format.set(self.config.default_preload_format)
732 preload_format_menu = OptionMenu(
733 settings_frame, self.preload_format, *self.config.preload_formats
735 preload_format_menu.config(width=25)
736 preload_format_menu.grid(
737 row=settings_row, column=2, columnspan=3, sticky=E, pady=5
741 report_format_label = Label(settings_frame, text="Report Format:")
742 report_format_label.grid(row=settings_row, column=1, sticky=W)
743 self.report_format = StringVar(self._root, name="report_format")
744 self.report_format.set(self.config.default_report_format)
745 report_format_menu = OptionMenu(
746 settings_frame, self.report_format, *self.config.report_formats
748 report_format_menu.config(width=25)
749 report_format_menu.grid(
750 row=settings_row, column=2, columnspan=3, sticky=E, pady=5
754 input_format_label = Label(settings_frame, text="Input Format:")
755 input_format_label.grid(row=settings_row, column=1, sticky=W)
756 self.input_format = StringVar(self._root, name="input_format")
757 self.input_format.set(self.config.default_input_format)
758 input_format_menu = OptionMenu(
759 settings_frame, self.input_format, *self.config.input_formats
761 input_format_menu.config(width=25)
762 input_format_menu.grid(
763 row=settings_row, column=2, columnspan=3, sticky=E, pady=5
767 self.halt_on_failure = BooleanVar(self._root, name="halt_on_failure")
768 self.halt_on_failure.set(self.config.default_halt_on_failure)
769 halt_on_failure_label = Label(settings_frame, text="Halt on Basic Failures:")
770 halt_on_failure_label.grid(row=settings_row, column=1, sticky=E, pady=5)
771 halt_checkbox = Checkbutton(
772 settings_frame, offvalue=False, onvalue=True, variable=self.halt_on_failure
774 halt_checkbox.grid(row=settings_row, column=2, columnspan=2, sticky=W, pady=5)
776 directory_label = Label(actions, text="Template Location:")
777 directory_label.grid(row=4, column=1, pady=5, sticky=W)
778 self.template_source = StringVar(self._root, name="template_source")
779 directory_entry = Entry(actions, width=40, textvariable=self.template_source)
780 directory_entry.grid(row=4, column=2, pady=5, sticky=W)
781 directory_browse = Button(actions, text="...", command=self.ask_template_source)
782 directory_browse.grid(row=4, column=3, pady=5, sticky=W)
784 validate_button = Button(
785 actions, text="Validate Templates", command=self.validate
787 validate_button.grid(row=5, column=1, columnspan=2, pady=5)
789 self.result_panel = Frame(actions)
790 # We'll add these labels now, and then make them visible when the run completes
791 self.completion_label = Label(self.result_panel, text="Validation Complete!")
792 self.result_label = Label(
793 self.result_panel, text="View Report", fg="blue", cursor="hand2"
795 self.underline(self.result_label)
796 self.result_label.bind("<Button-1>", self.open_report)
798 self.preload_label = Label(
799 self.result_panel, text="View Preloads", fg="blue", cursor="hand2"
801 self.underline(self.preload_label)
802 self.preload_label.bind("<Button-1>", self.open_preloads)
804 self.result_panel.grid(row=6, column=1, columnspan=2)
805 control_panel.pack(fill=BOTH, expand=1)
807 main_window.add(control_panel)
809 self.log_panel = ScrolledText(main_window, wrap=WORD, width=120, height=20)
810 self.log_panel.configure(font=font.Font(family="Courier New", size="11"))
811 self.log_panel.pack(fill=BOTH, expand=1)
813 main_window.add(self.log_panel)
815 # Briefly add the completion and result labels so the window size includes
817 self.completion_label.pack()
818 self.result_label.pack() # Show report link
819 self.preload_label.pack() # Show preload link
820 self._root.after_idle(
822 self.completion_label.pack_forget(),
823 self.result_label.pack_forget(),
824 self.preload_label.pack_forget(),
833 self.halt_on_failure,
835 if self.config.preload_formats:
836 self.config.watch(self.preload_format)
837 self.schedule(self.execute_pollers)
838 if self.config.terms_link_text and not self.config.are_terms_accepted:
839 TermsAndConditionsDialog(parent_frame, self.config)
840 if not self.config.are_terms_accepted:
843 def create_footer(self, parent_frame):
844 footer = Frame(parent_frame)
845 disclaimer = Message(footer, text=self.config.disclaimer_text, anchor=CENTER)
846 disclaimer.grid(row=0, pady=2)
848 "<Configure>", lambda e: disclaimer.configure(width=e.width - 20)
850 if self.config.requirement_link_text:
851 requirement_link = Text(
854 bg=disclaimer.cget("bg"),
856 font=disclaimer.cget("font"),
858 requirement_link.tag_configure("center", justify="center")
859 hyperlinks = HyperlinkManager(requirement_link)
860 requirement_link.insert(INSERT, "Validating: ")
861 requirement_link.insert(
863 self.config.requirement_link_text,
864 hyperlinks.add(self.open_requirements),
866 requirement_link.tag_add("center", "1.0", "end")
867 requirement_link.config(state=DISABLED)
868 requirement_link.grid(row=1, pady=2)
869 ToolTip(requirement_link, self.config.requirement_link_url)
870 footer.grid_columnconfigure(0, weight=1)
871 footer.pack(fill=BOTH, expand=True)
874 def ask_template_source(self):
875 if self.input_format.get() == "ZIP File":
876 template_source = filedialog.askopenfilename(
877 title="Select Archive",
878 filetypes=(("ZIP Files", "*.zip"), ("All Files", "*")),
881 template_source = filedialog.askdirectory()
882 self.template_source.set(template_source)
885 """Run the pytest validations in a background process"""
886 if not self.delete_prior_report():
889 if not self.template_source.get():
890 self.ask_template_source()
892 template_dir = self.resolve_template_dir()
895 self.kill_background_task()
897 self.completion_label.pack_forget()
898 self.result_label.pack_forget()
899 self.preload_label.pack_forget()
900 self.task = multiprocessing.Process(
904 self.config.log_file,
905 self.config.status_queue,
906 self.categories_list(),
907 self.VERBOSITY_LEVELS[self.verbosity.get()],
908 self.report_format.get().lower(),
909 self.halt_on_failure.get(),
910 self.template_source.get(),
913 self.task.daemon = True
918 """Returns the text displayed in the title bar of the application"""
919 return self._root.title()
921 def execute_pollers(self):
922 """Call all methods that require periodic execution, and re-schedule
923 their execution for the next polling interval"""
926 self.poll_status_queue()
927 self.poll_command_queue()
929 self.schedule(self.execute_pollers)
933 """Yields values from the queue until empty"""
936 yield q.get(block=False)
940 def poll_command_queue(self):
941 """Picks up command strings from the commmand queue, and
942 dispatches it for execution. Only SHUTDOWN is supported
944 for command in self._drain_queue(self.config.command_queue):
945 if command == "SHUTDOWN":
948 def poll_status_queue(self):
949 """Checks for completion of the job, and then displays the View Report link
950 if it was successful or writes the exception to the ``log_panel`` if
952 for is_success, e in self._drain_queue(self.config.status_queue):
954 self.completion_label.pack()
955 self.result_label.pack() # Show report link
956 if hasattr(self, "preload_format"):
957 self.preload_label.pack() # Show preload link
959 self.log_panel.insert(END, str(e))
961 def poll_log_file(self):
962 """Reads captured stdout and stderr from the log queue and writes it to the
964 for line in self._drain_queue(self.config.log_queue):
965 self.log_panel.insert(END, line)
966 self.log_panel.see(END)
968 def schedule(self, func: Callable):
969 """Schedule the callable ``func`` to be executed according to
970 the polling_frequency"""
971 self._root.after(self.config.polling_frequency, func)
974 """Removes all log entries from teh log panel"""
975 self.log_panel.delete("1.0", END)
977 def delete_prior_report(self) -> bool:
978 """Attempts to delete the current report, and pops up a warning message
979 to the user if it can't be deleted. This will force the user to
980 close the report before re-running the validation. Returns True if
981 the file was deleted or did not exist, or False otherwise"""
982 if not os.path.exists(self.report_file_path):
986 os.remove(self.report_file_path)
989 messagebox.showerror(
991 "Please close or rename the open report file before re-validating",
996 def report_file_path(self):
997 ext_mapping = {"csv": "csv", "html": "html", "excel": "xlsx"}
998 ext = ext_mapping.get(self.report_format.get().lower())
999 return os.path.join(PATH, OUT_DIR, "report.{}".format(ext))
1001 # noinspection PyUnusedLocal
1002 def open_report(self, event):
1003 """Open the report in the user's default browser"""
1004 webbrowser.open_new("file://{}".format(self.report_file_path))
1006 def open_preloads(self, event):
1007 """Open the report in the user's default browser"""
1008 path = os.path.join(
1012 self.config.get_subdir_for_preload(self.preload_format.get()),
1014 if platform.system() == "Windows":
1015 os.startfile(path) # nosec
1016 elif platform.system() == "Darwin":
1017 subprocess.Popen(["open", path]) # nosec
1019 subprocess.Popen(["xdg-open", path]) # nosec
1021 def open_requirements(self):
1022 """Open the report in the user's default browser"""
1023 webbrowser.open_new(self.config.requirement_link_url)
1026 """Start the event loop of the application. This method does not return"""
1027 self._root.mainloop()
1030 def underline(label):
1031 """Apply underline format to an existing label"""
1032 f = font.Font(label, label.cget("font"))
1033 f.configure(underline=True)
1034 label.configure(font=f)
1036 def kill_background_task(self):
1037 if self.task and self.task.is_alive():
1038 self.task.terminate()
1039 for _ in self._drain_queue(self.config.log_queue):
1043 """Shutdown the application"""
1044 self.kill_background_task()
1045 self._root.destroy()
1047 def check_template_source_is_valid(self):
1048 """Verifies the value of template source exists and of valid type based
1050 if not self.template_source.get():
1052 template_path = Path(self.template_source.get())
1054 if not template_path.exists():
1055 messagebox.showerror(
1057 "Input does not exist. Please provide a valid file or directory.",
1061 if self.input_format.get() == "ZIP File":
1062 if zipfile.is_zipfile(template_path):
1065 messagebox.showerror(
1066 "Error", "Expected ZIP file, but input is not a valid ZIP file"
1070 if template_path.is_dir():
1073 messagebox.showerror(
1074 "Error", "Expected directory, but input is not a directory"
1078 def resolve_template_dir(self) -> str:
1079 """Extracts the zip file to a temporary directory if needed, otherwise
1080 returns the directory supplied to template source. Returns empty string
1081 if the template source isn't valid"""
1082 if not self.check_template_source_is_valid():
1084 if self.input_format.get() == "ZIP File":
1085 temp_dir = tempfile.mkdtemp()
1086 archive = zipfile.ZipFile(self.template_source.get())
1087 archive.extractall(path=temp_dir)
1090 return self.template_source.get()
1092 def categories_list(self) -> list:
1094 selected_categories = self.categories
1095 for x in range(0, len(selected_categories)):
1096 if selected_categories[x].get():
1097 category = self.config.category_names[x]
1098 categories.append(self.config.get_category(category))
1102 if __name__ == "__main__":
1103 multiprocessing.freeze_support() # needed for PyInstaller to work
1104 ValidatorApp().start()