From 8caa4e2243767ff250dacf7982588d915e6f8a88 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 6 Apr 2023 13:26:00 +0000 Subject: [PATCH 1/6] fixed call to logging._level --- ina219.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ina219.py b/ina219.py index 5bfb2fd..e44e80a 100644 --- a/ina219.py +++ b/ina219.py @@ -406,7 +406,7 @@ def __read_register(self, register, negative_value_supported=False): def __log_register_operation(self, msg, register, value): # performance optimisation - if logging._level == logging.DEBUG: + if self._log.level == logging.DEBUG: binary = '{0:#018b}'.format(value) self._log.debug("%s register 0x%02x: 0x%04x %s", msg, register, value, binary) From c72c6c5776daf9cc40a38d07e41f9325a5ea1374 Mon Sep 17 00:00:00 2001 From: Tom Date: Thu, 6 Apr 2023 13:42:10 +0000 Subject: [PATCH 2/6] apply logLevel to correct logger --- ina219.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ina219.py b/ina219.py index e44e80a..bf79f28 100644 --- a/ina219.py +++ b/ina219.py @@ -124,8 +124,8 @@ def __init__(self, shunt_ohms, i2c, max_expected_amps=None, log_level -- set to logging.DEBUG to see detailed calibration calculations (optional). """ - logging.basicConfig(level=log_level) self._log = logging.getLogger("ina219") + self._log.setLevel(log_level) self._i2c = i2c self._address = address self._shunt_ohms = shunt_ohms From 4b82f64fbf56614389916e246716f32420c4ce08 Mon Sep 17 00:00:00 2001 From: Chris Borrill Date: Sat, 5 Aug 2023 16:20:42 +1200 Subject: [PATCH 3/6] Update to more std implementation of logging that allows logging to work without using basicConfig() in the main program. Update examples for esp32 and esp8266 based on testing with Micropython 1.20 --- esp32/example.py | 4 ++-- esp8266/example.py | 4 ++-- ina219.py | 6 ++++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/esp32/example.py b/esp32/example.py index e157f28..655bd5e 100644 --- a/esp32/example.py +++ b/esp32/example.py @@ -4,10 +4,10 @@ SHUNT_OHMS = 0.1 -i2c = I2C(-1, Pin(17), Pin(16)) +i2c = I2C(1, scl=Pin(16), sda=Pin(17)) ina = INA219(SHUNT_OHMS, i2c, log_level=INFO) ina.configure() print("Bus Voltage: %.3f V" % ina.voltage()) print("Current: %.3f mA" % ina.current()) -print("Power: %.3f mW" % ina.power()) \ No newline at end of file +print("Power: %.3f mW" % ina.power()) diff --git a/esp8266/example.py b/esp8266/example.py index 2830bd5..a21c579 100644 --- a/esp8266/example.py +++ b/esp8266/example.py @@ -4,10 +4,10 @@ SHUNT_OHMS = 0.1 -i2c = I2C(-1, Pin(5), Pin(4)) +i2c = I2C(Pin(5), Pin(4)) ina = INA219(SHUNT_OHMS, i2c, log_level=INFO) ina.configure() print("Bus Voltage: %.3f V" % ina.voltage()) print("Current: %.3f mA" % ina.current()) -print("Power: %.3f mW" % ina.power()) \ No newline at end of file +print("Power: %.3f mW" % ina.power()) diff --git a/ina219.py b/ina219.py index bf79f28..e5c330f 100644 --- a/ina219.py +++ b/ina219.py @@ -124,8 +124,10 @@ def __init__(self, shunt_ohms, i2c, max_expected_amps=None, log_level -- set to logging.DEBUG to see detailed calibration calculations (optional). """ - self._log = logging.getLogger("ina219") + logging.basicConfig(level=log_level) + self._log = logging.getLogger(__name__) self._log.setLevel(log_level) + self._i2c = i2c self._address = address self._shunt_ohms = shunt_ohms @@ -406,7 +408,7 @@ def __read_register(self, register, negative_value_supported=False): def __log_register_operation(self, msg, register, value): # performance optimisation - if self._log.level == logging.DEBUG: + if self._log.isEnabledFor(logging.DEBUG): binary = '{0:#018b}'.format(value) self._log.debug("%s register 0x%02x: 0x%04x %s", msg, register, value, binary) From 3c9986aaf0bd1e1d9da4845e42ff3a8580f2234b Mon Sep 17 00:00:00 2001 From: Chris Borrill Date: Sun, 6 Aug 2023 11:26:49 +1200 Subject: [PATCH 4/6] Update unit test configuration and add readme on running them. --- README.md | 22 ++ tests/__init__.py | 2 + unittest/README.md | 1 + unittest/__init__.py | 463 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 488 insertions(+) create mode 100644 unittest/README.md create mode 100644 unittest/__init__.py diff --git a/README.md b/README.md index 6d6ac22..189f8db 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,28 @@ Detailed logging of device register operations can be enabled with: ina = INA219(SHUNT_OHMS, I2C(2), log_level=logging.DEBUG) ``` +## Unit Testing + +Some basic unit tests are provided which do not require an ina219 sensor to be connected. + +Copy all the files in the _test_ and _unittest_ directories to matching directories on the device. From the REPL prompt run the tests with: +```python +import unittest +unittest.main("tests") +``` +The result should be: +``` +test_default (tests.TestConstructor) ... ok +test_with_max_expected_amps (tests.TestConstructor) ... ok +test_read_32v (tests.TestRead) ... ok +test_read_16v (tests.TestRead) ... ok +test_read_4_808v (tests.TestRead) ... ok +---------------------------------------------------------------------- +Ran 5 tests + +OK + +``` ## Coding Standard This library adheres to the *PEP8* standard and follows the *idiomatic* diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..4fe85ec 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +from .test_constructor import * +from .test_read import * \ No newline at end of file diff --git a/unittest/README.md b/unittest/README.md new file mode 100644 index 0000000..6548f1c --- /dev/null +++ b/unittest/README.md @@ -0,0 +1 @@ +This directory contains a copy of the standard Micropython unittest module [\_\_init\_\_.py](python-stdlib/unittest/unittest/__init__.py). \ No newline at end of file diff --git a/unittest/__init__.py b/unittest/__init__.py new file mode 100644 index 0000000..f15f304 --- /dev/null +++ b/unittest/__init__.py @@ -0,0 +1,463 @@ +import io +import os +import sys + +try: + import traceback +except ImportError: + traceback = None + + +class SkipTest(Exception): + pass + + +class AssertRaisesContext: + def __init__(self, exc): + self.expected = exc + + 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): + # store exception for later retrieval + self.exception = exc_value + return True + 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 + + def assertEqual(self, x, y, msg=""): + if not msg: + msg = "%r vs (expected) %r" % (x, y) + assert x == y, msg + + def assertNotEqual(self, x, y, msg=""): + if not 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 + if delta is not None and places is not None: + raise TypeError("specify delta or places not both") + + if delta is not None: + if abs(x - y) <= delta: + return + if not msg: + msg = "%r != %r within %r delta" % (x, y, delta) + else: + if places is None: + places = 7 + if round(abs(y - x), places) == 0: + return + if not msg: + msg = "%r != %r within %r places" % (x, y, places) + + assert False, msg + + def assertNotAlmostEqual(self, x, y, places=None, msg="", delta=None): + if delta is not None and places is not None: + raise TypeError("specify delta or places not both") + + if delta is not None: + if not (x == y) and abs(x - y) > delta: + return + if not msg: + msg = "%r == %r within %r delta" % (x, y, delta) + else: + if places is None: + places = 7 + if not (x == y) and round(abs(y - x), places) != 0: + return + if not msg: + msg = "%r == %r within %r places" % (x, y, places) + + assert False, msg + + def assertIs(self, x, y, msg=""): + if not msg: + msg = "%r is not %r" % (x, y) + assert x is y, msg + + def assertIsNot(self, x, y, msg=""): + if not msg: + msg = "%r is %r" % (x, y) + assert x is not y, msg + + def assertIsNone(self, x, msg=""): + if not msg: + msg = "%r is not None" % x + assert x is None, msg + + def assertIsNotNone(self, x, msg=""): + if not msg: + msg = "%r is None" % x + assert x is not None, msg + + def assertTrue(self, x, msg=""): + if not msg: + msg = "Expected %r to be True" % x + assert x, msg + + def assertFalse(self, x, msg=""): + if not msg: + msg = "Expected %r to be False" % x + assert not x, msg + + def assertIn(self, x, y, msg=""): + if not msg: + msg = "Expected %r to be in %r" % (x, y) + assert x in y, msg + + def assertIsInstance(self, x, y, msg=""): + assert isinstance(x, y), msg + + def assertRaises(self, exc, func=None, *args, **kwargs): + if func is None: + return AssertRaisesContext(exc) + + try: + func(*args, **kwargs) + 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): + # We just replace original fun with _inner + def _inner(self): + raise SkipTest(msg) + + return _inner + + return _decor + + +def skipIf(cond, msg): + if not cond: + return lambda x: x + return skip(msg) + + +def skipUnless(cond, msg): + if cond: + return lambda x: x + 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, name=""): + self._tests = [] + self.name = name + + def addTest(self, cls): + self._tests.append(cls) + + def run(self, result): + for c in self._tests: + _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): + res = TestResult() + 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 += " (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): + if self.errors or self.failures: + 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 + + +def _capture_exc(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, 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_function() + # 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=(type(ex), ex, None) + ) + # 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() + try: + 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) + + if callable(o): + name = o.__name__ + run_one(o) + finally: + tear_down_class() + + return exceptions + + +# 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 is None: + testRunner = TestRunner() + elif isinstance(testRunner, type): + testRunner = testRunner() + + if isinstance(module, str): + module = __import__(module) + suite = TestSuite(module.__name__) + suite._load_module(module) + return testRunner.run(suite) From d01c47733e91a09723dbd7613adb6f60025e4a67 Mon Sep 17 00:00:00 2001 From: Chris Borrill Date: Sun, 6 Aug 2023 11:30:43 +1200 Subject: [PATCH 5/6] Fix URL in README --- unittest/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unittest/README.md b/unittest/README.md index 6548f1c..822caa3 100644 --- a/unittest/README.md +++ b/unittest/README.md @@ -1 +1 @@ -This directory contains a copy of the standard Micropython unittest module [\_\_init\_\_.py](python-stdlib/unittest/unittest/__init__.py). \ No newline at end of file +This directory contains a copy of the standard Micropython unittest module [\_\_init\_\_.py](https://github.com/micropython/micropython-lib/blob/master/python-stdlib/unittest/unittest/__init__.py). \ No newline at end of file From 523e48ce7d7d22092f5b0e6eb63164fcbcc1289f Mon Sep 17 00:00:00 2001 From: Chris Borrill Date: Sun, 6 Aug 2023 11:36:42 +1200 Subject: [PATCH 6/6] Add to comments in examples --- esp32/example.py | 1 + esp8266/example.py | 1 + example.py | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/esp32/example.py b/esp32/example.py index 655bd5e..37b09c5 100644 --- a/esp32/example.py +++ b/esp32/example.py @@ -1,3 +1,4 @@ +"""Example script for ESP32.""" from machine import Pin, I2C from ina219 import INA219 from logging import INFO diff --git a/esp8266/example.py b/esp8266/example.py index a21c579..884b17f 100644 --- a/esp8266/example.py +++ b/esp8266/example.py @@ -1,3 +1,4 @@ +"""Example script for ESP32.""" from machine import Pin, I2C from ina219 import INA219 from logging import INFO diff --git a/example.py b/example.py index 2fa614a..94e25d2 100644 --- a/example.py +++ b/example.py @@ -1,4 +1,4 @@ -"""Example script. +"""Example script for Pyboard v1.1. Edit the I2C interface constant to match the one you have connected the sensor to.