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
108 VERSION = version.VERSION
109 PATH = os.path.dirname(os.path.realpath(__file__))
113 class ToolTip(object):
115 create a tooltip for a given widget
118 def __init__(self, widget, text="widget info"):
119 self.waittime = 750 # milliseconds
120 self.wraplength = 300 # pixels
123 self.widget.bind("<Enter>", self.enter)
124 self.widget.bind("<Leave>", self.leave)
125 self.widget.bind("<ButtonPress>", self.leave)
129 # noinspection PyUnusedLocal
130 def enter(self, event=None):
133 # noinspection PyUnusedLocal
134 def leave(self, event=None):
140 self.id = self.widget.after(self.waittime, self.showtip)
142 def unschedule(self):
146 self.widget.after_cancel(orig_id)
148 # noinspection PyUnusedLocal
149 def showtip(self, event=None):
151 x, y, cx, cy = self.widget.bbox("insert")
152 x += self.widget.winfo_rootx() + 25
153 y += self.widget.winfo_rooty() + 20
154 # creates a top level window
155 self.tw = Toplevel(self.widget)
156 # Leaves only the label and removes the app window
157 self.tw.wm_overrideredirect(True)
158 self.tw.wm_geometry("+%d+%d" % (x, y))
163 background="#ffffff",
166 wraplength=self.wraplength,
177 class HyperlinkManager:
178 """Adapted from http://effbot.org/zone/tkinter-text-hyperlink.htm"""
180 def __init__(self, text):
183 self.text.tag_config("hyper", foreground="blue", underline=1)
184 self.text.tag_bind("hyper", "<Enter>", self._enter)
185 self.text.tag_bind("hyper", "<Leave>", self._leave)
186 self.text.tag_bind("hyper", "<Button-1>", self._click)
192 def add(self, action):
193 # add an action to the manager. returns tags to use in
194 # associated text widget
195 tag = "hyper-%d" % len(self.links)
196 self.links[tag] = action
199 # noinspection PyUnusedLocal
200 def _enter(self, event):
201 self.text.config(cursor="hand2")
203 # noinspection PyUnusedLocal
204 def _leave(self, event):
205 self.text.config(cursor="")
207 # noinspection PyUnusedLocal
208 def _click(self, event):
209 for tag in self.text.tag_names(CURRENT):
210 if tag[:6] == "hyper-":
219 categories: Optional[list],
221 halt_on_failure: bool,
222 template_source: str,
224 preload_format: list,
226 """Runs pytest using the given ``profile`` in a background process. All
227 ``stdout`` and ``stderr`` are redirected to ``log``. The result of the job
228 will be put on the ``completion_queue``
230 :param template_dir: The directory containing the files to be validated.
231 :param log: ` `stderr`` and ``stdout`` of the pytest job will be
233 :param result_queue: Completion status posted here. See :class:`Config`
234 for more information.
235 :param categories: list of optional categories. When provided, pytest
236 will collect and execute all tests that are
237 decorated with any of the passed categories, as
238 well as tests not decorated with a category.
239 :param report_format: Determines the style of report written. Options are
241 :param halt_on_failure: Determines if validation will halt when basic failures
242 are encountered in the input files. This can help
243 prevent a large number of errors from flooding the
245 :param template_source: The path or name of the template to show on the report
246 :param env_dir: Optional directory of env files that can be used
247 to generate populated preload templates
248 :param preload_format: Selected preload format
250 out_path = "{}/{}".format(PATH, OUT_DIR)
251 if os.path.exists(out_path):
252 rmtree(out_path, ignore_errors=True)
253 with contextlib.redirect_stderr(log), contextlib.redirect_stdout(log):
256 "--ignore=app_tests",
258 "--template-directory={}".format(template_dir),
259 "--report-format={}".format(report_format),
260 "--template-source={}".format(template_source),
263 args.append("--env-directory={}".format(env_dir))
265 for category in categories:
266 args.extend(("--category", category))
267 if not halt_on_failure:
268 args.append("--continue-on-failure")
270 args.append("--preload-format={}".format(preload_format))
271 pytest.main(args=args)
272 result_queue.put((True, None))
274 result_queue.put((False, traceback.format_exc()))
277 class Dialog(Toplevel):
279 Adapted from http://www.effbot.org/tkinterbook/tkinter-dialog-windows.htm
282 def __init__(self, parent: Frame, title=None):
283 Toplevel.__init__(self, parent)
284 self.transient(parent)
290 self.initial_focus = self.body(body)
291 body.pack(padx=5, pady=5)
294 if not self.initial_focus:
295 self.initial_focus = self
296 self.protocol("WM_DELETE_WINDOW", self.cancel)
298 "+%d+%d" % (parent.winfo_rootx() + 600, parent.winfo_rooty() + 400)
300 self.initial_focus.focus_set()
301 self.wait_window(self)
303 def body(self, master):
304 raise NotImplementedError()
306 # noinspection PyAttributeOutsideInit
309 self.accept = Button(
317 self.accept.pack(side=LEFT, padx=5, pady=5)
318 self.decline = Button(
319 box, text="Decline", width=10, state=DISABLED, command=self.cancel
321 self.decline.pack(side=LEFT, padx=5, pady=5)
322 self.bind("<Return>", self.ok)
323 self.bind("<Escape>", self.cancel)
326 # noinspection PyUnusedLocal
327 def ok(self, event=None):
329 self.update_idletasks()
333 # noinspection PyUnusedLocal
334 def cancel(self, event=None):
335 self.parent.focus_set()
339 raise NotImplementedError()
341 def activate_buttons(self):
342 self.accept.configure(state=NORMAL)
343 self.decline.configure(state=NORMAL)
346 class TermsAndConditionsDialog(Dialog):
347 def __init__(self, parent, config: Config):
350 super().__init__(parent, config.terms_popup_title)
352 def body(self, master):
353 Label(master, text=self.config.terms_popup_message).grid(row=0, pady=5)
355 master, text=self.config.terms_link_text, fg="blue", cursor="hand2"
357 ValidatorApp.underline(tc_link)
358 tc_link.bind("<Button-1>", self.open_terms)
359 tc_link.grid(row=1, pady=5)
361 # noinspection PyUnusedLocal
362 def open_terms(self, event):
363 webbrowser.open(self.config.terms_link_url)
364 self.activate_buttons()
367 self.config.set_terms_accepted()
371 def __init__(self, config: Config = None):
372 """Constructs the GUI element of the Validation Tool"""
374 self.config = config or Config()
377 self._root.title(self.config.app_name)
378 self._root.protocol("WM_DELETE_WINDOW", self.shutdown)
380 if self.config.terms_link_text:
381 menubar = Menu(self._root)
383 label=self.config.terms_link_text,
384 command=lambda: webbrowser.open(self.config.terms_link_url),
386 self._root.config(menu=menubar)
388 parent_frame = Frame(self._root)
389 main_window = PanedWindow(parent_frame)
390 main_window.pack(fill=BOTH, expand=1)
392 control_panel = PanedWindow(
393 main_window, orient=HORIZONTAL, sashpad=4, sashrelief=RAISED
395 actions = Frame(control_panel)
396 control_panel.add(actions)
397 control_panel.paneconfigure(actions, minsize=350)
399 if self.config.disclaimer_text or self.config.requirement_link_text:
400 self.footer = self.create_footer(parent_frame)
401 parent_frame.pack(fill=BOTH, expand=True)
404 number_of_categories = len(self.config.category_names)
405 category_frame = LabelFrame(actions, text="Additional Validation Categories:")
406 category_frame.grid(row=1, column=1, columnspan=3, pady=5, sticky="we")
410 for x in range(0, number_of_categories):
411 category_name = self.config.category_names[x]
412 category_value = IntVar(value=0)
413 category_value._name = "category_{}".format(category_name.replace(" ", "_"))
414 # noinspection PyProtectedMember
415 category_value.set(self.config.get_category_value(category_value._name))
416 self.categories.append(category_value)
417 category_checkbox = Checkbutton(
418 category_frame, text=category_name, variable=self.categories[x]
420 ToolTip(category_checkbox, self.config.get_description(category_name))
421 category_checkbox.grid(row=x + 1, column=1, columnspan=2, sticky="w")
423 settings_frame = LabelFrame(actions, text="Settings")
425 settings_frame.grid(row=3, column=1, columnspan=3, pady=10, sticky="we")
427 if self.config.preload_formats:
428 preload_format_label = Label(settings_frame, text="Preload Template:")
429 preload_format_label.grid(row=settings_row, column=1, sticky=W)
430 self.preload_format = StringVar(self._root, name="preload_format")
431 self.preload_format.set(self.config.default_preload_format)
432 preload_format_menu = OptionMenu(
433 settings_frame, self.preload_format, *self.config.preload_formats
435 preload_format_menu.config(width=25)
436 preload_format_menu.grid(
437 row=settings_row, column=2, columnspan=3, sticky=E, pady=5
441 report_format_label = Label(settings_frame, text="Report Format:")
442 report_format_label.grid(row=settings_row, column=1, sticky=W)
443 self.report_format = StringVar(self._root, name="report_format")
444 self.report_format.set(self.config.default_report_format)
445 report_format_menu = OptionMenu(
446 settings_frame, self.report_format, *self.config.report_formats
448 report_format_menu.config(width=25)
449 report_format_menu.grid(
450 row=settings_row, column=2, columnspan=3, sticky=E, pady=5
454 input_format_label = Label(settings_frame, text="Input Format:")
455 input_format_label.grid(row=settings_row, column=1, sticky=W)
456 self.input_format = StringVar(self._root, name="input_format")
457 self.input_format.set(self.config.default_input_format)
458 input_format_menu = OptionMenu(
459 settings_frame, self.input_format, *self.config.input_formats
461 input_format_menu.config(width=25)
462 input_format_menu.grid(
463 row=settings_row, column=2, columnspan=3, sticky=E, pady=5
467 self.halt_on_failure = BooleanVar(self._root, name="halt_on_failure")
468 self.halt_on_failure.set(self.config.default_halt_on_failure)
469 halt_on_failure_label = Label(
470 settings_frame, text="Halt on Basic Failures:", anchor=W, justify=LEFT
472 halt_on_failure_label.grid(row=settings_row, column=1, sticky=W, pady=5)
473 halt_checkbox = Checkbutton(
474 settings_frame, offvalue=False, onvalue=True, variable=self.halt_on_failure
476 halt_checkbox.grid(row=settings_row, column=2, columnspan=2, sticky=W, pady=5)
479 self.create_preloads = BooleanVar(self._root, name="create_preloads")
480 self.create_preloads.set(0)
481 create_preloads_label = Label(
483 text="Create Preload from Env Files:",
487 create_preloads_label.grid(row=settings_row, column=1, sticky=W, pady=5)
488 create_preloads_checkbox = Checkbutton(
492 variable=self.create_preloads,
493 command=self.set_env_dir_state,
495 create_preloads_checkbox.grid(
496 row=settings_row, column=2, columnspan=2, sticky=W, pady=5
499 directory_label = Label(actions, text="Template Location:")
500 directory_label.grid(row=4, column=1, pady=5, sticky=W)
501 self.template_source = StringVar(self._root, name="template_source")
502 directory_entry = Entry(actions, width=40, textvariable=self.template_source)
503 directory_entry.grid(row=4, column=2, pady=5, sticky=W)
504 directory_browse = Button(actions, text="...", command=self.ask_template_source)
505 directory_browse.grid(row=4, column=3, pady=5, sticky=W)
507 env_dir_label = Label(actions, text="Env Files:")
508 env_dir_label.grid(row=5, column=1, pady=5, sticky=W)
509 self.env_dir = StringVar(self._root, name="env_dir")
510 self.env_dir_entry = Entry(
511 actions, width=40, textvariable=self.env_dir, state=DISABLED
513 self.env_dir_entry.grid(row=5, column=2, pady=5, sticky=W)
514 env_dir_browse = Button(actions, text="...", command=self.ask_env_dir_source)
515 env_dir_browse.grid(row=5, column=3, pady=5, sticky=W)
517 validate_button = Button(
518 actions, text="Process Templates", command=self.validate
520 validate_button.grid(row=6, column=1, columnspan=2, pady=5)
522 self.result_panel = Frame(actions)
523 # We'll add these labels now, and then make them visible when the run completes
524 self.completion_label = Label(self.result_panel, text="Validation Complete!")
525 self.result_label = Label(
526 self.result_panel, text="View Report", fg="blue", cursor="hand2"
528 self.underline(self.result_label)
529 self.result_label.bind("<Button-1>", self.open_report)
531 self.preload_label = Label(
532 self.result_panel, text="View Preload Templates", fg="blue", cursor="hand2"
534 self.underline(self.preload_label)
535 self.preload_label.bind("<Button-1>", self.open_preloads)
537 self.result_panel.grid(row=7, column=1, columnspan=2)
538 control_panel.pack(fill=BOTH, expand=1)
540 main_window.add(control_panel)
542 self.log_panel = ScrolledText(main_window, wrap=WORD, width=120, height=20)
543 self.log_panel.configure(font=font.Font(family="Courier New", size="11"))
544 self.log_panel.pack(fill=BOTH, expand=1)
546 main_window.add(self.log_panel)
548 # Briefly add the completion and result labels so the window size includes
550 self.completion_label.pack()
551 self.result_label.pack() # Show report link
552 self.preload_label.pack() # Show preload link
553 self._root.after_idle(
555 self.completion_label.pack_forget(),
556 self.result_label.pack_forget(),
557 self.preload_label.pack_forget(),
565 self.halt_on_failure,
567 if self.config.preload_formats:
568 self.config.watch(self.preload_format)
569 self.schedule(self.execute_pollers)
570 if self.config.terms_link_text and not self.config.are_terms_accepted:
571 TermsAndConditionsDialog(parent_frame, self.config)
572 if not self.config.are_terms_accepted:
575 def create_footer(self, parent_frame):
576 footer = Frame(parent_frame)
577 disclaimer = Message(footer, text=self.config.disclaimer_text, anchor=CENTER)
578 disclaimer.grid(row=0, pady=2)
580 "<Configure>", lambda e: disclaimer.configure(width=e.width - 20)
582 if self.config.requirement_link_text:
583 requirement_link = Text(
586 bg=disclaimer.cget("bg"),
588 font=disclaimer.cget("font"),
590 requirement_link.tag_configure("center", justify="center")
591 hyperlinks = HyperlinkManager(requirement_link)
592 requirement_link.insert(INSERT, "Validating: ")
593 requirement_link.insert(
595 self.config.requirement_link_text,
596 hyperlinks.add(self.open_requirements),
598 requirement_link.tag_add("center", "1.0", "end")
599 requirement_link.config(state=DISABLED)
600 requirement_link.grid(row=1, pady=2)
601 ToolTip(requirement_link, self.config.requirement_link_url)
602 footer.grid_columnconfigure(0, weight=1)
603 footer.pack(fill=BOTH, expand=True)
606 def set_env_dir_state(self):
607 state = NORMAL if self.create_preloads.get() else DISABLED
608 self.env_dir_entry.config(state=state)
610 def ask_template_source(self):
611 if self.input_format.get() == "ZIP File":
612 template_source = filedialog.askopenfilename(
613 title="Select Archive",
614 filetypes=(("ZIP Files", "*.zip"), ("All Files", "*")),
617 template_source = filedialog.askdirectory()
618 self.template_source.set(template_source)
620 def ask_env_dir_source(self):
621 self.env_dir.set(filedialog.askdirectory())
624 """Run the pytest validations in a background process"""
625 if not self.delete_prior_report():
628 if not self.template_source.get():
629 self.ask_template_source()
631 template_dir = self.resolve_template_dir()
634 self.kill_background_task()
636 self.completion_label.pack_forget()
637 self.result_label.pack_forget()
638 self.preload_label.pack_forget()
639 self.task = multiprocessing.Process(
643 self.config.log_file,
644 self.config.status_queue,
645 self.categories_list(),
646 self.report_format.get().lower(),
647 self.halt_on_failure.get(),
648 self.template_source.get(),
650 self.preload_format.get(),
653 self.task.daemon = True
658 """Returns the text displayed in the title bar of the application"""
659 return self._root.title()
661 def execute_pollers(self):
662 """Call all methods that require periodic execution, and re-schedule
663 their execution for the next polling interval"""
666 self.poll_status_queue()
667 self.poll_command_queue()
669 self.schedule(self.execute_pollers)
673 """Yields values from the queue until empty"""
676 yield q.get(block=False)
680 def poll_command_queue(self):
681 """Picks up command strings from the commmand queue, and
682 dispatches it for execution. Only SHUTDOWN is supported
684 for command in self._drain_queue(self.config.command_queue):
685 if command == "SHUTDOWN":
688 def poll_status_queue(self):
689 """Checks for completion of the job, and then displays the View Report link
690 if it was successful or writes the exception to the ``log_panel`` if
692 for is_success, e in self._drain_queue(self.config.status_queue):
694 self.completion_label.pack()
695 self.result_label.pack() # Show report link
696 if hasattr(self, "preload_format"):
697 self.preload_label.pack() # Show preload link
699 self.log_panel.insert(END, str(e))
701 def poll_log_file(self):
702 """Reads captured stdout and stderr from the log queue and writes it to the
704 for line in self._drain_queue(self.config.log_queue):
705 self.log_panel.insert(END, line)
706 self.log_panel.see(END)
708 def schedule(self, func: Callable):
709 """Schedule the callable ``func`` to be executed according to
710 the polling_frequency"""
711 self._root.after(self.config.polling_frequency, func)
714 """Removes all log entries from teh log panel"""
715 self.log_panel.delete("1.0", END)
717 def delete_prior_report(self) -> bool:
718 """Attempts to delete the current report, and pops up a warning message
719 to the user if it can't be deleted. This will force the user to
720 close the report before re-running the validation. Returns True if
721 the file was deleted or did not exist, or False otherwise"""
722 if not os.path.exists(self.report_file_path):
726 os.remove(self.report_file_path)
729 messagebox.showerror(
731 "Please close or rename the open report file before re-validating",
736 def report_file_path(self):
737 ext_mapping = {"csv": "csv", "html": "html", "excel": "xlsx"}
738 ext = ext_mapping.get(self.report_format.get().lower())
739 return os.path.join(PATH, OUT_DIR, "report.{}".format(ext))
741 # noinspection PyUnusedLocal
742 def open_report(self, event):
743 """Open the report in the user's default browser"""
744 path = Path(self.report_file_path).absolute().resolve().as_uri()
745 webbrowser.open_new(path)
747 def open_preloads(self, event):
748 """Open the report in the user's default browser"""
753 self.config.get_subdir_for_preload(self.preload_format.get()),
755 if platform.system() == "Windows":
756 os.startfile(path) # nosec
757 elif platform.system() == "Darwin":
758 subprocess.Popen(["open", path]) # nosec
760 subprocess.Popen(["xdg-open", path]) # nosec
762 def open_requirements(self):
763 """Open the report in the user's default browser"""
764 webbrowser.open_new(self.config.requirement_link_url)
767 """Start the event loop of the application. This method does not return"""
768 self._root.mainloop()
771 def underline(label):
772 """Apply underline format to an existing label"""
773 f = font.Font(label, label.cget("font"))
774 f.configure(underline=True)
775 label.configure(font=f)
777 def kill_background_task(self):
778 if self.task and self.task.is_alive():
779 self.task.terminate()
780 for _ in self._drain_queue(self.config.log_queue):
784 """Shutdown the application"""
785 self.kill_background_task()
788 def check_template_source_is_valid(self):
789 """Verifies the value of template source exists and of valid type based
791 if not self.template_source.get():
793 template_path = Path(self.template_source.get())
795 if not template_path.exists():
796 messagebox.showerror(
798 "Input does not exist. Please provide a valid file or directory.",
802 if self.input_format.get() == "ZIP File":
803 if zipfile.is_zipfile(template_path):
806 messagebox.showerror(
807 "Error", "Expected ZIP file, but input is not a valid ZIP file"
811 if template_path.is_dir():
814 messagebox.showerror(
815 "Error", "Expected directory, but input is not a directory"
819 def resolve_template_dir(self) -> str:
820 """Extracts the zip file to a temporary directory if needed, otherwise
821 returns the directory supplied to template source. Returns empty string
822 if the template source isn't valid"""
823 if not self.check_template_source_is_valid():
825 if self.input_format.get() == "ZIP File":
826 temp_dir = tempfile.mkdtemp()
827 archive = zipfile.ZipFile(self.template_source.get())
828 archive.extractall(path=temp_dir)
831 return self.template_source.get()
833 def categories_list(self) -> list:
835 selected_categories = self.categories
836 for x in range(0, len(selected_categories)):
837 if selected_categories[x].get():
838 category = self.config.category_names[x]
839 categories.append(self.config.get_category(category))
843 if __name__ == "__main__":
844 multiprocessing.freeze_support() # needed for PyInstaller to work
845 ValidatorApp().start()