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 import subprocess # nosec
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, TextIO, Callable
106 from config import Config
107 from preload.engine import PLUGIN_MGR
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-":
220 categories: Optional[list],
222 halt_on_failure: bool,
223 template_source: str,
225 preload_format: list,
228 """Runs pytest using the given ``profile`` in a background process. All
229 ``stdout`` and ``stderr`` are redirected to ``log``. The result of the job
230 will be put on the ``completion_queue``
232 :param template_dir: The directory containing the files to be validated.
233 :param log: ` `stderr`` and ``stdout`` of the pytest job will be
235 :param result_queue: Completion status posted here. See :class:`Config`
236 for more information.
237 :param categories: list of optional categories. When provided, pytest
238 will collect and execute all tests that are
239 decorated with any of the passed categories, as
240 well as tests not decorated with a category.
241 :param report_format: Determines the style of report written. Options are
243 :param halt_on_failure: Determines if validation will halt when basic failures
244 are encountered in the input files. This can help
245 prevent a large number of errors from flooding the
247 :param template_source: The path or name of the template to show on the report
248 :param preload_config: Optional directory or file that is input to preload
250 :param preload_format: Selected preload format
251 :param preload_source: Name of selected preload data source plugin
253 out_path = "{}/{}".format(PATH, OUT_DIR)
254 if os.path.exists(out_path):
255 rmtree(out_path, ignore_errors=True)
256 with contextlib.redirect_stderr(log), contextlib.redirect_stdout(log):
259 "--ignore=app_tests",
261 "--template-directory={}".format(template_dir),
262 "--report-format={}".format(report_format),
263 "--template-source={}".format(template_source),
266 args.append("--preload-source={}".format(preload_config))
268 "--preload-source-type={}".format(
269 PLUGIN_MGR.get_source_for_name(preload_source).get_identifier()
273 for category in categories:
274 args.extend(("--category", category))
275 if not halt_on_failure:
276 args.append("--continue-on-failure")
278 args.append("--preload-format={}".format(preload_format))
279 print("args: ", " ".join(args))
280 pytest.main(args=args)
281 result_queue.put((True, None))
283 result_queue.put((False, traceback.format_exc()))
286 class Dialog(Toplevel):
288 Adapted from http://www.effbot.org/tkinterbook/tkinter-dialog-windows.htm
291 def __init__(self, parent: Frame, title=None):
292 Toplevel.__init__(self, parent)
293 self.transient(parent)
299 self.initial_focus = self.body(body)
300 body.pack(padx=5, pady=5)
303 if not self.initial_focus:
304 self.initial_focus = self
305 self.protocol("WM_DELETE_WINDOW", self.cancel)
307 "+%d+%d" % (parent.winfo_rootx() + 600, parent.winfo_rooty() + 400)
309 self.initial_focus.focus_set()
310 self.wait_window(self)
312 def body(self, master):
313 raise NotImplementedError()
315 # noinspection PyAttributeOutsideInit
318 self.accept = Button(
326 self.accept.pack(side=LEFT, padx=5, pady=5)
327 self.decline = Button(
328 box, text="Decline", width=10, state=DISABLED, command=self.cancel
330 self.decline.pack(side=LEFT, padx=5, pady=5)
331 self.bind("<Return>", self.ok)
332 self.bind("<Escape>", self.cancel)
335 # noinspection PyUnusedLocal
336 def ok(self, event=None):
338 self.update_idletasks()
342 # noinspection PyUnusedLocal
343 def cancel(self, event=None):
344 self.parent.focus_set()
348 raise NotImplementedError()
350 def activate_buttons(self):
351 self.accept.configure(state=NORMAL)
352 self.decline.configure(state=NORMAL)
355 class TermsAndConditionsDialog(Dialog):
356 def __init__(self, parent, config: Config):
359 super().__init__(parent, config.terms_popup_title)
361 def body(self, master):
362 Label(master, text=self.config.terms_popup_message).grid(row=0, pady=5)
364 master, text=self.config.terms_link_text, fg="blue", cursor="hand2"
366 ValidatorApp.underline(tc_link)
367 tc_link.bind("<Button-1>", self.open_terms)
368 tc_link.grid(row=1, pady=5)
370 # noinspection PyUnusedLocal
371 def open_terms(self, event):
372 webbrowser.open(self.config.terms_link_url)
373 self.activate_buttons()
376 self.config.set_terms_accepted()
380 def __init__(self, config: Config = None):
381 """Constructs the GUI element of the Validation Tool"""
383 self.config = config or Config()
386 self._root.title(self.config.app_name)
387 self._root.protocol("WM_DELETE_WINDOW", self.shutdown)
389 if self.config.terms_link_text:
390 menubar = Menu(self._root)
392 label=self.config.terms_link_text,
393 command=lambda: webbrowser.open(self.config.terms_link_url),
395 self._root.config(menu=menubar)
397 parent_frame = Frame(self._root)
398 main_window = PanedWindow(parent_frame)
399 main_window.pack(fill=BOTH, expand=1)
401 control_panel = PanedWindow(
402 main_window, orient=HORIZONTAL, sashpad=4, sashrelief=RAISED
404 actions = Frame(control_panel)
405 control_panel.add(actions)
406 control_panel.paneconfigure(actions, minsize=350)
408 if self.config.disclaimer_text or self.config.requirement_link_text:
409 self.footer = self.create_footer(parent_frame)
410 parent_frame.pack(fill=BOTH, expand=True)
413 number_of_categories = len(self.config.category_names)
414 category_frame = LabelFrame(actions, text="Additional Validation Categories:")
415 category_frame.grid(row=1, column=1, columnspan=3, pady=5, sticky="we")
419 for x in range(0, number_of_categories):
420 category_name = self.config.category_names[x]
421 category_value = IntVar(value=0)
422 category_value._name = "category_{}".format(category_name.replace(" ", "_"))
423 # noinspection PyProtectedMember
424 category_value.set(self.config.get_category_value(category_value._name))
425 self.categories.append(category_value)
426 category_checkbox = Checkbutton(
427 category_frame, text=category_name, variable=self.categories[x]
429 ToolTip(category_checkbox, self.config.get_description(category_name))
430 category_checkbox.grid(row=x + 1, column=1, columnspan=2, sticky="w")
432 settings_frame = LabelFrame(actions, text="Settings")
434 settings_frame.grid(row=3, column=1, columnspan=3, pady=10, sticky="we")
436 if self.config.preload_formats:
437 preload_format_label = Label(settings_frame, text="Preload Format:")
438 preload_format_label.grid(row=settings_row, column=1, sticky=W)
439 self.preload_format = StringVar(self._root, name="preload_format")
440 self.preload_format.set(self.config.default_preload_format)
441 preload_format_menu = OptionMenu(
442 settings_frame, self.preload_format, *self.config.preload_formats
444 preload_format_menu.config(width=25)
445 preload_format_menu.grid(
446 row=settings_row, column=2, columnspan=3, sticky=E, pady=5
450 preload_source_label = Label(settings_frame, text="Preload Source:")
451 preload_source_label.grid(row=settings_row, column=1, sticky=W)
452 self.preload_source = StringVar(self._root, name="preload_source")
453 self.preload_source.set(self.config.default_preload_source)
454 preload_source_menu = OptionMenu(
455 settings_frame, self.preload_source, *self.config.preload_source_types
457 preload_source_menu.config(width=25)
458 preload_source_menu.grid(
459 row=settings_row, column=2, columnspan=3, sticky=E, pady=5
463 report_format_label = Label(settings_frame, text="Report Format:")
464 report_format_label.grid(row=settings_row, column=1, sticky=W)
465 self.report_format = StringVar(self._root, name="report_format")
466 self.report_format.set(self.config.default_report_format)
467 report_format_menu = OptionMenu(
468 settings_frame, self.report_format, *self.config.report_formats
470 report_format_menu.config(width=25)
471 report_format_menu.grid(
472 row=settings_row, column=2, columnspan=3, sticky=E, pady=5
476 input_format_label = Label(settings_frame, text="Input Format:")
477 input_format_label.grid(row=settings_row, column=1, sticky=W)
478 self.input_format = StringVar(self._root, name="input_format")
479 self.input_format.set(self.config.default_input_format)
480 input_format_menu = OptionMenu(
481 settings_frame, self.input_format, *self.config.input_formats
483 input_format_menu.config(width=25)
484 input_format_menu.grid(
485 row=settings_row, column=2, columnspan=3, sticky=E, pady=5
489 self.halt_on_failure = BooleanVar(self._root, name="halt_on_failure")
490 self.halt_on_failure.set(self.config.default_halt_on_failure)
491 halt_on_failure_label = Label(
492 settings_frame, text="Halt on Basic Failures:", anchor=W, justify=LEFT
494 halt_on_failure_label.grid(row=settings_row, column=1, sticky=W, pady=5)
495 halt_checkbox = Checkbutton(
496 settings_frame, offvalue=False, onvalue=True, variable=self.halt_on_failure
498 halt_checkbox.grid(row=settings_row, column=2, columnspan=2, sticky=W, pady=5)
501 self.create_preloads = BooleanVar(self._root, name="create_preloads")
502 self.create_preloads.set(self.config.default_create_preloads)
503 create_preloads_label = Label(
505 text="Create Preload from Datasource:",
509 create_preloads_label.grid(row=settings_row, column=1, sticky=W, pady=5)
510 create_preloads_checkbox = Checkbutton(
514 variable=self.create_preloads,
515 command=self.set_env_dir_state,
517 create_preloads_checkbox.grid(
518 row=settings_row, column=2, columnspan=2, sticky=W, pady=5
521 directory_label = Label(actions, text="Template Location:")
522 directory_label.grid(row=4, column=1, pady=5, sticky=W)
523 self.template_source = StringVar(self._root, name="template_source")
524 directory_entry = Entry(actions, width=40, textvariable=self.template_source)
525 directory_entry.grid(row=4, column=2, pady=5, sticky=W)
526 directory_browse = Button(actions, text="...", command=self.ask_template_source)
527 directory_browse.grid(row=4, column=3, pady=5, sticky=W)
529 preload_config_label = Label(actions, text="Preload Datasource:")
530 preload_config_label.grid(row=5, column=1, pady=5, sticky=W)
531 self.preload_config = StringVar(self._root, name="preload_config")
532 preload_config_state = NORMAL if self.create_preloads.get() else DISABLED
533 self.preload_config_entry = Entry(
536 textvariable=self.preload_config,
537 state=preload_config_state,
539 self.preload_config_entry.grid(row=5, column=2, pady=5, sticky=W)
540 preload_config_browse = Button(
541 actions, text="...", command=self.ask_preload_source
543 preload_config_browse.grid(row=5, column=3, pady=5, sticky=W)
545 validate_button = Button(
546 actions, text="Process Templates", command=self.validate
548 validate_button.grid(row=6, column=1, columnspan=2, pady=5)
550 self.result_panel = Frame(actions)
551 # We'll add these labels now, and then make them visible when the run completes
552 self.completion_label = Label(self.result_panel, text="Validation Complete!")
553 self.result_label = Label(
554 self.result_panel, text="View Report", fg="blue", cursor="hand2"
556 self.underline(self.result_label)
557 self.result_label.bind("<Button-1>", self.open_report)
559 self.preload_label = Label(
560 self.result_panel, text="View Preload Templates", fg="blue", cursor="hand2"
562 self.underline(self.preload_label)
563 self.preload_label.bind("<Button-1>", self.open_preloads)
565 self.result_panel.grid(row=7, column=1, columnspan=2)
566 control_panel.pack(fill=BOTH, expand=1)
568 main_window.add(control_panel)
570 self.log_panel = ScrolledText(main_window, wrap=WORD, width=120, height=20)
571 self.log_panel.configure(font=font.Font(family="Courier New", size="11"))
572 self.log_panel.pack(fill=BOTH, expand=1)
574 main_window.add(self.log_panel)
576 # Briefly add the completion and result labels so the window size includes
578 self.completion_label.pack()
579 self.result_label.pack() # Show report link
580 self.preload_label.pack() # Show preload link
581 self._root.after_idle(
583 self.completion_label.pack_forget(),
584 self.result_label.pack_forget(),
585 self.preload_label.pack_forget(),
593 self.halt_on_failure,
595 self.create_preloads,
598 self.schedule(self.execute_pollers)
599 if self.config.terms_link_text and not self.config.are_terms_accepted:
600 TermsAndConditionsDialog(parent_frame, self.config)
601 if not self.config.are_terms_accepted:
604 def create_footer(self, parent_frame):
605 footer = Frame(parent_frame)
606 disclaimer = Message(footer, text=self.config.disclaimer_text, anchor=CENTER)
607 disclaimer.grid(row=0, pady=2)
609 "<Configure>", lambda e: disclaimer.configure(width=e.width - 20)
611 if self.config.requirement_link_text:
612 requirement_link = Text(
615 bg=disclaimer.cget("bg"),
617 font=disclaimer.cget("font"),
619 requirement_link.tag_configure("center", justify="center")
620 hyperlinks = HyperlinkManager(requirement_link)
621 requirement_link.insert(INSERT, "Validating: ")
622 requirement_link.insert(
624 self.config.requirement_link_text,
625 hyperlinks.add(self.open_requirements),
627 requirement_link.tag_add("center", "1.0", "end")
628 requirement_link.config(state=DISABLED)
629 requirement_link.grid(row=1, pady=2)
630 ToolTip(requirement_link, self.config.requirement_link_url)
631 footer.grid_columnconfigure(0, weight=1)
632 footer.pack(fill=BOTH, expand=True)
635 def set_env_dir_state(self):
636 state = NORMAL if self.create_preloads.get() else DISABLED
637 if state == DISABLED:
638 self.preload_config.set("")
639 self.preload_config_entry.config(state=state)
641 def ask_template_source(self):
642 if self.input_format.get() == "ZIP File":
643 template_source = filedialog.askopenfilename(
644 title="Select Archive",
645 filetypes=(("ZIP Files", "*.zip"), ("All Files", "*")),
648 template_source = filedialog.askdirectory()
649 self.template_source.set(template_source)
651 def ask_preload_source(self):
653 for source in PLUGIN_MGR.preload_sources:
654 if source.get_name() == self.preload_source.get():
655 input_type = source.get_source_type()
657 if input_type == "DIR":
658 self.preload_config.set(filedialog.askdirectory())
660 self.preload_config.set(
661 filedialog.askopenfilename(
662 title="Select Preload Datasource File",
663 filetypes=(("All Files", "*"),),
668 """Run the pytest validations in a background process"""
669 if not self.delete_prior_report():
672 if not self.template_source.get():
673 self.ask_template_source()
675 template_dir = self.resolve_template_dir()
678 self.kill_background_task()
680 self.completion_label.pack_forget()
681 self.result_label.pack_forget()
682 self.preload_label.pack_forget()
683 self.task = multiprocessing.Process(
687 self.config.log_file,
688 self.config.status_queue,
689 self.categories_list(),
690 self.report_format.get().lower(),
691 self.halt_on_failure.get(),
692 self.template_source.get(),
693 self.preload_config.get(),
694 self.preload_format.get(),
695 self.preload_source.get(),
698 self.task.daemon = True
703 """Returns the text displayed in the title bar of the application"""
704 return self._root.title()
706 def execute_pollers(self):
707 """Call all methods that require periodic execution, and re-schedule
708 their execution for the next polling interval"""
711 self.poll_status_queue()
712 self.poll_command_queue()
714 self.schedule(self.execute_pollers)
718 """Yields values from the queue until empty"""
721 yield q.get(block=False)
725 def poll_command_queue(self):
726 """Picks up command strings from the commmand queue, and
727 dispatches it for execution. Only SHUTDOWN is supported
729 for command in self._drain_queue(self.config.command_queue):
730 if command == "SHUTDOWN":
733 def poll_status_queue(self):
734 """Checks for completion of the job, and then displays the View Report link
735 if it was successful or writes the exception to the ``log_panel`` if
737 for is_success, e in self._drain_queue(self.config.status_queue):
739 self.completion_label.pack()
740 self.result_label.pack() # Show report link
741 if hasattr(self, "preload_format"):
742 self.preload_label.pack() # Show preload link
744 self.log_panel.insert(END, str(e))
746 def poll_log_file(self):
747 """Reads captured stdout and stderr from the log queue and writes it to the
749 for line in self._drain_queue(self.config.log_queue):
750 self.log_panel.insert(END, line)
751 self.log_panel.see(END)
753 def schedule(self, func: Callable):
754 """Schedule the callable ``func`` to be executed according to
755 the polling_frequency"""
756 self._root.after(self.config.polling_frequency, func)
759 """Removes all log entries from teh log panel"""
760 self.log_panel.delete("1.0", END)
762 def delete_prior_report(self) -> bool:
763 """Attempts to delete the current report, and pops up a warning message
764 to the user if it can't be deleted. This will force the user to
765 close the report before re-running the validation. Returns True if
766 the file was deleted or did not exist, or False otherwise"""
767 if not os.path.exists(self.report_file_path):
771 os.remove(self.report_file_path)
774 messagebox.showerror(
776 "Please close or rename the open report file before re-validating",
781 def report_file_path(self):
782 ext_mapping = {"csv": "csv", "html": "html", "excel": "xlsx"}
783 ext = ext_mapping.get(self.report_format.get().lower())
784 return os.path.join(PATH, OUT_DIR, "report.{}".format(ext))
786 # noinspection PyUnusedLocal
787 def open_report(self, event):
788 """Open the report in the user's default browser"""
789 path = Path(self.report_file_path).absolute().resolve().as_uri()
790 webbrowser.open_new(path)
792 def open_preloads(self, event):
793 """Open the report in the user's default browser"""
798 self.config.get_subdir_for_preload(self.preload_format.get()),
800 if platform.system() == "Windows":
801 os.startfile(path) # nosec
802 elif platform.system() == "Darwin":
803 subprocess.Popen(["open", path]) # nosec
805 subprocess.Popen(["xdg-open", path]) # nosec
807 def open_requirements(self):
808 """Open the report in the user's default browser"""
809 webbrowser.open_new(self.config.requirement_link_url)
812 """Start the event loop of the application. This method does not return"""
813 self._root.mainloop()
816 def underline(label):
817 """Apply underline format to an existing label"""
818 f = font.Font(label, label.cget("font"))
819 f.configure(underline=True)
820 label.configure(font=f)
822 def kill_background_task(self):
823 if self.task and self.task.is_alive():
824 self.task.terminate()
825 for _ in self._drain_queue(self.config.log_queue):
829 """Shutdown the application"""
830 self.kill_background_task()
833 def check_template_source_is_valid(self):
834 """Verifies the value of template source exists and of valid type based
836 if not self.template_source.get():
838 template_path = Path(self.template_source.get())
840 if not template_path.exists():
841 messagebox.showerror(
843 "Input does not exist. Please provide a valid file or directory.",
847 if self.input_format.get() == "ZIP File":
848 if zipfile.is_zipfile(template_path):
851 messagebox.showerror(
852 "Error", "Expected ZIP file, but input is not a valid ZIP file"
856 if template_path.is_dir():
859 messagebox.showerror(
860 "Error", "Expected directory, but input is not a directory"
864 def resolve_template_dir(self) -> str:
865 """Extracts the zip file to a temporary directory if needed, otherwise
866 returns the directory supplied to template source. Returns empty string
867 if the template source isn't valid"""
868 if not self.check_template_source_is_valid():
870 if self.input_format.get() == "ZIP File":
871 temp_dir = tempfile.mkdtemp()
872 archive = zipfile.ZipFile(self.template_source.get())
873 archive.extractall(path=temp_dir)
876 return self.template_source.get()
878 def categories_list(self) -> list:
880 selected_categories = self.categories
881 for x in range(0, len(selected_categories)):
882 if selected_categories[x].get():
883 category = self.config.category_names[x]
884 categories.append(self.config.get_category(category))
888 if __name__ == "__main__":
889 multiprocessing.freeze_support() # needed for PyInstaller to work
890 ValidatorApp().start()