1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367
|
# (C) Copyright 2004-2023 Enthought, Inc., Austin, TX
# All rights reserved.
#
# This software is provided without warranty under the terms of the BSD
# license included in LICENSE.txt and may be redistributed only under
# the conditions described in the aforementioned license. The license
# is also available online at http://www.enthought.com/licenses/BSD.txt
#
# Thanks for using Enthought open source!
""" Tests for demo and tutorial examples.
"""
import contextlib
import io
import os
import sys
import traceback
import unittest
from unittest import mock
import pkg_resources
from traits.api import HasTraits
from traitsui.tests._tools import (
BaseTestMixin,
is_qt,
is_qt5,
is_qt6,
is_wx,
process_cascade_events,
requires_toolkit,
ToolkitName,
)
from traitsui.testing.api import UITester
# This test file is not distributed nor is it in a package.
HERE = os.path.dirname(__file__)
class ExampleSearcher:
"""This object collects and reports example files to be tested."""
def __init__(self, source_dirs):
"""
Parameters
----------
source_dirs : list of str
List of directory paths from which Python files will be collected.
"""
self.source_dirs = source_dirs
self.files_may_be_skipped = {}
def skip_file_if(self, filepath, condition, reason):
"""Mark a file to be skipped for a given condition.
Parameters
----------
filepath : str
Path of the file which may be skipped from tests.
condition: callable() -> bool
The condition for skipping a file.
reason : str
Reason for skipping the file.
"""
filepath = os.path.abspath(filepath)
self.files_may_be_skipped[filepath] = (condition, reason)
def is_skipped(self, filepath):
"""Return if the Python file should be skipped in test.
Parameters
----------
path : str
Path to a file.
Returns
-------
skipped : bool
True if the file should be skipped.
reason : str
Reason why it should be skipped.
"""
path = os.path.abspath(filepath)
if path not in self.files_may_be_skipped:
return False, ""
condition, reason = self.files_may_be_skipped[path]
return condition(), reason
def validate(self):
"""Validate configuration. Currently this checks all files that may
be skipped still exist.
"""
for filepath in self.files_may_be_skipped:
if not os.path.exists(filepath):
raise RuntimeError("{} does not exist.".format(filepath))
@staticmethod
def _is_python_file(path):
"""Return true if the given path is (public) non-test Python file."""
_, basename = os.path.split(path)
_, ext = os.path.splitext(basename)
return (
ext == ".py"
and not basename.startswith("_")
and not basename.startswith("test_")
)
def get_python_files(self):
"""Report Python files to be tested or to be skipped.
Returns
-------
accepted_files : list of str
Python file paths to be tested.
skipped_files : (filepath: str, reason: str)
Skipped files. First item is the file path, second
item is the reason why it is skipped.
"""
accepted_files = []
skipped_files = []
for source_dir in self.source_dirs:
for root, _, files in os.walk(source_dir):
for filename in files:
path = os.path.abspath(os.path.join(root, filename))
if not self._is_python_file(path):
continue
skipped, reason = self.is_skipped(path)
if skipped:
skipped_files.append((path, reason))
else:
accepted_files.append(path)
# Avoid arbitrary ordering from the OS
return sorted(accepted_files), sorted(skipped_files)
# =============================================================================
# Configuration
# =============================================================================
# Tutorial files are not part of the package data
TUTORIALS = os.path.join(
HERE,
"..",
"examples",
"tutorials",
"doc_examples",
"examples",
)
# Demo files are part of the package data.
DEMO = pkg_resources.resource_filename("traitsui", "examples/demo")
#: Explicitly include folders from which example files should be found
#: recursively.
SOURCE_DIRS = [
DEMO,
TUTORIALS,
]
SEARCHER = ExampleSearcher(source_dirs=SOURCE_DIRS)
SEARCHER.skip_file_if(
os.path.join(DEMO, "Advanced", "HDF5_tree_demo.py"),
lambda: sys.platform == "darwin",
"This example depends on PyTables which may be built to require CPUs with "
"a specific AVX version that is not supported on a paricular OSX host.",
)
SEARCHER.skip_file_if(
os.path.join(DEMO, "Advanced", "Table_editor_with_progress_column.py"),
is_wx,
"ProgressRenderer is not implemented in wx.",
)
SEARCHER.skip_file_if(
os.path.join(DEMO, "Advanced", "Scrubber_editor_demo.py"),
is_qt,
"ScrubberEditor is not implemented in qt.",
)
SEARCHER.skip_file_if(
os.path.join(DEMO, "Extras", "animated_GIF.py"),
lambda: not is_wx(),
"Only support wx",
)
SEARCHER.skip_file_if(
os.path.join(DEMO, "Extras", "Tree_editor_with_TreeNodeRenderer.py"),
lambda: not is_qt(),
"Only support Qt",
)
SEARCHER.skip_file_if(
os.path.join(DEMO, "Extras", "windows", "flash.py"),
lambda: not is_wx(),
"Only support wx",
)
SEARCHER.skip_file_if(
os.path.join(DEMO, "Extras", "windows", "internet_explorer.py"),
lambda: not is_wx(),
"Only support wx",
)
SEARCHER.skip_file_if(
os.path.join(TUTORIALS, "view_multi_object.py"),
lambda: True,
"Require wx and is blocking.",
)
SEARCHER.skip_file_if(
os.path.join(TUTORIALS, "view_standalone.py"),
lambda: True,
"Require wx and is blocking.",
)
# Validate configuration.
SEARCHER.validate()
# =============================================================================
# Test run utility functions
# =============================================================================
def replaced_configure_traits(
instance,
filename=None,
view=None,
kind=None,
edit=True,
context=None,
handler=None,
id="",
scrollable=None,
**args,
):
"""Mocked configure_traits to launch then close the GUI."""
ui_kwargs = dict(
view=view,
parent=None,
kind="live", # other options may block the test
context=context,
handler=handler,
id=id,
scrollable=scrollable,
**args,
)
with UITester().create_ui(instance, ui_kwargs):
pass
@contextlib.contextmanager
def replace_configure_traits():
"""Context manager to temporarily replace HasTraits.configure_traits
with a mocked version such that GUI launched are closed soon after they
are open.
"""
original_func = HasTraits.configure_traits
HasTraits.configure_traits = replaced_configure_traits
try:
yield
finally:
HasTraits.configure_traits = original_func
def run_file(file_path):
"""Execute a given Python file.
Parameters
----------
file_path : str
File path to be tested.
"""
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
globals = {
"__name__": "__main__",
"__file__": file_path,
}
with replace_configure_traits(), mock.patch(
"sys.stdout", new_callable=io.StringIO
), mock.patch("sys.argv", [file_path]):
# Mock stdout: Examples typically print educational information.
# They are expected but they should not pollute test output.
# Mock argv: Some example reads sys.argv to allow more arguments
# But all examples should support being run without additional
# arguments.
exec(content, globals)
# =============================================================================
# load_tests protocol for unittest discover
# =============================================================================
def load_tests(loader, tests, pattern):
"""Implement load_tests protocol so that when unittest discover is run
with this test module, the tests in the demo folder (not a package) are
also loaded.
See unittest documentation on load_tests
"""
# Keep all the other loaded tests.
suite = unittest.TestSuite()
suite.addTests(tests)
# Expand the test suite with tests from the examples, assuming
# the test for ``group/script.py`` is placed in ``group/tests/`` directory.
accepted_files, _ = SEARCHER.get_python_files()
test_dirs = set(
os.path.join(os.path.dirname(path), "tests") for path in accepted_files
)
test_dirs = set(path for path in test_dirs if os.path.exists(path))
for dirpath in sorted(test_dirs):
# Test files are scripts too and they demonstrate running the
# tests. Mock the run side-effect when we load the test cases.
with mock.patch.object(unittest.TextTestRunner, "run"):
test_suite = unittest.TestLoader().discover(
dirpath, pattern=pattern
)
if is_qt() or is_wx():
suite.addTests(test_suite)
return suite
# =============================================================================
# Test cases
# =============================================================================
@requires_toolkit([ToolkitName.qt, ToolkitName.wx])
class TestExample(BaseTestMixin, unittest.TestCase):
def setUp(self):
BaseTestMixin.setUp(self)
def tearDown(self):
BaseTestMixin.tearDown(self)
def test_run(self):
accepted_files, skipped_files = SEARCHER.get_python_files()
for file_path in accepted_files:
with self.subTest(file_path=file_path):
try:
run_file(file_path)
except Exception as exc:
message = "".join(
traceback.format_exception(*sys.exc_info())
)
self.fail(
"Executing {} failed with exception {}\n {}".format(
file_path, exc, message
)
)
finally:
# Whatever failure, always flush the GUI event queue
# before running the next one.
process_cascade_events()
# Report skipped files
for file_path, reason in skipped_files:
with self.subTest(file_path=file_path):
# make up for unittest not reporting the parameter in skip
# message.
raise unittest.SkipTest(
"{reason} (File: {file_path})".format(
reason=reason, file_path=file_path
)
)
|