diff --git a/python-stdlib/fnmatch/fnmatch.py b/python-stdlib/fnmatch/fnmatch.py index 93b5d5214..71009afa2 100644 --- a/python-stdlib/fnmatch/fnmatch.py +++ b/python-stdlib/fnmatch/fnmatch.py @@ -9,14 +9,26 @@ The function translate(PATTERN) returns a regular expression corresponding to PATTERN. (It does not compile it.) """ -import os -import os.path import re -# import functools +try: + from os.path import normcase +except ImportError: + + def normcase(s): + """ + From os.path.normcase + Normalize the case of a pathname. On Windows, convert all characters + in the pathname to lowercase, and also convert forward slashes to + backward slashes. On other operating systems, return the path unchanged. + """ + return s + __all__ = ["filter", "fnmatch", "fnmatchcase", "translate"] +COMPAT = re.__name__ == "ure" + def fnmatch(name, pat): """Test whether FILENAME matches PATTERN. @@ -33,8 +45,8 @@ def fnmatch(name, pat): if the operating system requires it. If you don't want this, use fnmatchcase(FILENAME, PATTERN). """ - name = os.path.normcase(name) - pat = os.path.normcase(pat) + name = normcase(name) + pat = normcase(pat) return fnmatchcase(name, pat) @@ -46,16 +58,21 @@ def _compile_pattern(pat): res = bytes(res_str, "ISO-8859-1") else: res = translate(pat) + if COMPAT: + if res.startswith("(?ms)"): + res = res[5:] + if res.endswith("\\Z"): + res = res[:-2] + "$" return re.compile(res).match def filter(names, pat): """Return the subset of the list NAMES that match PAT.""" result = [] - pat = os.path.normcase(pat) + pat = normcase(pat) match = _compile_pattern(pat) for name in names: - if match(os.path.normcase(name)): + if match(normcase(name)): result.append(name) return result @@ -104,6 +121,15 @@ def translate(pat): stuff = "\\" + stuff res = "%s[%s]" % (res, stuff) else: - res = res + re.escape(c) + try: + res = res + re.escape(c) + except AttributeError: + # Using ure rather than re-pcre + res = res + re_escape(c) # Original patterns is undefined, see http://bugs.python.org/issue21464 return "(?ms)" + res + "\Z" + + +def re_escape(pattern): + # Replacement minimal re.escape for ure compatibility + return re.sub(r"([\^\$\.\|\?\*\+\(\)\[\\])", r"\\\1", pattern) diff --git a/python-stdlib/fnmatch/metadata.txt b/python-stdlib/fnmatch/metadata.txt index faa832140..27b8c03f3 100644 --- a/python-stdlib/fnmatch/metadata.txt +++ b/python-stdlib/fnmatch/metadata.txt @@ -1,4 +1,3 @@ srctype = cpython type = module -version = 0.5.2 -depends = os, os.path, re-pcre +version = 0.6.0 diff --git a/python-stdlib/fnmatch/setup.py b/python-stdlib/fnmatch/setup.py index 415804c01..06331a9cb 100644 --- a/python-stdlib/fnmatch/setup.py +++ b/python-stdlib/fnmatch/setup.py @@ -10,7 +10,7 @@ setup( name="micropython-fnmatch", - version="0.5.2", + version="0.6.0", description="CPython fnmatch module ported to MicroPython", long_description="This is a module ported from CPython standard library to be compatible with\nMicroPython interpreter. Usually, this means applying small patches for\nfeatures not supported (yet, or at all) in MicroPython. Sometimes, heavier\nchanges are required. Note that CPython modules are written with availability\nof vast resources in mind, and may not work for MicroPython ports with\nlimited heap. If you are affected by such a case, please help reimplement\nthe module from scratch.", url="https://github.com/micropython/micropython-lib", @@ -21,5 +21,4 @@ license="Python", cmdclass={"sdist": sdist_upip.sdist}, py_modules=["fnmatch"], - install_requires=["micropython-os", "micropython-os.path", "micropython-re-pcre"], ) diff --git a/python-stdlib/fnmatch/test_fnmatch.py b/python-stdlib/fnmatch/test_fnmatch.py index 1ddf8a607..4eaeec63b 100644 --- a/python-stdlib/fnmatch/test_fnmatch.py +++ b/python-stdlib/fnmatch/test_fnmatch.py @@ -10,7 +10,8 @@ class FnmatchTestCase(unittest.TestCase): def check_match(self, filename, pattern, should_match=1, fn=fnmatch): if should_match: self.assertTrue( - fn(filename, pattern), "expected %r to match pattern %r" % (filename, pattern) + fn(filename, pattern), + "expected %r to match pattern %r" % (filename, pattern), ) else: self.assertTrue( @@ -80,9 +81,9 @@ def test_filter(self): self.assertEqual(filter(["a", "b"], "a"), ["a"]) -def test_main(): +def main(): support.run_unittest(FnmatchTestCase, TranslateTestCase, FilterTestCase) if __name__ == "__main__": - test_main() + main() diff --git a/python-stdlib/unittest/metadata.txt b/python-stdlib/unittest/metadata.txt index f3c23ccee..ba9a983fc 100644 --- a/python-stdlib/unittest/metadata.txt +++ b/python-stdlib/unittest/metadata.txt @@ -1,3 +1,4 @@ srctype = micropython-lib type = module -version = 0.3.2 +version = 0.9.0 +depends = argparse, fnmatch diff --git a/python-stdlib/unittest/setup.py b/python-stdlib/unittest/setup.py index 74b985e81..d1604f813 100644 --- a/python-stdlib/unittest/setup.py +++ b/python-stdlib/unittest/setup.py @@ -10,7 +10,7 @@ setup( name="micropython-unittest", - version="0.3.2", + version="0.9.0", description="unittest module for MicroPython", long_description="This is a module reimplemented specifically for MicroPython standard library,\nwith efficient and lean design in mind. Note that this module is likely work\nin progress and likely supports just a subset of CPython's corresponding\nmodule. Please help with the development if you are interested in this\nmodule.", url="https://github.com/micropython/micropython-lib", @@ -21,4 +21,5 @@ license="MIT", cmdclass={"sdist": sdist_upip.sdist}, py_modules=["unittest"], + install_requires=["micropython-argparse", "micropython-fnmatch"], ) diff --git a/python-stdlib/unittest/test_unittest.py b/python-stdlib/unittest/test_unittest.py index 4651cf852..8e108995a 100644 --- a/python-stdlib/unittest/test_unittest.py +++ b/python-stdlib/unittest/test_unittest.py @@ -1,4 +1,5 @@ import unittest +from test_unittest_isolated import global_context class TestUnittestAssertions(unittest.TestCase): @@ -37,7 +38,7 @@ def test_AlmostEqual(self): with self.assertRaises(AssertionError): self.assertNotAlmostEqual(float("inf"), float("inf")) - def test_AmostEqualWithDelta(self): + def test_AlmostEqualWithDelta(self): self.assertAlmostEqual(1.1, 1.0, delta=0.5) self.assertAlmostEqual(1.0, 1.1, delta=0.5) self.assertNotAlmostEqual(1.1, 1.0, delta=0.05) @@ -109,7 +110,70 @@ def testRaises(self): @unittest.skip("test of skipping") def testSkip(self): - self.assertFail("this should be skipped") + self.fail("this should be skipped") + + def testAssert(self): + + e1 = None + try: + + def func_under_test(a): + assert a > 10 + + self.assertRaises(AssertionError, func_under_test, 20) + except AssertionError as e: + e1 = e + + if not e1 or "not raised" not in e1.args[0]: + self.fail("Expected to catch lack of AssertionError from assert in func_under_test") + + @unittest.expectedFailure + def testExpectedFailure(self): + self.assertEqual(1, 0) + + def testExpectedFailureNot(self): + @unittest.expectedFailure + def testInner(): + self.assertEqual(1, 1) + + try: + testInner() + except: + pass + 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. + """ + for i in range(0, 10, 2): + with self.subTest("Should only pass for even numbers", i=i): + 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__": diff --git a/python-stdlib/unittest/test_unittest_isolated.py b/python-stdlib/unittest/test_unittest_isolated.py new file mode 100644 index 000000000..a828f9a3b --- /dev/null +++ b/python-stdlib/unittest/test_unittest_isolated.py @@ -0,0 +1,15 @@ +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/python-stdlib/unittest/unittest.py b/python-stdlib/unittest/unittest.py index 2b00fbddb..b61686981 100644 --- a/python-stdlib/unittest/unittest.py +++ b/python-stdlib/unittest/unittest.py @@ -1,4 +1,20 @@ 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): @@ -13,6 +29,7 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_value, tb): + self.exception = exc_value if exc_type is None: assert False, "%r not raised" % self.expected if issubclass(exc_type, self.expected): @@ -20,7 +37,64 @@ def __exit__(self, exc_type, exc_value, tb): return False +# These are used to provide required context to things like subTest +__current_test__ = None +__test_result__ = None + + +class SubtestContext: + def __init__(self, msg=None, params=None): + self.msg = msg + self.params = params + + def __enter__(self): + pass + + def __exit__(self, *exc_info): + if exc_info[0] is not None: + # Exception raised + global __test_result__, __current_test__ + test_details = __current_test__ + if self.msg: + test_details += (f" [{self.msg}]",) + if self.params: + 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) + # Suppress the exception as we've captured it above + return True + + +class NullContext: + def __enter__(self): + pass + + def __exit__(self, exc_type, exc_value, traceback): + pass + + class TestCase: + def __init__(self): + pass + + def addCleanup(self, func, *args, **kwargs): + if not hasattr(self, "_cleanups"): + self._cleanups = [] + self._cleanups.append((func, args, kwargs)) + + def doCleanups(self): + if hasattr(self, "_cleanups"): + while self._cleanups: + func, args, kwargs = self._cleanups.pop() + func(*args, **kwargs) + + def subTest(self, msg=None, **params): + return SubtestContext(msg=msg, params=params) + + def skipTest(self, reason): + raise SkipTest(reason) + def fail(self, msg=""): assert False, msg @@ -34,6 +108,16 @@ def assertNotEqual(self, x, y, msg=""): msg = "%r not expected to be equal %r" % (x, y) assert x != y, msg + def assertLessEqual(self, x, y, msg=None): + if msg is None: + msg = "%r is expected to be <= %r" % (x, y) + assert x <= y, msg + + def assertGreaterEqual(self, x, y, msg=None): + if msg is None: + msg = "%r is expected to be >= %r" % (x, y) + assert x >= y, msg + def assertAlmostEqual(self, x, y, places=None, msg="", delta=None): if x == y: return @@ -118,12 +202,16 @@ def assertRaises(self, exc, func=None, *args, **kwargs): try: func(*args, **kwargs) - assert False, "%r not raised" % exc except Exception as e: if isinstance(e, exc): return raise + assert False, "%r not raised" % exc + + def assertWarns(self, warn): + return NullContext() + def skip(msg): def _decor(fun): @@ -148,82 +236,283 @@ def skipUnless(cond, msg): return skip(msg) +def expectedFailure(test): + def test_exp_fail(*args, **kwargs): + try: + test(*args, **kwargs) + except: + pass + else: + assert False, "unexpected success" + + return test_exp_fail + + class TestSuite: - def __init__(self): - self.tests = [] + def __init__(self, name=""): + self._tests = [] + self.name = name def addTest(self, cls): - self.tests.append(cls) + self._tests.append(cls) + + def run(self, result): + for c in self._tests: + run_suite(c, result, self.name) + return result class TestRunner: - def run(self, suite): + def run(self, suite: TestSuite): res = TestResult() - for c in suite.tests: - run_class(c, res) + suite.run(res) + res.printErrors() + print("----------------------------------------------------------------------") print("Ran %d tests\n" % res.testsRun) if res.failuresNum > 0 or res.errorsNum > 0: print("FAILED (failures=%d, errors=%d)" % (res.failuresNum, res.errorsNum)) else: msg = "OK" if res.skippedNum > 0: - msg += " (%d skipped)" % res.skippedNum + msg += " (skipped=%d)" % res.skippedNum print(msg) return res +TextTestRunner = TestRunner + + class TestResult: def __init__(self): self.errorsNum = 0 self.failuresNum = 0 self.skippedNum = 0 self.testsRun = 0 + self.errors = [] + self.failures = [] + self.skipped = [] + self._newFailures = 0 def wasSuccessful(self): return self.errorsNum == 0 and self.failuresNum == 0 + def printErrors(self): + print() + self.printErrorList(self.errors) + self.printErrorList(self.failures) + + def printErrorList(self, lst): + sep = "----------------------------------------------------------------------" + for c, e in lst: + detail = " ".join((str(i) for i in c)) + print("======================================================================") + print(f"FAIL: {detail}") + print(sep) + print(e) + + def __repr__(self): + # Format is compatible with CPython. + return "" % ( + self.testsRun, + self.errorsNum, + self.failuresNum, + ) + + def __add__(self, other): + self.errorsNum += other.errorsNum + self.failuresNum += other.failuresNum + self.skippedNum += other.skippedNum + self.testsRun += other.testsRun + self.errors.extend(other.errors) + self.failures.extend(other.failures) + self.skipped.extend(other.skipped) + return self -# TODO: Uncompliant -def run_class(c, test_result): - o = c() + +def capture_exc(exc, traceback): + buf = io.StringIO() + if hasattr(sys, "print_exception"): + sys.print_exception(exc, buf) + elif traceback is not None: + traceback.print_exception(None, exc, traceback, file=buf) + return buf.getvalue() + + +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) + if isinstance(exc, AssertionError): + test_result.failuresNum += 1 + test_result.failures.append((current_test, ex_str)) + if verbose: + print(" FAIL") + else: + test_result.errorsNum += 1 + test_result.errors.append((current_test, ex_str)) + if verbose: + print(" ERROR") + test_result._newFailures += 1 + + +def run_suite(c, test_result: TestResult, suite_name=""): + if isinstance(c, TestSuite): + c.run(test_result) + return + + if isinstance(c, type): + o = c() + else: + o = c + set_up_class = getattr(o, "setUpClass", lambda: None) + tear_down_class = getattr(o, "tearDownClass", lambda: None) set_up = getattr(o, "setUp", lambda: None) tear_down = getattr(o, "tearDown", lambda: None) + exceptions = [] + try: + suite_name += "." + c.__qualname__ + except AttributeError: + pass + + def run_one(test_function): + global __test_result__, __current_test__ + print("%s (%s) ..." % (name, suite_name), end="") + set_up() + __test_result__ = test_result + test_container = f"({suite_name})" + __current_test__ = (name, test_container) + 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) + # No exception occurred, test passed + if test_result._newFailures: + print(" FAIL") + else: + print(" ok") + except SkipTest as e: + reason = e.args[0] + print(" skipped:", reason) + test_result.skippedNum += 1 + test_result.skipped.append((name, c, reason)) + except Exception as ex: + handle_test_exception( + current_test=(name, c), test_result=test_result, exc_info=sys.exc_info() + ) + # Uncomment to investigate failure in detail + # raise + finally: + __test_result__ = None + __current_test__ = None + tear_down() + try: + o.doCleanups() + except AttributeError: + pass + + set_up_class() + + if hasattr(o, "runTest"): + name = str(o) + run_one(o.runTest) + return + for name in dir(o): if name.startswith("test"): - print("%s (%s) ..." % (name, c.__qualname__), end="") m = getattr(o, name) - set_up() - try: - test_result.testsRun += 1 - m() - print(" ok") - except SkipTest as e: - print(" skipped:", e.args[0]) - test_result.skippedNum += 1 - except: - print(" FAIL") - test_result.failuresNum += 1 - # Uncomment to investigate failure in detail - # raise + if not callable(m): continue - finally: - tear_down() - - -def main(module="__main__"): - def test_cases(m): - for tn in dir(m): - c = getattr(m, tn) - if isinstance(c, object) and isinstance(c, type) and issubclass(c, TestCase): - yield c - - m = __import__(module) - suite = TestSuite() - for c in test_cases(m): - suite.addTest(c) - runner = TestRunner() - result = runner.run(suite) + run_one(m) + + if callable(o): + name = o.__name__ + run_one(o) + + 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) + + +def main(module="__main__", testRunner=None): + if testRunner: + if isinstance(testRunner, type): + runner = testRunner() + else: + runner = testRunner + else: + runner = 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 > 0) + sys.exit(result.failuresNum or result.errorsNum) + + +if __name__ == "__main__": + main() diff --git a/python-stdlib/unittest/unittest_discover.py b/python-stdlib/unittest/unittest_discover.py new file mode 100644 index 000000000..7c5abd1f8 --- /dev/null +++ b/python-stdlib/unittest/unittest_discover.py @@ -0,0 +1,70 @@ +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