diff --git a/python-stdlib/unittest-discover/manifest.py b/python-stdlib/unittest-discover/manifest.py new file mode 100644 index 00000000..28874c7e --- /dev/null +++ b/python-stdlib/unittest-discover/manifest.py @@ -0,0 +1,7 @@ +metadata(version="0.1.0") + +require("argparse") +require("fnmatch") +require("unittest") + +module("unittest_discover.py") diff --git a/python-stdlib/unittest-discover/tests/isolated.py b/python-stdlib/unittest-discover/tests/isolated.py new file mode 100644 index 00000000..21b905c1 --- /dev/null +++ b/python-stdlib/unittest-discover/tests/isolated.py @@ -0,0 +1,4 @@ +# Module that is used in both test_isolated_1.py and test_isolated_2.py. +# The module should be clean reloaded for each. + +state = None diff --git a/python-stdlib/unittest-discover/tests/test_isolated_1.py b/python-stdlib/unittest-discover/tests/test_isolated_1.py new file mode 100644 index 00000000..a2bd83b1 --- /dev/null +++ b/python-stdlib/unittest-discover/tests/test_isolated_1.py @@ -0,0 +1,8 @@ +import unittest +import isolated + + +class TestUnittestIsolated1(unittest.TestCase): + def test_NotChangedByOtherTest(self): + self.assertIsNone(isolated.state) + isolated.state = True diff --git a/python-stdlib/unittest-discover/tests/test_isolated_2.py b/python-stdlib/unittest-discover/tests/test_isolated_2.py new file mode 100644 index 00000000..1beb9961 --- /dev/null +++ b/python-stdlib/unittest-discover/tests/test_isolated_2.py @@ -0,0 +1,8 @@ +import unittest +import isolated + + +class TestUnittestIsolated2(unittest.TestCase): + def test_NotChangedByOtherTest(self): + self.assertIsNone(isolated.state) + isolated.state = True diff --git a/python-stdlib/unittest-discover/unittest_discover.py b/python-stdlib/unittest-discover/unittest_discover.py new file mode 100644 index 00000000..b57ccf39 --- /dev/null +++ b/python-stdlib/unittest-discover/unittest_discover.py @@ -0,0 +1,140 @@ +# Extension for "unittest" that adds the ability to run via "micropython -m unittest". + +import argparse +import os +import sys +from fnmatch import fnmatch +from micropython import const + +from unittest import TestRunner, TestResult, TestSuite + + +# Run a single test in a clean environment. +def _run_test_module(runner: TestRunner, module_name: str, *extra_paths: list[str]): + module_snapshot = {k: v for k, v in sys.modules.items()} + path_snapshot = sys.path[:] + try: + for path in reversed(extra_paths): + if path: + sys.path.insert(0, path) + + module = __import__(module_name) + suite = TestSuite(module_name) + suite._load_module(module) + return runner.run(suite) + finally: + sys.path[:] = path_snapshot + sys.modules.clear() + sys.modules.update(module_snapshot) + + +_DIR_TYPE = const(0x4000) + + +def _run_all_in_dir(runner: TestRunner, path: str, pattern: str, top: str): + result = TestResult() + for fname, ftype, *_ in os.ilistdir(path): + if fname in ("..", "."): + continue + if ftype == _DIR_TYPE: + result += _run_all_in_dir( + runner=runner, + path="/".join((path, fname)), + pattern=pattern, + top=top, + ) + if fnmatch(fname, pattern): + module_name = fname.rsplit(".", 1)[0] + result += _run_test_module(runner, module_name, path, top) + return result + + +# Implements discovery inspired by https://docs.python.org/3/library/unittest.html#test-discovery +def _discover(runner: TestRunner): + parser = argparse.ArgumentParser() + # parser.add_argument( + # "-v", + # "--verbose", + # action="store_true", + # help="Verbose output", + # ) + parser.add_argument( + "-s", + "--start-directory", + dest="start", + default=".", + help="Directory to start discovery", + ) + parser.add_argument( + "-p", + "--pattern ", + dest="pattern", + default="test*.py", + help="Pattern to match test files", + ) + parser.add_argument( + "-t", + "--top-level-directory", + dest="top", + help="Top level directory of project (defaults to start directory)", + ) + args = parser.parse_args(args=sys.argv[2:]) + + path = args.start + top = args.top or path + + return _run_all_in_dir( + runner=runner, + path=path, + pattern=args.pattern, + top=top, + ) + + +# TODO: Use os.path for path handling. +PATH_SEP = getattr(os, "sep", "/") + + +# foo/bar/x.y.z --> foo/bar, x.y +def _dirname_filename_no_ext(path): + # Workaround: The Windows port currently reports "/" for os.sep + # (and MicroPython doesn't have os.altsep). So for now just + # always work with os.sep (i.e. "/"). + path = path.replace("\\", PATH_SEP) + + split = path.rsplit(PATH_SEP, 1) + if len(split) == 1: + dirname, filename = "", split[0] + else: + dirname, filename = split + return dirname, filename.rsplit(".", 1)[0] + + +# This is called from unittest when __name__ == "__main__". +def discover_main(): + failures = 0 + runner = TestRunner() + + if len(sys.argv) == 1 or ( + len(sys.argv) >= 2 + and _dirname_filename_no_ext(sys.argv[0])[1] == "unittest" + and sys.argv[1] == "discover" + ): + # No args, or `python -m unittest discover ...`. + result = _discover(runner) + failures += result.failuresNum or result.errorsNum + else: + for test_spec in sys.argv[1:]: + try: + os.stat(test_spec) + # File exists, strip extension and import with its parent directory in sys.path. + dirname, module_name = _dirname_filename_no_ext(test_spec) + result = _run_test_module(runner, module_name, dirname) + except OSError: + # Not a file, treat as named module to import. + result = _run_test_module(runner, test_spec) + + failures += result.failuresNum or result.errorsNum + + # Terminate with non zero return code in case of failures. + sys.exit(failures) diff --git a/python-stdlib/unittest/manifest.py b/python-stdlib/unittest/manifest.py new file mode 100644 index 00000000..34c80075 --- /dev/null +++ b/python-stdlib/unittest/manifest.py @@ -0,0 +1,3 @@ +metadata(version="0.10.0") + +module("unittest.py") diff --git a/unix-ffi/unittest/test_unittest.py b/python-stdlib/unittest/tests/test_assertions.py similarity index 84% rename from unix-ffi/unittest/test_unittest.py rename to python-stdlib/unittest/tests/test_assertions.py index 8e108995..089a528a 100644 --- a/unix-ffi/unittest/test_unittest.py +++ b/python-stdlib/unittest/tests/test_assertions.py @@ -1,5 +1,4 @@ import unittest -from test_unittest_isolated import global_context class TestUnittestAssertions(unittest.TestCase): @@ -143,11 +142,6 @@ class TestUnittestAssertions(unittest.TestCase): else: self.fail("Unexpected success was not detected") - def test_NotChangedByOtherTest(self): - global global_context - assert global_context is None - global_context = True - def test_subtest_even(self): """ Test that numbers between 0 and 5 are all even. @@ -157,24 +151,5 @@ class TestUnittestAssertions(unittest.TestCase): self.assertEqual(i % 2, 0) -class TestUnittestSetup(unittest.TestCase): - class_setup_var = 0 - - def setUpClass(self): - TestUnittestSetup.class_setup_var += 1 - - def tearDownClass(self): - # Not sure how to actually test this, but we can check (in the test case below) - # that it hasn't been run already at least. - TestUnittestSetup.class_setup_var = -1 - - def testSetUpTearDownClass_1(self): - assert TestUnittestSetup.class_setup_var == 1, TestUnittestSetup.class_setup_var - - def testSetUpTearDownClass_2(self): - # Test this twice, as if setUpClass() gets run like setUp() it would be run twice - assert TestUnittestSetup.class_setup_var == 1, TestUnittestSetup.class_setup_var - - if __name__ == "__main__": unittest.main() diff --git a/python-stdlib/unittest/tests/test_basics.py b/python-stdlib/unittest/tests/test_basics.py new file mode 100644 index 00000000..70f0dd61 --- /dev/null +++ b/python-stdlib/unittest/tests/test_basics.py @@ -0,0 +1,29 @@ +import unittest + + +class TestWithRunTest(unittest.TestCase): + run = False + + def runTest(self): + TestWithRunTest.run = True + + def testRunTest(self): + self.fail() + + @staticmethod + def tearDownClass(): + if not TestWithRunTest.run: + raise ValueError() + + +def test_func(): + pass + + +@unittest.expectedFailure +def test_foo(): + raise ValueError() + + +if __name__ == "__main__": + unittest.main() diff --git a/python-stdlib/unittest/tests/test_setup.py b/python-stdlib/unittest/tests/test_setup.py new file mode 100644 index 00000000..43fdcece --- /dev/null +++ b/python-stdlib/unittest/tests/test_setup.py @@ -0,0 +1,28 @@ +import unittest + + +class TestUnittestSetup(unittest.TestCase): + class_setup_var = 0 + + @classmethod + def setUpClass(cls): + assert cls is TestUnittestSetup + TestUnittestSetup.class_setup_var += 1 + + @classmethod + def tearDownClass(cls): + assert cls is TestUnittestSetup + # Not sure how to actually test this, but we can check (in the test case below) + # that it hasn't been run already at least. + TestUnittestSetup.class_setup_var = -1 + + def testSetUpTearDownClass_1(self): + assert TestUnittestSetup.class_setup_var == 1, TestUnittestSetup.class_setup_var + + def testSetUpTearDownClass_2(self): + # Test this twice, as if setUpClass() gets run like setUp() it would be run twice + assert TestUnittestSetup.class_setup_var == 1, TestUnittestSetup.class_setup_var + + +if __name__ == "__main__": + unittest.main() diff --git a/unix-ffi/unittest/unittest.py b/python-stdlib/unittest/unittest.py similarity index 77% rename from unix-ffi/unittest/unittest.py rename to python-stdlib/unittest/unittest.py index b6168698..35ed14ac 100644 --- a/unix-ffi/unittest/unittest.py +++ b/python-stdlib/unittest/unittest.py @@ -1,22 +1,13 @@ +import io +import os import sys -import uos try: - import io import traceback except ImportError: - import uio as io - traceback = None -def _snapshot_modules(): - return {k: v for k, v in sys.modules.items()} - - -__modules__ = _snapshot_modules() - - class SkipTest(Exception): pass @@ -61,7 +52,7 @@ class SubtestContext: detail = ", ".join(f"{k}={v}" for k, v in self.params.items()) test_details += (f" ({detail})",) - handle_test_exception(test_details, __test_result__, exc_info, False) + _handle_test_exception(test_details, __test_result__, exc_info, False) # Suppress the exception as we've captured it above return True @@ -258,9 +249,17 @@ class TestSuite: def run(self, result): for c in self._tests: - run_suite(c, result, self.name) + _run_suite(c, result, self.name) return result + def _load_module(self, mod): + for tn in dir(mod): + c = getattr(mod, tn) + if isinstance(c, object) and isinstance(c, type) and issubclass(c, TestCase): + self.addTest(c) + elif tn.startswith("test") and callable(c): + self.addTest(c) + class TestRunner: def run(self, suite: TestSuite): @@ -331,7 +330,7 @@ class TestResult: return self -def capture_exc(exc, traceback): +def _capture_exc(exc, traceback): buf = io.StringIO() if hasattr(sys, "print_exception"): sys.print_exception(exc, buf) @@ -340,12 +339,12 @@ def capture_exc(exc, traceback): return buf.getvalue() -def handle_test_exception( +def _handle_test_exception( current_test: tuple, test_result: TestResult, exc_info: tuple, verbose=True ): exc = exc_info[1] traceback = exc_info[2] - ex_str = capture_exc(exc, traceback) + ex_str = _capture_exc(exc, traceback) if isinstance(exc, AssertionError): test_result.failuresNum += 1 test_result.failures.append((current_test, ex_str)) @@ -359,7 +358,7 @@ def handle_test_exception( test_result._newFailures += 1 -def run_suite(c, test_result: TestResult, suite_name=""): +def _run_suite(c, test_result: TestResult, suite_name=""): if isinstance(c, TestSuite): c.run(test_result) return @@ -388,9 +387,7 @@ def run_suite(c, test_result: TestResult, suite_name=""): try: test_result._newFailures = 0 test_result.testsRun += 1 - test_globals = dict(**globals()) - test_globals["test_function"] = test_function - exec("test_function()", test_globals, test_globals) + test_function() # No exception occurred, test passed if test_result._newFailures: print(" FAIL") @@ -402,7 +399,7 @@ def run_suite(c, test_result: TestResult, suite_name=""): test_result.skippedNum += 1 test_result.skipped.append((name, c, reason)) except Exception as ex: - handle_test_exception( + _handle_test_exception( current_test=(name, c), test_result=test_result, exc_info=sys.exc_info() ) # Uncomment to investigate failure in detail @@ -417,102 +414,59 @@ def run_suite(c, test_result: TestResult, suite_name=""): pass set_up_class() + try: + if hasattr(o, "runTest"): + name = str(o) + run_one(o.runTest) + return - if hasattr(o, "runTest"): - name = str(o) - run_one(o.runTest) - return + for name in dir(o): + if name.startswith("test"): + m = getattr(o, name) + if not callable(m): + continue + run_one(m) - for name in dir(o): - if name.startswith("test"): - m = getattr(o, name) - if not callable(m): - continue - run_one(m) - - if callable(o): - name = o.__name__ - run_one(o) - - tear_down_class() + if callable(o): + name = o.__name__ + run_one(o) + finally: + tear_down_class() return exceptions -def _test_cases(mod): - for tn in dir(mod): - c = getattr(mod, tn) - if isinstance(c, object) and isinstance(c, type) and issubclass(c, TestCase): - yield c - elif tn.startswith("test_") and callable(c): - yield c - - -def run_module(runner, module, path, top): - if not module: - raise ValueError("Empty module name") - - # Reset the python environment before running test - sys.modules.clear() - sys.modules.update(__modules__) - - sys_path_initial = sys.path[:] - # Add script dir and top dir to import path - sys.path.insert(0, str(path)) - if top: - sys.path.insert(1, top) - try: - suite = TestSuite(module) - m = __import__(module) if isinstance(module, str) else module - for c in _test_cases(m): - suite.addTest(c) - result = runner.run(suite) - return result - - finally: - sys.path[:] = sys_path_initial - - -def discover(runner: TestRunner): - from unittest_discover import discover - - global __modules__ - __modules__ = _snapshot_modules() - return discover(runner=runner) - - +# This supports either: +# +# >>> import mytest +# >>> unitttest.main(mytest) +# +# >>> unittest.main("mytest") +# +# Or, a script that ends with: +# if __name__ == "__main__": +# unittest.main() +# e.g. run via `mpremote run mytest.py` def main(module="__main__", testRunner=None): - if testRunner: - if isinstance(testRunner, type): - runner = testRunner() - else: - runner = testRunner - else: - runner = TestRunner() + if testRunner is None: + testRunner = TestRunner() + elif isinstance(testRunner, type): + testRunner = testRunner() - if len(sys.argv) <= 1: - result = discover(runner) - elif sys.argv[0].split(".")[0] == "unittest" and sys.argv[1] == "discover": - result = discover(runner) - else: - for test_spec in sys.argv[1:]: - try: - uos.stat(test_spec) - # test_spec is a local file, run it directly - if "/" in test_spec: - path, fname = test_spec.rsplit("/", 1) - else: - path, fname = ".", test_spec - modname = fname.rsplit(".", 1)[0] - result = run_module(runner, modname, path, None) - - except OSError: - # Not a file, treat as import name - result = run_module(runner, test_spec, ".", None) - - # Terminate with non zero return code in case of failures - sys.exit(result.failuresNum or result.errorsNum) + if isinstance(module, str): + module = __import__(module) + suite = TestSuite(module.__name__) + suite._load_module(module) + return testRunner.run(suite) +# Support `micropython -m unittest` (only useful if unitest-discover is +# installed). if __name__ == "__main__": - main() + try: + # If unitest-discover is installed, use the main() provided there. + from unittest_discover import discover_main + + discover_main() + except ImportError: + pass diff --git a/unix-ffi/unittest/manifest.py b/unix-ffi/unittest/manifest.py deleted file mode 100644 index 85a5f401..00000000 --- a/unix-ffi/unittest/manifest.py +++ /dev/null @@ -1,6 +0,0 @@ -metadata(version="0.9.0") - -require("argparse") -require("fnmatch") - -module("unittest.py") diff --git a/unix-ffi/unittest/test_unittest_isolated.py b/unix-ffi/unittest/test_unittest_isolated.py deleted file mode 100644 index a828f9a3..00000000 --- a/unix-ffi/unittest/test_unittest_isolated.py +++ /dev/null @@ -1,15 +0,0 @@ -import unittest - - -global_context = None - - -class TestUnittestIsolated(unittest.TestCase): - def test_NotChangedByOtherTest(self): - global global_context - assert global_context is None - global_context = True - - -if __name__ == "__main__": - unittest.main() diff --git a/unix-ffi/unittest/unittest_discover.py b/unix-ffi/unittest/unittest_discover.py deleted file mode 100644 index 7c5abd1f..00000000 --- a/unix-ffi/unittest/unittest_discover.py +++ /dev/null @@ -1,70 +0,0 @@ -import argparse -import sys -import uos -from fnmatch import fnmatch - -from unittest import TestRunner, TestResult, run_module - - -def discover(runner: TestRunner): - """ - Implements discover function inspired by https://docs.python.org/3/library/unittest.html#test-discovery - """ - parser = argparse.ArgumentParser() - # parser.add_argument( - # "-v", - # "--verbose", - # action="store_true", - # help="Verbose output", - # ) - parser.add_argument( - "-s", - "--start-directory", - dest="start", - default=".", - help="Directory to start discovery", - ) - parser.add_argument( - "-p", - "--pattern ", - dest="pattern", - default="test*.py", - help="Pattern to match test files", - ) - parser.add_argument( - "-t", - "--top-level-directory", - dest="top", - help="Top level directory of project (defaults to start directory)", - ) - args = parser.parse_args(args=sys.argv[2:]) - - path = args.start - top = args.top or path - - return run_all_in_dir( - runner=runner, - path=path, - pattern=args.pattern, - top=top, - ) - - -def run_all_in_dir(runner: TestRunner, path: str, pattern: str, top: str): - DIR_TYPE = 0x4000 - - result = TestResult() - for fname, type, *_ in uos.ilistdir(path): - if fname in ("..", "."): - continue - if type == DIR_TYPE: - result += run_all_in_dir( - runner=runner, - path="/".join((path, fname)), - pattern=pattern, - top=top, - ) - if fnmatch(fname, pattern): - modname = fname[: fname.rfind(".")] - result += run_module(runner, modname, path, top) - return result