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(self.config.default_create_preloads)
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 env_dir_state = NORMAL if self.create_preloads.get() else DISABLED
511 self.env_dir_entry = Entry(
512 actions, width=40, textvariable=self.env_dir, state=env_dir_state
514 self.env_dir_entry.grid(row=5, column=2, pady=5, sticky=W)
515 env_dir_browse = Button(actions, text="...", command=self.ask_env_dir_source)
516 env_dir_browse.grid(row=5, column=3, pady=5, sticky=W)
518 validate_button = Button(
519 actions, text="Process Templates", command=self.validate
521 validate_button.grid(row=6, column=1, columnspan=2, pady=5)
523 self.result_panel = Frame(actions)
524 # We'll add these labels now, and then make them visible when the run completes
525 self.completion_label = Label(self.result_panel, text="Validation Complete!")
526 self.result_label = Label(
527 self.result_panel, text="View Report", fg="blue", cursor="hand2"
529 self.underline(self.result_label)
530 self.result_label.bind("<Button-1>", self.open_report)
532 self.preload_label = Label(
533 self.result_panel, text="View Preload Templates", fg="blue", cursor="hand2"
535 self.underline(self.preload_label)
536 self.preload_label.bind("<Button-1>", self.open_preloads)
538 self.result_panel.grid(row=7, column=1, columnspan=2)
539 control_panel.pack(fill=BOTH, expand=1)
541 main_window.add(control_panel)
543 self.log_panel = ScrolledText(main_window, wrap=WORD, width=120, height=20)
544 self.log_panel.configure(font=font.Font(family="Courier New", size="11"))
545 self.log_panel.pack(fill=BOTH, expand=1)
547 main_window.add(self.log_panel)
549 # Briefly add the completion and result labels so the window size includes
551 self.completion_label.pack()
552 self.result_label.pack() # Show report link
553 self.preload_label.pack() # Show preload link
554 self._root.after_idle(
556 self.completion_label.pack_forget(),
557 self.result_label.pack_forget(),
558 self.preload_label.pack_forget(),
566 self.halt_on_failure,
568 self.create_preloads,
570 self.schedule(self.execute_pollers)
571 if self.config.terms_link_text and not self.config.are_terms_accepted:
572 TermsAndConditionsDialog(parent_frame, self.config)
573 if not self.config.are_terms_accepted:
576 def create_footer(self, parent_frame):
577 footer = Frame(parent_frame)
578 disclaimer = Message(footer, text=self.config.disclaimer_text, anchor=CENTER)
579 disclaimer.grid(row=0, pady=2)
581 "<Configure>", lambda e: disclaimer.configure(width=e.width - 20)
583 if self.config.requirement_link_text:
584 requirement_link = Text(
587 bg=disclaimer.cget("bg"),
589 font=disclaimer.cget("font"),
591 requirement_link.tag_configure("center", justify="center")
592 hyperlinks = HyperlinkManager(requirement_link)
593 requirement_link.insert(INSERT, "Validating: ")
594 requirement_link.insert(
596 self.config.requirement_link_text,
597 hyperlinks.add(self.open_requirements),
599 requirement_link.tag_add("center", "1.0", "end")
600 requirement_link.config(state=DISABLED)
601 requirement_link.grid(row=1, pady=2)
602 ToolTip(requirement_link, self.config.requirement_link_url)
603 footer.grid_columnconfigure(0, weight=1)
604 footer.pack(fill=BOTH, expand=True)
607 def set_env_dir_state(self):
608 state = NORMAL if self.create_preloads.get() else DISABLED
609 self.env_dir_entry.config(state=state)
611 def ask_template_source(self):
612 if self.input_format.get() == "ZIP File":
613 template_source = filedialog.askopenfilename(
614 title="Select Archive",
615 filetypes=(("ZIP Files", "*.zip"), ("All Files", "*")),
618 template_source = filedialog.askdirectory()
619 self.template_source.set(template_source)
621 def ask_env_dir_source(self):
622 self.env_dir.set(filedialog.askdirectory())
625 """Run the pytest validations in a background process"""
626 if not self.delete_prior_report():
629 if not self.template_source.get():
630 self.ask_template_source()
632 template_dir = self.resolve_template_dir()
635 self.kill_background_task()
637 self.completion_label.pack_forget()
638 self.result_label.pack_forget()
639 self.preload_label.pack_forget()
640 self.task = multiprocessing.Process(
644 self.config.log_file,
645 self.config.status_queue,
646 self.categories_list(),
647 self.report_format.get().lower(),
648 self.halt_on_failure.get(),
649 self.template_source.get(),
651 self.preload_format.get(),
654 self.task.daemon = True
659 """Returns the text displayed in the title bar of the application"""
660 return self._root.title()
662 def execute_pollers(self):
663 """Call all methods that require periodic execution, and re-schedule
664 their execution for the next polling interval"""
667 self.poll_status_queue()
668 self.poll_command_queue()
670 self.schedule(self.execute_pollers)
674 """Yields values from the queue until empty"""
677 yield q.get(block=False)
681 def poll_command_queue(self):
682 """Picks up command strings from the commmand queue, and
683 dispatches it for execution. Only SHUTDOWN is supported
685 for command in self._drain_queue(self.config.command_queue):
686 if command == "SHUTDOWN":
689 def poll_status_queue(self):
690 """Checks for completion of the job, and then displays the View Report link
691 if it was successful or writes the exception to the ``log_panel`` if
693 for is_success, e in self._drain_queue(self.config.status_queue):
695 self.completion_label.pack()
696 self.result_label.pack() # Show report link
697 if hasattr(self, "preload_format"):
698 self.preload_label.pack() # Show preload link
700 self.log_panel.insert(END, str(e))
702 def poll_log_file(self):
703 """Reads captured stdout and stderr from the log queue and writes it to the
705 for line in self._drain_queue(self.config.log_queue):
706 self.log_panel.insert(END, line)
707 self.log_panel.see(END)
709 def schedule(self, func: Callable):
710 """Schedule the callable ``func`` to be executed according to
711 the polling_frequency"""
712 self._root.after(self.config.polling_frequency, func)
715 """Removes all log entries from teh log panel"""
716 self.log_panel.delete("1.0", END)
718 def delete_prior_report(self) -> bool:
719 """Attempts to delete the current report, and pops up a warning message
720 to the user if it can't be deleted. This will force the user to
721 close the report before re-running the validation. Returns True if
722 the file was deleted or did not exist, or False otherwise"""
723 if not os.path.exists(self.report_file_path):
727 os.remove(self.report_file_path)
730 messagebox.showerror(
732 "Please close or rename the open report file before re-validating",
737 def report_file_path(self):
738 ext_mapping = {"csv": "csv", "html": "html", "excel": "xlsx"}
739 ext = ext_mapping.get(self.report_format.get().lower())
740 return os.path.join(PATH, OUT_DIR, "report.{}".format(ext))
742 # noinspection PyUnusedLocal
743 def open_report(self, event):
744 """Open the report in the user's default browser"""
745 path = Path(self.report_file_path).absolute().resolve().as_uri()
746 webbrowser.open_new(path)
748 def open_preloads(self, event):
749 """Open the report in the user's default browser"""
754 self.config.get_subdir_for_preload(self.preload_format.get()),
756 if platform.system() == "Windows":
757 os.startfile(path) # nosec
758 elif platform.system() == "Darwin":
759 subprocess.Popen(["open", path]) # nosec
761 subprocess.Popen(["xdg-open", path]) # nosec
763 def open_requirements(self):
764 """Open the report in the user's default browser"""
765 webbrowser.open_new(self.config.requirement_link_url)
768 """Start the event loop of the application. This method does not return"""
769 self._root.mainloop()
772 def underline(label):
773 """Apply underline format to an existing label"""
774 f = font.Font(label, label.cget("font"))
775 f.configure(underline=True)
776 label.configure(font=f)
778 def kill_background_task(self):
779 if self.task and self.task.is_alive():
780 self.task.terminate()
781 for _ in self._drain_queue(self.config.log_queue):
785 """Shutdown the application"""
786 self.kill_background_task()
789 def check_template_source_is_valid(self):
790 """Verifies the value of template source exists and of valid type based
792 if not self.template_source.get():
794 template_path = Path(self.template_source.get())
796 if not template_path.exists():
797 messagebox.showerror(
799 "Input does not exist. Please provide a valid file or directory.",
803 if self.input_format.get() == "ZIP File":
804 if zipfile.is_zipfile(template_path):
807 messagebox.showerror(
808 "Error", "Expected ZIP file, but input is not a valid ZIP file"
812 if template_path.is_dir():
815 messagebox.showerror(
816 "Error", "Expected directory, but input is not a directory"
820 def resolve_template_dir(self) -> str:
821 """Extracts the zip file to a temporary directory if needed, otherwise
822 returns the directory supplied to template source. Returns empty string
823 if the template source isn't valid"""
824 if not self.check_template_source_is_valid():
826 if self.input_format.get() == "ZIP File":
827 temp_dir = tempfile.mkdtemp()
828 archive = zipfile.ZipFile(self.template_source.get())
829 archive.extractall(path=temp_dir)
832 return self.template_source.get()
834 def categories_list(self) -> list:
836 selected_categories = self.categories
837 for x in range(0, len(selected_categories)):
838 if selected_categories[x].get():
839 category = self.config.category_names[x]
840 categories.append(self.config.get_category(category))
844 if __name__ == "__main__":
845 multiprocessing.freeze_support() # needed for PyInstaller to work
846 ValidatorApp().start()