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============================================
38 # ECOMP is a trademark and service mark of AT&T Intellectual Property.
42 A GUI that wraps the pytest validations scripts.
44 To make an executable for windows execute the ``make_exe.bat`` to generate the
45 .exe and its associated files. The the necessary files will be written to the
46 ``dist/vvp/`` directory. This entire directory must be copied to the target machine.
48 NOTE: This script does require Python 3.6+
57 import multiprocessing
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
93 from tkinter.scrolledtext import ScrolledText
94 from typing import Optional, List, Dict, TextIO, Callable, Iterator
96 VERSION = version.VERSION
97 PATH = os.path.dirname(os.path.realpath(__file__))
101 class ToolTip(object):
103 create a tooltip for a given widget
106 def __init__(self, widget, text="widget info"):
107 self.waittime = 750 # miliseconds
108 self.wraplength = 180 # pixels
111 self.widget.bind("<Enter>", self.enter)
112 self.widget.bind("<Leave>", self.leave)
113 self.widget.bind("<ButtonPress>", self.leave)
117 def enter(self, event=None):
120 def leave(self, event=None):
126 self.id = self.widget.after(self.waittime, self.showtip)
128 def unschedule(self):
132 self.widget.after_cancel(id)
134 def showtip(self, event=None):
136 x, y, cx, cy = self.widget.bbox("insert")
137 x += self.widget.winfo_rootx() + 25
138 y += self.widget.winfo_rooty() + 20
139 # creates a toplevel window
140 self.tw = Toplevel(self.widget)
141 # Leaves only the label and removes the app window
142 self.tw.wm_overrideredirect(True)
143 self.tw.wm_geometry("+%d+%d" % (x, y))
148 background="#ffffff",
151 wraplength=self.wraplength,
163 """``stdout`` and ``stderr`` will be written to this queue by pytest, and
164 pulled into the main GUI application"""
166 def __init__(self, log_queue: queue.Queue):
167 """Writes data to the provided queue.
169 :param log_queue: the queue instance to write to.
171 self.queue = log_queue
173 def write(self, data: str):
174 """Writes ``data`` to the queue """
177 # noinspection PyMethodMayBeStatic
178 def isatty(self) -> bool:
179 """Always returns ``False``"""
183 """No operation method to satisfy file-like behavior"""
187 def get_plugins() -> Optional[List]:
188 """When running in a frozen bundle, plugins need to be registered
189 explicitly. This method will return the required plugins to register
190 based on the run mode"""
191 if hasattr(sys, "frozen"):
192 import pytest_tap.plugin
194 return [pytest_tap.plugin]
203 categories: Optional[list],
206 halt_on_failure: bool,
207 template_source: str,
209 """Runs pytest using the given ``profile`` in a background process. All
210 ``stdout`` and ``stderr`` are redirected to ``log``. The result of the job
211 will be put on the ``completion_queue``
213 :param template_dir: The directory containing the files to be validated.
214 :param log: ` `stderr`` and ``stdout`` of the pytest job will be
216 :param result_queue: Completion status posted here. See :class:`Config`
217 for more information.
218 :param categories: list of optional categories. When provided, pytest
219 will collect and execute all tests that are
220 decorated with any of the passed categories, as
221 well as tests not decorated with a category.
222 :param verbosity: Flag to be passed to pytest to control verbosity.
223 Options are '' (empty string), '-v' (verbose),
224 '-vv' (more verbose).
225 :param report_format: Determines the style of report written. Options are
227 :param halt_on_failure: Determines if validation will halt when basic failures
228 are encountered in the input files. This can help
229 prevent a large number of errors from flooding the
232 out_path = "{}/{}".format(PATH, OUT_DIR)
233 if os.path.exists(out_path):
234 rmtree(out_path, ignore_errors=True)
235 with contextlib.redirect_stderr(log), contextlib.redirect_stdout(log):
238 "--ignore=app_tests",
241 "--template-directory={}".format(template_dir),
242 "--report-format={}".format(report_format),
243 "--template-source={}".format(template_source),
246 for category in categories:
247 args.extend(("--category", category))
248 if not halt_on_failure:
249 args.append("--continue-on-failure")
250 pytest.main(args=args, plugins=get_plugins())
251 result_queue.put((True, None))
252 except Exception as e:
253 result_queue.put((False, e))
256 class UserSettings(MutableMapping):
257 FILE_NAME = "UserSettings.ini"
260 user_config_dir = appdirs.AppDirs("org.onap.vvp", "ONAP").user_config_dir
261 if not os.path.exists(user_config_dir):
262 os.makedirs(user_config_dir, exist_ok=True)
263 self._settings_path = os.path.join(user_config_dir, self.FILE_NAME)
264 self._config = ConfigParser()
265 self._config.read(self._settings_path)
267 def __getitem__(self, k):
268 return self._config["DEFAULT"][k]
270 def __setitem__(self, k, v) -> None:
271 self._config["DEFAULT"][k] = v
273 def __delitem__(self, v) -> None:
274 del self._config["DEFAULT"][v]
276 def __len__(self) -> int:
277 return len(self._config["DEFAULT"])
279 def __iter__(self) -> Iterator:
280 return iter(self._config["DEFAULT"])
283 with open(self._settings_path, "w") as f:
284 self._config.write(f)
289 Configuration for the Validation GUI Application
293 ``log_queue`` Queue for the ``stdout`` and ``stderr` of
295 ``log_file`` File-like object (write only!) that writes to
297 ``status_queue`` Job completion status of the background job is
298 posted here as a tuple of (bool, Exception).
299 The first parameter is True if the job completed
300 successfully, and False otherwise. If the job
301 failed, then an Exception will be provided as the
303 ``command_queue`` Used to send commands to the GUI. Currently only
304 used to send shutdown commands in tests.
307 DEFAULT_FILENAME = "vvp-config.yaml"
308 DEFAULT_POLLING_FREQUENCY = "1000"
310 def __init__(self, config: dict = None):
311 """Creates instance of application configuration.
313 :param config: override default configuration if provided."""
315 self._config = config
317 with open(self.DEFAULT_FILENAME, "r") as f:
318 self._config = yaml.load(f)
319 self._user_settings = UserSettings()
320 self._watched_variables = []
322 self._manager = multiprocessing.Manager()
323 self.log_queue = self._manager.Queue()
324 self.status_queue = self._manager.Queue()
325 self.log_file = QueueWriter(self.log_queue)
326 self.command_queue = self._manager.Queue()
328 def watch(self, *variables):
329 """Traces the variables and saves their settings for the user. The
330 last settings will be used where available"""
331 self._watched_variables = variables
332 for var in self._watched_variables:
333 var.trace_add("write", self.save_settings)
335 def save_settings(self, *args):
336 """Save the value of all watched variables to user settings"""
337 for var in self._watched_variables:
338 self._user_settings[var._name] = str(var.get())
339 self._user_settings.save()
342 def app_name(self) -> str:
343 """Name of the application (displayed in title bar)"""
344 app_name = self._config["ui"].get("app-name", "VNF Validation Tool")
345 return "{} - {}".format(app_name, VERSION)
348 def category_names(self) -> List[str]:
349 """List of validation profile names for display in the UI"""
350 return [category["name"] for category in self._config["categories"]]
353 def polling_frequency(self) -> int:
354 """Returns the frequency (in ms) the UI polls the queue communicating
355 with any background job"""
357 self._config["settings"].get(
358 "polling-frequency", self.DEFAULT_POLLING_FREQUENCY
362 def default_verbosity(self, levels: Dict[str, str]) -> str:
363 requested_level = self._user_settings.get("verbosity") or self._config[
365 ].get("default-verbosity", "Standard")
366 keys = [key for key in levels]
368 if key.lower().startswith(requested_level.lower()):
371 "Invalid default-verbosity level {}. Valid"
372 "values are {}".format(requested_level, ", ".join(keys))
375 def get_description(self, category_name: str) -> str:
376 """Returns the description associated with the category name"""
377 return self._get_category(category_name)["description"]
379 def get_category(self, category_name: str) -> str:
380 """Returns the category associated with the category name"""
381 return self._get_category(category_name).get("category", "")
383 def get_category_value(self, category_name: str) -> str:
384 """Returns the saved value for a category name"""
385 return self._user_settings.get(category_name, 0)
387 def _get_category(self, category_name: str) -> Dict[str, str]:
388 """Returns the profile definition"""
389 for category in self._config["categories"]:
390 if category["name"] == category_name:
393 "Unexpected error: No category found in vvp-config.yaml "
394 "with a name of " + category_name
398 def default_report_format(self):
399 return self._user_settings.get("report_format", "HTML")
402 def report_formats(self):
403 return ["CSV", "Excel", "HTML"]
406 def default_input_format(self):
407 requested_default = self._user_settings.get("input_format") or self._config[
409 ].get("default-input-format")
410 if requested_default in self.input_formats:
411 return requested_default
413 return self.input_formats[0]
416 def input_formats(self):
417 return ["Directory (Uncompressed)", "ZIP File"]
420 def default_halt_on_failure(self):
421 setting = self._user_settings.get("halt_on_failure", "True")
422 return setting.lower() == "true"
425 """Ensures the config file is properly formatted"""
426 categories = self._config["categories"]
428 # All profiles have required keys
429 expected_keys = {"name", "description"}
430 for category in categories:
431 actual_keys = set(category.keys())
432 missing_keys = expected_keys.difference(actual_keys)
435 "Error in vvp-config.yaml file: "
436 "Required field missing in category. "
438 "Categories: {}".format(",".join(missing_keys), category)
443 VERBOSITY_LEVELS = {"Less": "", "Standard (-v)": "-v", "More (-vv)": "-vv"}
445 def __init__(self, config: Config = None):
446 """Constructs the GUI element of the Validation Tool"""
448 self.config = config or Config()
451 self._root.title(self.config.app_name)
452 self._root.protocol("WM_DELETE_WINDOW", self.shutdown)
454 main_window = PanedWindow(self._root)
455 main_window.pack(fill=BOTH, expand=1)
457 control_panel = PanedWindow(
458 main_window, orient=HORIZONTAL, sashpad=4, sashrelief=RAISED
460 actions = Frame(control_panel)
461 control_panel.add(actions)
462 control_panel.paneconfigure(actions, minsize=250)
465 number_of_categories = len(self.config.category_names)
466 category_frame = LabelFrame(actions, text="Additional Validation Categories:")
467 category_frame.grid(row=1, column=1, columnspan=3, pady=5, sticky="we")
471 for x in range(0, number_of_categories):
472 category_name = self.config.category_names[x]
473 category_value = IntVar(value=0)
474 category_value._name = "category_{}".format(category_name.replace(" ", "_"))
475 category_value.set(self.config.get_category_value(category_value._name))
476 self.categories.append(category_value)
477 category_checkbox = Checkbutton(
478 category_frame, text=category_name, variable=self.categories[x]
480 ToolTip(category_checkbox, self.config.get_description(category_name))
481 category_checkbox.grid(row=x + 1, column=1, columnspan=2, sticky="w")
483 settings_frame = LabelFrame(actions, text="Settings")
484 settings_frame.grid(row=3, column=1, columnspan=3, pady=10, sticky="we")
485 verbosity_label = Label(settings_frame, text="Verbosity:")
486 verbosity_label.grid(row=1, column=1, sticky=W)
487 self.verbosity = StringVar(self._root, name="verbosity")
488 self.verbosity.set(self.config.default_verbosity(self.VERBOSITY_LEVELS))
489 verbosity_menu = OptionMenu(
490 settings_frame, self.verbosity, *tuple(self.VERBOSITY_LEVELS.keys())
492 verbosity_menu.config(width=25)
493 verbosity_menu.grid(row=1, column=2, columnspan=3, sticky=E, pady=5)
495 report_format_label = Label(settings_frame, text="Report Format:")
496 report_format_label.grid(row=2, column=1, sticky=W)
497 self.report_format = StringVar(self._root, name="report_format")
498 self.report_format.set(self.config.default_report_format)
499 report_format_menu = OptionMenu(
500 settings_frame, self.report_format, *self.config.report_formats
502 report_format_menu.config(width=25)
503 report_format_menu.grid(row=2, column=2, columnspan=3, sticky=E, pady=5)
505 input_format_label = Label(settings_frame, text="Input Format:")
506 input_format_label.grid(row=3, column=1, sticky=W)
507 self.input_format = StringVar(self._root, name="input_format")
508 self.input_format.set(self.config.default_input_format)
509 input_format_menu = OptionMenu(
510 settings_frame, self.input_format, *self.config.input_formats
512 input_format_menu.config(width=25)
513 input_format_menu.grid(row=3, column=2, columnspan=3, sticky=E, pady=5)
515 self.halt_on_failure = BooleanVar(self._root, name="halt_on_failure")
516 self.halt_on_failure.set(self.config.default_halt_on_failure)
517 halt_on_failure_label = Label(settings_frame, text="Halt on Basic Failures:")
518 halt_on_failure_label.grid(row=4, column=1, sticky=E, pady=5)
519 halt_checkbox = Checkbutton(
520 settings_frame, offvalue=False, onvalue=True, variable=self.halt_on_failure
522 halt_checkbox.grid(row=4, column=2, columnspan=2, sticky=W, pady=5)
524 directory_label = Label(actions, text="Template Location:")
525 directory_label.grid(row=4, column=1, pady=5, sticky=W)
526 self.template_source = StringVar(self._root, name="template_source")
527 directory_entry = Entry(actions, width=40, textvariable=self.template_source)
528 directory_entry.grid(row=4, column=2, pady=5, sticky=W)
529 directory_browse = Button(actions, text="...", command=self.ask_template_source)
530 directory_browse.grid(row=4, column=3, pady=5, sticky=W)
532 validate = Button(actions, text="Validate Templates", command=self.validate)
533 validate.grid(row=5, column=1, columnspan=2, pady=5)
535 self.result_panel = Frame(actions)
536 # We'll add these labels now, and then make them visible when the run completes
537 self.completion_label = Label(self.result_panel, text="Validation Complete!")
538 self.result_label = Label(
539 self.result_panel, text="View Report", fg="blue", cursor="hand2"
541 self.underline(self.result_label)
542 self.result_label.bind("<Button-1>", self.open_report)
543 self.result_panel.grid(row=6, column=1, columnspan=2)
544 control_panel.pack(fill=BOTH, expand=1)
546 main_window.add(control_panel)
548 self.log_panel = ScrolledText(main_window, wrap=WORD, width=120, height=20)
549 self.log_panel.configure(font=font.Font(family="Courier New", size="11"))
550 self.log_panel.pack(fill=BOTH, expand=1)
552 main_window.add(self.log_panel)
554 # Briefly add the completion and result labels so the window size includes
556 self.completion_label.pack()
557 self.result_label.pack() # Show report link
558 self._root.after_idle(
560 self.completion_label.pack_forget(),
561 self.result_label.pack_forget(),
570 self.halt_on_failure,
572 self.schedule(self.execute_pollers)
574 def ask_template_source(self):
575 if self.input_format.get() == "ZIP File":
576 template_source = filedialog.askopenfilename(
577 title="Select Archive",
578 filetypes=(("ZIP Files", "*.zip"), ("All Files", "*")),
581 template_source = filedialog.askdirectory()
582 self.template_source.set(template_source)
585 """Run the pytest validations in a background process"""
586 if not self.delete_prior_report():
589 if not self.template_source.get():
590 self.ask_template_source()
592 template_dir = self.resolve_template_dir()
595 self.kill_background_task()
597 self.completion_label.pack_forget()
598 self.result_label.pack_forget()
599 self.task = multiprocessing.Process(
603 self.config.log_file,
604 self.config.status_queue,
605 self.categories_list(),
606 self.VERBOSITY_LEVELS[self.verbosity.get()],
607 self.report_format.get().lower(),
608 self.halt_on_failure.get(),
609 self.template_source.get(),
612 self.task.daemon = True
617 """Returns the text displayed in the title bar of the application"""
618 return self._root.title()
620 def execute_pollers(self):
621 """Call all methods that require periodic execution, and re-schedule
622 their execution for the next polling interval"""
625 self.poll_status_queue()
626 self.poll_command_queue()
628 self.schedule(self.execute_pollers)
632 """Yields values from the queue until empty"""
635 yield q.get(block=False)
639 def poll_command_queue(self):
640 """Picks up command strings from the commmand queue, and
641 dispatches it for execution. Only SHUTDOWN is supported
643 for command in self._drain_queue(self.config.command_queue):
644 if command == "SHUTDOWN":
647 def poll_status_queue(self):
648 """Checks for completion of the job, and then displays the View Report link
649 if it was successful or writes the exception to the ``log_panel`` if
651 for is_success, e in self._drain_queue(self.config.status_queue):
653 self.completion_label.pack()
654 self.result_label.pack() # Show report link
656 self.log_panel.insert(END, str(e))
658 def poll_log_file(self):
659 """Reads captured stdout and stderr from the log queue and writes it to the
661 for line in self._drain_queue(self.config.log_queue):
662 self.log_panel.insert(END, line)
663 self.log_panel.see(END)
665 def schedule(self, func: Callable):
666 """Schedule the callable ``func`` to be executed according to
667 the polling_frequency"""
668 self._root.after(self.config.polling_frequency, func)
671 """Removes all log entries from teh log panel"""
672 self.log_panel.delete("1.0", END)
674 def delete_prior_report(self) -> bool:
675 """Attempts to delete the current report, and pops up a warning message
676 to the user if it can't be deleted. This will force the user to
677 close the report before re-running the validation. Returns True if
678 the file was deleted or did not exist, or False otherwise"""
679 if not os.path.exists(self.report_file_path):
683 os.remove(self.report_file_path)
686 messagebox.showerror(
688 "Please close or rename the open report file before re-validating",
693 def report_file_path(self):
694 ext_mapping = {"csv": "csv", "html": "html", "excel": "xlsx"}
695 ext = ext_mapping.get(self.report_format.get().lower())
696 return os.path.join(PATH, OUT_DIR, "report.{}".format(ext))
698 def open_report(self, event):
699 """Open the report in the user's default browser"""
700 webbrowser.open_new("file://{}".format(self.report_file_path))
703 """Start the event loop of the application. This method does not return"""
704 self._root.mainloop()
707 def underline(label):
708 """Apply underline format to an existing label"""
709 f = font.Font(label, label.cget("font"))
710 f.configure(underline=True)
711 label.configure(font=f)
713 def kill_background_task(self):
714 if self.task and self.task.is_alive():
715 self.task.terminate()
716 for _ in self._drain_queue(self.config.log_queue):
720 """Shutdown the application"""
721 self.kill_background_task()
724 def check_template_source_is_valid(self):
725 """Verifies the value of template source exists and of valid type based
727 if not self.template_source.get():
729 template_path = Path(self.template_source.get())
731 if not template_path.exists():
732 messagebox.showerror(
734 "Input does not exist. Please provide a valid file or directory.",
738 if self.input_format.get() == "ZIP File":
739 if zipfile.is_zipfile(template_path):
742 messagebox.showerror(
743 "Error", "Expected ZIP file, but input is not a valid ZIP file"
747 if template_path.is_dir():
750 messagebox.showerror(
751 "Error", "Expected directory, but input is not a directory"
755 def resolve_template_dir(self) -> str:
756 """Extracts the zip file to a temporary directory if needed, otherwise
757 returns the directory supplied to template source. Returns empty string
758 if the template source isn't valid"""
759 if not self.check_template_source_is_valid():
761 if self.input_format.get() == "ZIP File":
762 temp_dir = tempfile.mkdtemp()
763 archive = zipfile.ZipFile(self.template_source.get())
764 archive.extractall(path=temp_dir)
767 return self.template_source.get()
769 def categories_list(self) -> list:
771 selected_categories = self.categories
772 for x in range(0, len(selected_categories)):
773 if selected_categories[x].get():
774 category = self.config.category_names[x]
775 categories.append(self.config.get_category(category))
779 if __name__ == "__main__":
780 multiprocessing.freeze_support() # needed for PyInstaller to work
781 ValidatorApp().start()