From 6675711c5d2c915a617505e47b84701d55323095 Mon Sep 17 00:00:00 2001 From: Phil Howard Date: Thu, 12 Aug 2021 09:31:51 +0100 Subject: [PATCH 1/6] Performance optimization and code cleanup - Remove logging, user can print() or log this themselves - Support creating a GPIOPin instance directly for (slightly) faster IO - Rewrte IO to use bytes mode, for a ~2.5x performance gain over the optimized versions - Add write to match GPIOPin's write/read - Move all pin state handling, setup and teardown to GPIOPin - Remove function aliases - Apply flake8 linting - Move logging inside "_export_lock" so Export/Unexport are logged when they happen - Support list or tuple of pins in setup() and cleanup() for #13 and #10 - Support str/int GPIO number for #7 - costs performance but using GPIOPin directly gets it back - General code tidyup - Version bump to 1.0.0 Co-authored-by: rahulraghu94 Co-authored-by: sheffieldnick --- MANIFEST.in | 3 + README.md | 43 +++++++- gpio.py | 208 ----------------------------------- gpio/__init__.py | 280 +++++++++++++++++++++++++++++++++++++++++++++++ setup.cfg | 38 ++++++- setup.py | 40 +------ 6 files changed, 364 insertions(+), 248 deletions(-) delete mode 100644 gpio.py create mode 100644 gpio/__init__.py diff --git a/MANIFEST.in b/MANIFEST.in index bb3ec5f..cec267d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,4 @@ include README.md +include LICENSE.txt +include setup.py +recursive-include gpio *.py \ No newline at end of file diff --git a/README.md b/README.md index f16c30e..4df6716 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,49 @@ It is intended to mimick [RPIO](http://pythonhosted.org/RPIO/) as much as possib for all features, while also supporting additional (and better named) functionality to the same methods. - ## Supported Features + - get pin values with `read(pin)` or `input(pin)` -- set pin values with `set(pin, value)` or `output(pin, value)` +- set pin values with `write(pin, value)`, `set(pin, value)` or `output(pin, value)` - get the pin mode with `mode(pin)` - set the pin mode with `setup(pin, mode)` - `mode` can currently equal `gpio.IN` or `gpio.OUT` +- create a `GPIOPin` class directly to `write` and `read` a pin + +## Examples + +### RPi.GPIO Drop-in + +Good for up to 130KHz pin toggle on a Pi 400. + +```python +import time + +import gpio as GPIO + +GPIO.setup(14, GPIO.OUT) + +while True: + GPIO.output(14, GPIO.HIGH) + time.sleep(1.0) + GPIO.output(14, GPIO.LOW) + time.sleep(1.0) +``` + +### Use GPIOPin directly + +Good for up to 160KHz pin toggle on a Pi 400. + +This gives you a class instance you can manipulate directly, eliminating the lookup: + +```python +import gpio + +pin = gpio.GPIOPin(14, gpio.OUT) + +while True: + pin.write(14, GPIO.HIGH) + time.sleep(1.0) + pin.write(14, GPIO.LOW) + time.sleep(1.0) +``` diff --git a/gpio.py b/gpio.py deleted file mode 100644 index 1c893ba..0000000 --- a/gpio.py +++ /dev/null @@ -1,208 +0,0 @@ -# -*- coding: utf-8 -*- -__version__ = '0.3.0' - -import threading -import os - -import logging -log = logging.getLogger(__name__) - - -class PinState(object): - """An ultra simple pin-state object. - - Keeps track data related to each pin. - - Args: - value: the file pointer to set/read value of pin. - direction: the file pointer to set/read direction of the pin. - active_now: the file pointer to set/read if the pin is active_low. - """ - def __init__(self, value, direction, active_low): - self.value = value - self.direction = direction - self.active_low = active_low - -path = os.path -pjoin = os.path.join - -gpio_root = '/sys/class/gpio' -gpiopath = lambda pin: os.path.join(gpio_root, 'gpio{0}'.format(pin)) -_export_lock = threading.Lock() - -_pyset = set - -_open = dict() -FMODE = 'w+' - -IN, OUT = 'in', 'out' -LOW, HIGH = 'low', 'high' - - -def _write(f, v): - log.debug("writing: {0}: {1}".format(f, v)) - f.write(str(v)) - f.flush() - - -def _read(f): - log.debug("Reading: {0}".format(f)) - f.seek(0) - return f.read().strip() - - -def _verify(function): - """decorator to ensure pin is properly set up""" - # @functools.wraps - def wrapped(pin, *args, **kwargs): - pin = int(pin) - if pin not in _open: - ppath = gpiopath(pin) - if not os.path.exists(ppath): - log.debug("Creating Pin {0}".format(pin)) - with _export_lock: - with open(pjoin(gpio_root, 'export'), 'w') as f: - _write(f, pin) - value, direction, active_low = None, None, None - try: - value = open(pjoin(ppath, 'value'), FMODE) - direction = open(pjoin(ppath, 'direction'), FMODE) - active_low = open(pjoin(ppath, 'active_low'), FMODE) - except Exception as e: - if value: value.close() - if direction: direction.close() - if active_low: active_low.close() - raise e - _open[pin] = PinState(value=value, direction=direction, active_low=active_low) - return function(pin, *args, **kwargs) - return wrapped - - -def cleanup(pin=None, assert_exists=False): - """Cleanup the pin by closing and unexporting it. - - Args: - pin (int, optional): either the pin to clean up or None (default). - If None, clean up all pins. - assert_exists: if True, raise a ValueError if the pin was not - setup. Otherwise, this function is a NOOP. - """ - if pin is None: - # Take a list of keys because we will be deleting from _open - for pin in list(_open): - cleanup(pin) - return - if not isinstance(pin, int): - raise TypeError("pin must be an int, got: {}".format(pin)) - - state = _open.get(pin) - if state is None: - if assert_exists: - raise ValueError("pin {} was not setup".format(pin)) - return - state.value.close() - state.direction.close() - state.active_low.close() - if os.path.exists(gpiopath(pin)): - log.debug("Unexporting pin {0}".format(pin)) - with _export_lock: - with open(pjoin(gpio_root, 'unexport'), 'w') as f: - _write(f, pin) - - del _open[pin] - - -@_verify -def setup(pin, mode, pullup=None, initial=False, active_low=None): - '''Setup pin with mode IN or OUT. - - Args: - pin (int): - mode (str): use either gpio.OUT or gpio.IN - pullup (None): rpio compatibility. If anything but None, raises - value Error - initial (bool, optional): Initial pin value. Default is False - active_low (bool, optional): Set the pin to active low. Default - is None which leaves things as configured in sysfs - ''' - if pullup is not None: - raise ValueError("sysfs does not support pullups") - - if mode not in (IN, OUT, LOW, HIGH): - raise ValueError(mode) - - if active_low is not None: - if not isinstance(active_low, bool): - raise ValueError("active_low argument must be True or False") - log.debug("Set active_low {0}: {1}".format(pin, active_low)) - f_active_low = _open[pin].active_low - _write(f_active_low, int(active_low)) - - log.debug("Setup {0}: {1}".format(pin, mode)) - f_direction = _open[pin].direction - _write(f_direction, mode) - if mode == OUT: - if initial: - set(pin, 1) - else: - set(pin, 0) - - -@_verify -def mode(pin): - '''get the pin mode - - Returns: - str: "in" or "out" - ''' - f = _open[pin].direction - return _read(f) - - -@_verify -def read(pin): - '''read the pin value - - Returns: - bool: 0 or 1 - ''' - f = _open[pin].value - out = int(_read(f)) - log.debug("Read {0}: {1}".format(pin, out)) - return out - - -@_verify -def set(pin, value): - '''set the pin value to 0 or 1''' - if value is LOW: - value = 0 - value = int(bool(value)) - log.debug("Write {0}: {1}".format(pin, value)) - f = _open[pin].value - _write(f, value) - - -@_verify -def input(pin): - '''read the pin. Same as read''' - return read(pin) - - -@_verify -def output(pin, value): - '''set the pin. Same as set''' - return set(pin, value) - - -def setwarnings(value): - '''exists for rpio compatibility''' - pass - - -def setmode(value): - '''exists for rpio compatibility''' - pass - - -BCM = None # rpio compatibility diff --git a/gpio/__init__.py b/gpio/__init__.py new file mode 100644 index 0000000..265f957 --- /dev/null +++ b/gpio/__init__.py @@ -0,0 +1,280 @@ +# -*- coding: utf-8 -*- +__version__ = '1.0.0' + +from threading import Lock +import os + + +_export_lock = Lock() +_open_pins = {} + + +GPIO_ROOT = '/sys/class/gpio' +GPIO_EXPORT = os.path.join(GPIO_ROOT, 'export') +GPIO_UNEXPORT = os.path.join(GPIO_ROOT, 'unexport') +FMODE = 'w+' # w+ overwrites and truncates existing files +IN, OUT = 'in', 'out' +LOW, HIGH = 0, 1 +BCM = None # Exists for RPi.GPIO compatibility + + +class GPIOPin(object): + """Handle pin state. + + Keeps track of file nodes and functions related to a pin. + + Args: + pin (int): the GPIO pin to set up + """ + def __init__(self, pin, mode=None, initial=LOW, active_low=None): + existing = _open_pins.get(pin) + if existing: + existing.cleanup() + del existing + + self.pin = str(pin) + self.value = None + + self.root = os.path.join(GPIO_ROOT, 'gpio{0}'.format(pin)) + + if not os.path.exists(self.root): + with _export_lock: + with open(GPIO_EXPORT, 'w') as f: + f.write(self.pin) + f.flush() + + # Using unbuffered binary IO is ~ 3x faster than text + self.value = open(os.path.join(self.root, 'value'), 'wb+', buffering=0) + + if mode is not None: + self.set_direction(mode) + + if active_low is not None: + self.set_active_low(active_low) + + if mode == OUT: + self.write(initial) + + # Add class to open pins + _open_pins[pin] = self + + def get_direction(self): + '''Get the direction of pin + + Returns: + str: "in" or "out" + ''' + with open(os.path.join(self.root, 'direction'), FMODE) as f: + return f.read().strip() + + def set_direction(self, mode): + '''Set the direction of pin + + Args: + mode (str): "in" or "out" + ''' + if mode not in (IN, OUT, LOW, HIGH): + raise ValueError("Unsupported pin mode {}".format(mode)) + + with open(os.path.join(self.root, 'direction'), FMODE) as f: + f.write(str(mode)) + f.flush() + + def set_active_low(self, active_low): + '''Set the direction of pin + + Args: + mode (bool): True/False + ''' + if not isinstance(active_low, bool): + raise ValueError("active_low must be True or False") + + with open(os.path.join(self.root, 'active_low'), FMODE) as f: + f.write(str(active_low)) + f.flush() + + def read(self): + self.value.seek(0) + # Subtracting 48 converts an ASCII "0" or "1" to an int + # ord("0") == 48 + return self.value.read()[0] - 48 + + def write(self, value): + # Convert any truthy value explicitly to HIGH and vice versa + # this is about 3x faster than int(bool(value)) + value = HIGH if value else LOW + # write as bytes, about 3x faster than string IO + self.value.write(b'1' if value else b'0') + # state.value.write(str(value).encode()) # Slow alternate for Python 2 + + def cleanup(self): + # Note: I have not put "cleanup" into the __del__ method since it's not + # always desireable to unexport pins at program exit. + self.value.close() + + if os.path.exists(self.root): + with _export_lock: + with open(GPIO_UNEXPORT, 'w') as f: + f.write(self.pin) + f.flush() + + +def cleanup(pin=None, assert_exists=False): + """Cleanup the pin by closing and unexporting it. + + Args: + pin (int, optional): either the pin to clean up or None (default). + If None, clean up all pins. + assert_exists: if True, raise a ValueError if the pin was not + setup. Otherwise, this function is a NOOP. + """ + if type(pin) in (list, tuple): + for p in pin: + cleanup(p, assert_exists=assert_exists) + return + + if pin is None: + # Iterate through the open pins, "cleanup" and "del" them. + for pin in list(_open_pins.keys()): + _open_pins[pin].cleanup() + del _open_pins[pin] + return + + try: + pin = int(pin) + except TypeError: + # This is a white lie, supporting "1" etc is a silent back-compat fix + raise TypeError("pin must be an int") + + if pin not in _open_pins: + if assert_exists: + raise ValueError("pin {} was not set up".format(pin)) + return + + _open_pins[pin].cleanup() + del _open_pins[pin] + + +def setup(pin, mode, pullup=None, initial=LOW, active_low=None): + '''Setup pin with mode IN or OUT. + + Args: + pin (int): + mode (str): use either gpio.OUT or gpio.IN + pullup (None): rpio compatibility. If anything but None, raises + value Error + initial (bool, optional): Initial pin value. Default is LOW + active_low (bool, optional): Set the pin to active low. Default + is None which leaves things as configured in sysfs + ''' + if type(pin) in (list, tuple): + for p in pin: + setup(p, mode, pullup=pullup, initial=initial, active_low=active_low) + return + + try: + pin = int(pin) + except TypeError: + # This is a white lie, supporting "1" etc is a silent back-compat fix + raise TypeError("pin must be an int") + + state = _open_pins.get(pin) + + # Attempt to create the pin if not set up + if state is None: + # GPIOPin will add itself to _open_pins + state = GPIOPin(pin) + + if pullup is not None: + raise ValueError("sysfs does not support pull up/down") + + state.set_direction(mode) + + if active_low is not None: + state.set_active_low(active_low) + + # RPi.GPIO accepts an "initial" pin state value of HIGH or LOW + # and sets the pin to that value during setup() + if mode == OUT: + set(pin, initial) + + +def mode(pin): + '''get the pin mode + + Returns: + str: "in" or "out" + ''' + + try: + pin = int(pin) + except TypeError: + # This is a white lie, supporting "1" etc is a silent back-compat fix + raise TypeError("pin must be an int") + + state = _open_pins.get(pin) + if not state: + raise ValueError("pin {} is not set up".format(pin)) + + return state.get_direction() + + +def read(pin): + '''read the pin value + + Returns: + bool: 0 or 1 + ''' + + # This costs us some read speed performance. + # If you want things to be faster use a GPIOPin instance directly. + try: + pin = int(pin) + except TypeError: + # This is a white lie, supporting "1" etc is a silent back-compat fix + raise TypeError("pin must be an int") + + state = _open_pins.get(pin) + if not state: + raise ValueError("pin {} is not set up".format(pin)) + + # These function calls lose us a little speed + # but we're already > 2x faster so... + # If you want things to be faster use a GPIOPin instance directly. + return state.read() + + +def write(pin, value): + '''set the pin value to 0 or 1''' + + # This costs us about 30KHz but preserves API support for str GPIO numbers + # If you want things to be faster use a GPIOPin instance directly. + try: + pin = int(pin) + except TypeError: + # This is a white lie, supporting "1" etc is a silent back-compat fix + raise TypeError("pin must be an int") + + state = _open_pins.get(pin) + if not state: + raise ValueError("pin {} is not set up".format(pin)) + + # These function calls lose us a little speed + # but we're already > 2x faster so... + # If you want things to be faster use a GPIOPin instance directly. + state.write(value) + + +def setwarnings(value): + '''exists for RPi.GPIO compatibility''' + pass + + +def setmode(value): + '''exists for RPi.GPIO compatibility''' + pass + + +input = read +output = write +set = write # TODO Set should be dropped, since it's a Python reserved word diff --git a/setup.cfg b/setup.cfg index b88034e..8fdd050 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,38 @@ +# -*- coding: utf-8 -*- [metadata] -description-file = README.md +name = gpio +version = 1.0.0 +author = Garrett Berg +author_email = garrett@cloudformdesign.com +description = gpio access via the standard linux sysfs interface +long_description = file: README.md +long_description_content_type = text/markdown +keywords = gpio libgpio +license = MIT +license_files = LICENSE.txt +project_urls = + GitHub = https://github.com/cloudformdesign/gpio +classifiers = + Intended Audience :: Developers + License :: OSI Approved :: MIT License + Operating System :: POSIX + Programming Language :: Python :: 2.7 + Programming Language :: Python :: 3 + Topic :: Software Development :: Embedded Systems + Topic :: Software Development :: Libraries :: Python Modules + +[options] +python_requires = >= 2.7 +packages = gpio +install_requires = + +[flake8] +exclude = + .tox, + .eggs, + .git, + __pycache__, + build, + dist +ignore = + E501 \ No newline at end of file diff --git a/setup.py b/setup.py index 26a11db..53d3ffc 100644 --- a/setup.py +++ b/setup.py @@ -1,38 +1,4 @@ -try: - from setuptools import setup -except ImportError: - from distutils.core import setup +# -*- coding: utf-8 -*- +from setuptools import setup -from gpio import __version__ - - -with open("README.md") as f: - ldesc = f.read() - -config = { - 'name': 'gpio', - 'author': 'Garrett Berg', - 'author_email': 'garrett@cloudformdesign.com', - 'version': __version__, - 'py_modules': ['gpio'], - 'license': 'MIT', - 'install_requires': [ - ], - 'extras_require': { - }, - 'description': "gpio access via the standard linux sysfs interface", - 'long_description': ldesc, - 'url': "https://github.com/cloudformdesign/gpio", - 'classifiers': [ - # 'Development Status :: 4 - Beta', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: POSIX', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Topic :: Software Development :: Embedded Systems', - 'Topic :: Software Development :: Libraries :: Python Modules', - ] -} - -setup(**config) +setup() From 3820ee4198ad5bb704e0bccf04812ac7af060efc Mon Sep 17 00:00:00 2001 From: Phil Howard Date: Thu, 12 Aug 2021 17:06:58 +0100 Subject: [PATCH 2/6] Improvements based on @vitiral feedback - Drop RPi.GPIO compatibility stubs - GPIOPin: Throw RuntimeError if pin is already configured - GPIOPin: Use FMODE for export/unexport - GPIOPin: Implicitly cast str pin to int - GPIOPin: new "configured" @staticmethod for retrieving existing pins - GPIOPin: active_low now writes "0"/"1" - GPIOPin: some docstrings + tweaks - GPIOPin: now handles removing itself from _open_pins - use "configured" in lieu of "set up" --- .coveragerc | 4 + .github/workflows/test.yml | 32 +++++++ .gitignore | 4 + gpio/__init__.py | 96 ++++++++++++------- setup.cfg | 8 +- tests/__init__.py | 1 - tests/__init__.pyc | Bin 144 -> 0 bytes tests/conftest.py | 19 ++++ tests/test_gpio.py | 187 +++++++++++++++++++++++-------------- tox.ini | 24 +++++ 10 files changed, 265 insertions(+), 110 deletions(-) create mode 100644 .coveragerc create mode 100644 .github/workflows/test.yml delete mode 100644 tests/__init__.py delete mode 100644 tests/__init__.pyc create mode 100644 tests/conftest.py create mode 100644 tox.ini diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..c1f72df --- /dev/null +++ b/.coveragerc @@ -0,0 +1,4 @@ +[run] +source = gpio +omit = + .tox/* \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..210a2a3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,32 @@ +name: Python Tests + +on: + pull_request: + push: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python: [2.7, 3.5, 3.7, 3.9] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + - name: Install Dependencies + run: | + python -m pip install --upgrade setuptools tox + - name: Run Tests + run: | + tox -e py + - name: Coverage + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python -m pip install coveralls + coveralls --service=github + if: ${{ matrix.python == '3.9' }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 61153fb..2d40554 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ __pycache__ *.pyc *.egg* +.coverage +.tox/ +build/ +dist/ diff --git a/gpio/__init__.py b/gpio/__init__.py index 265f957..4c7ef8b 100644 --- a/gpio/__init__.py +++ b/gpio/__init__.py @@ -15,7 +15,6 @@ FMODE = 'w+' # w+ overwrites and truncates existing files IN, OUT = 'in', 'out' LOW, HIGH = 0, 1 -BCM = None # Exists for RPi.GPIO compatibility class GPIOPin(object): @@ -24,22 +23,25 @@ class GPIOPin(object): Keeps track of file nodes and functions related to a pin. Args: - pin (int): the GPIO pin to set up + pin (int): the GPIO pin to configure """ def __init__(self, pin, mode=None, initial=LOW, active_low=None): - existing = _open_pins.get(pin) - if existing: - existing.cleanup() - del existing + try: + # Implicitly convert str to int, ie: "1" -> 1 + pin = int(pin) + except TypeError: + raise TypeError("pin must be an int") - self.pin = str(pin) - self.value = None + if _open_pins.get(pin): + raise RuntimeError("pin {} already configured".format(pin)) - self.root = os.path.join(GPIO_ROOT, 'gpio{0}'.format(pin)) + self.value = None + self.pin = str(pin) + self.root = os.path.join(GPIO_ROOT, 'gpio{0}'.format(self.pin)) if not os.path.exists(self.root): with _export_lock: - with open(GPIO_EXPORT, 'w') as f: + with open(GPIO_EXPORT, FMODE) as f: f.write(self.pin) f.flush() @@ -58,6 +60,24 @@ def __init__(self, pin, mode=None, initial=LOW, active_low=None): # Add class to open pins _open_pins[pin] = self + @staticmethod + def configured(pin): + """Get a configured GPIOPin instance where available. + + Args: + pin (int): the GPIO pin to configured + + Returns: + object: GPIOPin is configured, otherwise None + """ + try: + # Implicitly convert str to int, ie: "1" -> 1 + pin = int(pin) + except TypeError: + raise TypeError("pin must be an int") + + return _open_pins.get(pin) + def get_direction(self): '''Get the direction of pin @@ -90,16 +110,24 @@ def set_active_low(self, active_low): raise ValueError("active_low must be True or False") with open(os.path.join(self.root, 'active_low'), FMODE) as f: - f.write(str(active_low)) + f.write('1' if active_low else '0') f.flush() def read(self): + '''Read pin value''' self.value.seek(0) - # Subtracting 48 converts an ASCII "0" or "1" to an int - # ord("0") == 48 - return self.value.read()[0] - 48 + value = self.value.read() + try: + # Python > 3 - bytes + # Subtracting 48 converts an ASCII "0" or "1" to an int + # ord("0") == 48 + return value[0] - 48 + except TypeError: + # Python 2.x - str + return int(value) def write(self, value): + '''Write pin value''' # Convert any truthy value explicitly to HIGH and vice versa # this is about 3x faster than int(bool(value)) value = HIGH if value else LOW @@ -108,16 +136,24 @@ def write(self, value): # state.value.write(str(value).encode()) # Slow alternate for Python 2 def cleanup(self): + '''Clean up pin + + Unexports the pin and deletes it from the open list. + + ''' # Note: I have not put "cleanup" into the __del__ method since it's not # always desireable to unexport pins at program exit. + # Additionally "open" can be deleted *before* the GPIOPin instance. self.value.close() if os.path.exists(self.root): with _export_lock: - with open(GPIO_UNEXPORT, 'w') as f: + with open(GPIO_UNEXPORT, FMODE) as f: f.write(self.pin) f.flush() + del _open_pins[int(self.pin)] + def cleanup(pin=None, assert_exists=False): """Cleanup the pin by closing and unexporting it. @@ -137,7 +173,6 @@ def cleanup(pin=None, assert_exists=False): # Iterate through the open pins, "cleanup" and "del" them. for pin in list(_open_pins.keys()): _open_pins[pin].cleanup() - del _open_pins[pin] return try: @@ -148,7 +183,7 @@ def cleanup(pin=None, assert_exists=False): if pin not in _open_pins: if assert_exists: - raise ValueError("pin {} was not set up".format(pin)) + raise ValueError("pin {} was not configured".format(pin)) return _open_pins[pin].cleanup() @@ -180,7 +215,7 @@ def setup(pin, mode, pullup=None, initial=LOW, active_low=None): state = _open_pins.get(pin) - # Attempt to create the pin if not set up + # Attempt to create the pin if not configured if state is None: # GPIOPin will add itself to _open_pins state = GPIOPin(pin) @@ -214,7 +249,7 @@ def mode(pin): state = _open_pins.get(pin) if not state: - raise ValueError("pin {} is not set up".format(pin)) + raise ValueError("pin {} is not configured".format(pin)) return state.get_direction() @@ -223,7 +258,7 @@ def read(pin): '''read the pin value Returns: - bool: 0 or 1 + bool: LOW or HIGH ''' # This costs us some read speed performance. @@ -236,7 +271,7 @@ def read(pin): state = _open_pins.get(pin) if not state: - raise ValueError("pin {} is not set up".format(pin)) + raise ValueError("pin {} is not configured".format(pin)) # These function calls lose us a little speed # but we're already > 2x faster so... @@ -245,7 +280,12 @@ def read(pin): def write(pin, value): - '''set the pin value to 0 or 1''' + '''set the pin value to LOW or HIGH + + Args: + pin (int): any configured pin + value (bool): LOW or HIGH + ''' # This costs us about 30KHz but preserves API support for str GPIO numbers # If you want things to be faster use a GPIOPin instance directly. @@ -257,7 +297,7 @@ def write(pin, value): state = _open_pins.get(pin) if not state: - raise ValueError("pin {} is not set up".format(pin)) + raise ValueError("pin {} is not configured".format(pin)) # These function calls lose us a little speed # but we're already > 2x faster so... @@ -265,16 +305,6 @@ def write(pin, value): state.write(value) -def setwarnings(value): - '''exists for RPi.GPIO compatibility''' - pass - - -def setmode(value): - '''exists for RPi.GPIO compatibility''' - pass - - input = read output = write set = write # TODO Set should be dropped, since it's a Python reserved word diff --git a/setup.cfg b/setup.cfg index 8fdd050..39054ab 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,16 +2,16 @@ [metadata] name = gpio version = 1.0.0 -author = Garrett Berg -author_email = garrett@cloudformdesign.com +author = Garrett Berg, Phil Howard +author_email = phil@pimoroni.com description = gpio access via the standard linux sysfs interface long_description = file: README.md long_description_content_type = text/markdown -keywords = gpio libgpio +keywords = gpio sysfs linux license = MIT license_files = LICENSE.txt project_urls = - GitHub = https://github.com/cloudformdesign/gpio + GitHub = https://github.com/vitiral/gpio classifiers = Intended Audience :: Developers License :: OSI Approved :: MIT License diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 40a96af..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# -*- coding: utf-8 -*- diff --git a/tests/__init__.pyc b/tests/__init__.pyc deleted file mode 100644 index 47e2037042932f684048638071dd7c8669c9acbb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 144 zcmZSn%*&;k^*=3.1 + pytest-cov + +[testenv:qa] +commands = + check-manifest --ignore tox.ini,tests/*,.coveragerc + python setup.py sdist bdist_wheel + twine check dist/* + flake8 +deps = + check-manifest + flake8 + twine \ No newline at end of file From f2c0831ea58bfd452eb2100b1048c5551302aecd Mon Sep 17 00:00:00 2001 From: Phil Howard Date: Thu, 12 Aug 2021 17:54:30 +0100 Subject: [PATCH 3/6] More improvements - Remove duplicate code - Allow "configured" to raise a RuntimeError directly for unconfigured pins - Test coverage for cleanup + multi-pin cleanup - Improve cleanup with no arguments - Cut back cleanup to single flow, no recursion - Store pin in class as int (this was an unecessary optimisation) --- gpio/__init__.py | 117 ++++++++++++--------------------------------- tests/test_gpio.py | 35 +++++++++++++- 2 files changed, 65 insertions(+), 87 deletions(-) diff --git a/gpio/__init__.py b/gpio/__init__.py index 4c7ef8b..d2e935d 100644 --- a/gpio/__init__.py +++ b/gpio/__init__.py @@ -26,23 +26,18 @@ class GPIOPin(object): pin (int): the GPIO pin to configure """ def __init__(self, pin, mode=None, initial=LOW, active_low=None): - try: - # Implicitly convert str to int, ie: "1" -> 1 - pin = int(pin) - except TypeError: - raise TypeError("pin must be an int") - - if _open_pins.get(pin): - raise RuntimeError("pin {} already configured".format(pin)) + # .configured() will raise a TypeError if "pin" is not convertable to int + if GPIOPin.configured(pin, False) is not None: + raise RuntimeError("pin {} is already configured".format(pin)) self.value = None - self.pin = str(pin) + self.pin = int(pin) self.root = os.path.join(GPIO_ROOT, 'gpio{0}'.format(self.pin)) if not os.path.exists(self.root): with _export_lock: with open(GPIO_EXPORT, FMODE) as f: - f.write(self.pin) + f.write(str(self.pin)) f.flush() # Using unbuffered binary IO is ~ 3x faster than text @@ -58,17 +53,21 @@ def __init__(self, pin, mode=None, initial=LOW, active_low=None): self.write(initial) # Add class to open pins - _open_pins[pin] = self + _open_pins[self.pin] = self @staticmethod - def configured(pin): + def configured(pin, assert_configured=True): """Get a configured GPIOPin instance where available. Args: pin (int): the GPIO pin to configured + assert_configured (bool): True to raise exception if pin unconfigured Returns: - object: GPIOPin is configured, otherwise None + object: GPIOPin if configured, otherwise None + + Raises: + RuntimeError: if pin is not configured """ try: # Implicitly convert str to int, ie: "1" -> 1 @@ -76,6 +75,9 @@ def configured(pin): except TypeError: raise TypeError("pin must be an int") + if pin not in _open_pins and assert_configured: + raise RuntimeError("pin {} is not configured".format(pin)) + return _open_pins.get(pin) def get_direction(self): @@ -117,12 +119,12 @@ def read(self): '''Read pin value''' self.value.seek(0) value = self.value.read() - try: + try: # Python > 3 - bytes # Subtracting 48 converts an ASCII "0" or "1" to an int # ord("0") == 48 return value[0] - 48 - except TypeError: + except TypeError: # Python 2.x - str return int(value) @@ -149,10 +151,10 @@ def cleanup(self): if os.path.exists(self.root): with _export_lock: with open(GPIO_UNEXPORT, FMODE) as f: - f.write(self.pin) + f.write(str(self.pin)) f.flush() - del _open_pins[int(self.pin)] + del _open_pins[self.pin] def cleanup(pin=None, assert_exists=False): @@ -164,30 +166,17 @@ def cleanup(pin=None, assert_exists=False): assert_exists: if True, raise a ValueError if the pin was not setup. Otherwise, this function is a NOOP. """ - if type(pin) in (list, tuple): - for p in pin: - cleanup(p, assert_exists=assert_exists) - return - if pin is None: - # Iterate through the open pins, "cleanup" and "del" them. - for pin in list(_open_pins.keys()): - _open_pins[pin].cleanup() - return + pin = list(_open_pins.keys()) - try: - pin = int(pin) - except TypeError: - # This is a white lie, supporting "1" etc is a silent back-compat fix - raise TypeError("pin must be an int") + if type(pin) not in (list, tuple): + pin = [pin] - if pin not in _open_pins: - if assert_exists: - raise ValueError("pin {} was not configured".format(pin)) - return + for p in pin: + state = GPIOPin.configured(p, assert_exists) - _open_pins[pin].cleanup() - del _open_pins[pin] + if state is not None: + state.cleanup() def setup(pin, mode, pullup=None, initial=LOW, active_low=None): @@ -207,18 +196,11 @@ def setup(pin, mode, pullup=None, initial=LOW, active_low=None): setup(p, mode, pullup=pullup, initial=initial, active_low=active_low) return - try: - pin = int(pin) - except TypeError: - # This is a white lie, supporting "1" etc is a silent back-compat fix - raise TypeError("pin must be an int") - - state = _open_pins.get(pin) + state = GPIOPin.configured(pin, False) # Attempt to create the pin if not configured if state is None: - # GPIOPin will add itself to _open_pins - state = GPIOPin(pin) + state = GPIOPin(pin) # GPIOPin will add itself to _open_pins if pullup is not None: raise ValueError("sysfs does not support pull up/down") @@ -240,18 +222,7 @@ def mode(pin): Returns: str: "in" or "out" ''' - - try: - pin = int(pin) - except TypeError: - # This is a white lie, supporting "1" etc is a silent back-compat fix - raise TypeError("pin must be an int") - - state = _open_pins.get(pin) - if not state: - raise ValueError("pin {} is not configured".format(pin)) - - return state.get_direction() + return GPIOPin.configured(pin).get_direction() def read(pin): @@ -260,23 +231,10 @@ def read(pin): Returns: bool: LOW or HIGH ''' - - # This costs us some read speed performance. - # If you want things to be faster use a GPIOPin instance directly. - try: - pin = int(pin) - except TypeError: - # This is a white lie, supporting "1" etc is a silent back-compat fix - raise TypeError("pin must be an int") - - state = _open_pins.get(pin) - if not state: - raise ValueError("pin {} is not configured".format(pin)) - # These function calls lose us a little speed # but we're already > 2x faster so... # If you want things to be faster use a GPIOPin instance directly. - return state.read() + return GPIOPin.configured(pin).read() def write(pin, value): @@ -286,23 +244,10 @@ def write(pin, value): pin (int): any configured pin value (bool): LOW or HIGH ''' - - # This costs us about 30KHz but preserves API support for str GPIO numbers - # If you want things to be faster use a GPIOPin instance directly. - try: - pin = int(pin) - except TypeError: - # This is a white lie, supporting "1" etc is a silent back-compat fix - raise TypeError("pin must be an int") - - state = _open_pins.get(pin) - if not state: - raise ValueError("pin {} is not configured".format(pin)) - # These function calls lose us a little speed # but we're already > 2x faster so... # If you want things to be faster use a GPIOPin instance directly. - state.write(value) + GPIOPin.configured(pin).write(value) input = read diff --git a/tests/test_gpio.py b/tests/test_gpio.py index c49ef9c..de76b5e 100644 --- a/tests/test_gpio.py +++ b/tests/test_gpio.py @@ -38,6 +38,34 @@ def test_rpio_already_setup(gpio, patch_open): gpio.GPIOPin(10, gpio.OUT) +def test_rpio_cleanup_all(gpio, patch_open): + gpio.setup(10, gpio.OUT) + gpio.setup(11, gpio.OUT) + gpio.cleanup() + assert gpio.GPIOPin.configured(10, False) is None + assert gpio.GPIOPin.configured(11, False) is None + + +def test_rpio_cleanup_list(gpio, patch_open): + gpio.setup(10, gpio.OUT) + gpio.setup(11, gpio.OUT) + gpio.setup(12, gpio.OUT) + gpio.cleanup([10, 11]) + assert gpio.GPIOPin.configured(10, False) is None + assert gpio.GPIOPin.configured(11, False) is None + assert gpio.GPIOPin.configured(12, False) is not None + + +def test_rpio_invalid_cleanup(gpio, patch_open): + with pytest.raises(RuntimeError): + gpio.cleanup(10, True) + +def test_rpio_invalid_cleanup_list(gpio, patch_open): + gpio.setup(10, gpio.OUT) + with pytest.raises(RuntimeError): + gpio.cleanup([10, 11], True) + + def test_setup_class_registers_self(gpio, patch_open): pin = gpio.GPIOPin(10, gpio.OUT) assert gpio.GPIOPin.configured(10) == pin @@ -57,7 +85,7 @@ def test_cleanup_class_unregisters_self(gpio, patch_open): pin = gpio.GPIOPin(10, gpio.OUT) patch_open.reset_mock() pin.cleanup() - assert gpio.GPIOPin.configured(10) == None + assert gpio.GPIOPin.configured(10, False) == None def test_set_direction(gpio, patch_open): @@ -87,6 +115,11 @@ def test_set_active_low(gpio, patch_open): )) +def test_unconfigured_runtimeerror(gpio, patch_open): + with pytest.raises(RuntimeError): + pin = gpio.GPIOPin.configured(10) + + def test_write(gpio, patch_open): pin = gpio.GPIOPin(10, gpio.OUT) From cf121dafb0a6ca3bbf2a08d98cbd85dfc1f44415 Mon Sep 17 00:00:00 2001 From: Phil Howard Date: Thu, 12 Aug 2021 18:27:36 +0100 Subject: [PATCH 4/6] Improve setup, docstrings, simplify code - Add setup method to GPIOPin - Simplify gpio.setup, avoid recursion, use GPIOPin.setup - Improve docstrings --- gpio/__init__.py | 86 ++++++++++++++++++++++++++-------------------- tests/test_gpio.py | 3 ++ 2 files changed, 52 insertions(+), 37 deletions(-) diff --git a/gpio/__init__.py b/gpio/__init__.py index d2e935d..e813ad9 100644 --- a/gpio/__init__.py +++ b/gpio/__init__.py @@ -20,10 +20,16 @@ class GPIOPin(object): """Handle pin state. - Keeps track of file nodes and functions related to a pin. + Create a singleton instance of a GPIOPin(n) and track its state internally. Args: - pin (int): the GPIO pin to configure + pin (int): the pin to configure + mode (str): use either gpio.OUT or gpio.IN + initial (bool, optional): Initial pin value. Default is LOW + active_low (bool, optional): Set the pin to active low. Default + is None which leaves things as configured in sysfs + Raises: + RuntimeError: if pin is already configured """ def __init__(self, pin, mode=None, initial=LOW, active_low=None): # .configured() will raise a TypeError if "pin" is not convertable to int @@ -43,6 +49,13 @@ def __init__(self, pin, mode=None, initial=LOW, active_low=None): # Using unbuffered binary IO is ~ 3x faster than text self.value = open(os.path.join(self.root, 'value'), 'wb+', buffering=0) + # I hate manually calling .setup()! + self.setup(mode, initial, active_low) + + # Add class to open pins + _open_pins[self.pin] = self + + def setup(self, mode=None, initial=LOW, active_low=None): if mode is not None: self.set_direction(mode) @@ -52,15 +65,12 @@ def __init__(self, pin, mode=None, initial=LOW, active_low=None): if mode == OUT: self.write(initial) - # Add class to open pins - _open_pins[self.pin] = self - @staticmethod def configured(pin, assert_configured=True): """Get a configured GPIOPin instance where available. Args: - pin (int): the GPIO pin to configured + pin (int): the pin to check assert_configured (bool): True to raise exception if pin unconfigured Returns: @@ -93,7 +103,7 @@ def set_direction(self, mode): '''Set the direction of pin Args: - mode (str): "in" or "out" + mode (str): use either gpio.OUT or gpio.IN ''' if mode not in (IN, OUT, LOW, HIGH): raise ValueError("Unsupported pin mode {}".format(mode)) @@ -106,7 +116,7 @@ def set_active_low(self, active_low): '''Set the direction of pin Args: - mode (bool): True/False + mode (bool): True = active low / False = active high ''' if not isinstance(active_low, bool): raise ValueError("active_low must be True or False") @@ -116,7 +126,11 @@ def set_active_low(self, active_low): f.flush() def read(self): - '''Read pin value''' + '''Read pin value + + Returns: + int: gpio.HIGH or gpio.LOW + ''' self.value.seek(0) value = self.value.read() try: @@ -129,7 +143,11 @@ def read(self): return int(value) def write(self, value): - '''Write pin value''' + '''Write pin value + + Args: + value (bool): use either gpio.HIGH or gpio.LOW + ''' # Convert any truthy value explicitly to HIGH and vice versa # this is about 3x faster than int(bool(value)) value = HIGH if value else LOW @@ -166,20 +184,23 @@ def cleanup(pin=None, assert_exists=False): assert_exists: if True, raise a ValueError if the pin was not setup. Otherwise, this function is a NOOP. """ - if pin is None: - pin = list(_open_pins.keys()) + # Note: since "pin" is a kwarg in this function, it has not been renamed it to "pins" above + pins = pin + + if pins is None: + pins = list(_open_pins.keys()) - if type(pin) not in (list, tuple): - pin = [pin] + if type(pins) not in (list, tuple): + pins = [pins] - for p in pin: - state = GPIOPin.configured(p, assert_exists) + for pin in pins: + state = GPIOPin.configured(pin, assert_exists) if state is not None: - state.cleanup() + state.cleanup() # GPIOPin will remove itself from _open_pins -def setup(pin, mode, pullup=None, initial=LOW, active_low=None): +def setup(pins, mode, pullup=None, initial=LOW, active_low=None): '''Setup pin with mode IN or OUT. Args: @@ -191,29 +212,20 @@ def setup(pin, mode, pullup=None, initial=LOW, active_low=None): active_low (bool, optional): Set the pin to active low. Default is None which leaves things as configured in sysfs ''' - if type(pin) in (list, tuple): - for p in pin: - setup(p, mode, pullup=pullup, initial=initial, active_low=active_low) - return - - state = GPIOPin.configured(pin, False) - - # Attempt to create the pin if not configured - if state is None: - state = GPIOPin(pin) # GPIOPin will add itself to _open_pins + if type(pins) not in (list, tuple): + pins = [pins] if pullup is not None: raise ValueError("sysfs does not support pull up/down") - state.set_direction(mode) + for pin in pins: + state = GPIOPin.configured(pin, False) - if active_low is not None: - state.set_active_low(active_low) + # Attempt to create the pin if not configured + if state is None: + state = GPIOPin(pin) # GPIOPin will add itself to _open_pins - # RPi.GPIO accepts an "initial" pin state value of HIGH or LOW - # and sets the pin to that value during setup() - if mode == OUT: - set(pin, initial) + state.setup(mode, initial, active_low) def mode(pin): @@ -229,7 +241,7 @@ def read(pin): '''read the pin value Returns: - bool: LOW or HIGH + bool: either gpio.LOW or gpio.HIGH ''' # These function calls lose us a little speed # but we're already > 2x faster so... @@ -242,7 +254,7 @@ def write(pin, value): Args: pin (int): any configured pin - value (bool): LOW or HIGH + value (bool): use gpio.LOW or gpio.HIGH ''' # These function calls lose us a little speed # but we're already > 2x faster so... diff --git a/tests/test_gpio.py b/tests/test_gpio.py index de76b5e..69797e2 100644 --- a/tests/test_gpio.py +++ b/tests/test_gpio.py @@ -33,6 +33,9 @@ def test_class_already_setup(gpio, patch_open): def test_rpio_already_setup(gpio, patch_open): gpio.setup(10, gpio.OUT) + # Running gpio.setup again should not raise an error + # in RPi.GPIO this may raise a warning + gpio.setup(10, gpio.OUT) with pytest.raises(RuntimeError): gpio.GPIOPin(10, gpio.OUT) From 7ad240075f4704f9a98017a7935bcec989fdb94e Mon Sep 17 00:00:00 2001 From: Phil Howard Date: Thu, 12 Aug 2021 18:53:03 +0100 Subject: [PATCH 5/6] Test coverage and fixes - Test rpio equivilents of GPIOPin functions - Test invalid pins - Test error when trying to configure pullup - Try to use constants in tests - GPIOPin "mode" is now "direction" - GPIOPin catch ValueError for implicit str to int pin conversion - GPIOPin "active_low" "direction" is now "polarity" - Note: Can't test python2.7 in python3 and vice versa --- gpio/__init__.py | 19 +++++----- tests/test_gpio.py | 87 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 89 insertions(+), 17 deletions(-) diff --git a/gpio/__init__.py b/gpio/__init__.py index e813ad9..3468363 100644 --- a/gpio/__init__.py +++ b/gpio/__init__.py @@ -31,7 +31,7 @@ class GPIOPin(object): Raises: RuntimeError: if pin is already configured """ - def __init__(self, pin, mode=None, initial=LOW, active_low=None): + def __init__(self, pin, direction=None, initial=LOW, active_low=None): # .configured() will raise a TypeError if "pin" is not convertable to int if GPIOPin.configured(pin, False) is not None: raise RuntimeError("pin {} is already configured".format(pin)) @@ -50,19 +50,19 @@ def __init__(self, pin, mode=None, initial=LOW, active_low=None): self.value = open(os.path.join(self.root, 'value'), 'wb+', buffering=0) # I hate manually calling .setup()! - self.setup(mode, initial, active_low) + self.setup(direction, initial, active_low) # Add class to open pins _open_pins[self.pin] = self - def setup(self, mode=None, initial=LOW, active_low=None): - if mode is not None: - self.set_direction(mode) + def setup(self, direction=None, initial=LOW, active_low=None): + if direction is not None: + self.set_direction(direction) if active_low is not None: self.set_active_low(active_low) - if mode == OUT: + if direction == OUT: self.write(initial) @staticmethod @@ -82,8 +82,8 @@ def configured(pin, assert_configured=True): try: # Implicitly convert str to int, ie: "1" -> 1 pin = int(pin) - except TypeError: - raise TypeError("pin must be an int") + except (TypeError, ValueError): + raise ValueError("pin must be an int") if pin not in _open_pins and assert_configured: raise RuntimeError("pin {} is not configured".format(pin)) @@ -113,7 +113,7 @@ def set_direction(self, mode): f.flush() def set_active_low(self, active_low): - '''Set the direction of pin + '''Set the polarity of pin Args: mode (bool): True = active low / False = active high @@ -200,6 +200,7 @@ def cleanup(pin=None, assert_exists=False): state.cleanup() # GPIOPin will remove itself from _open_pins +# TODO RPi.GPIO uses "pull_up_down", does rpio differ? def setup(pins, mode, pullup=None, initial=LOW, active_low=None): '''Setup pin with mode IN or OUT. diff --git a/tests/test_gpio.py b/tests/test_gpio.py index 69797e2..587e31a 100644 --- a/tests/test_gpio.py +++ b/tests/test_gpio.py @@ -10,7 +10,7 @@ def test_setup_rpio(gpio, patch_open): patch_open.assert_any_call('/sys/class/gpio/gpio10/value', 'wb+', buffering=0) patch_open.assert_any_call('/sys/class/gpio/gpio10/direction', 'w+') - patch_open().__enter__().write.assert_any_call('out') + patch_open().__enter__().write.assert_any_call(str(gpio.OUT)) def test_setup_class(gpio, patch_open): @@ -21,7 +21,12 @@ def test_setup_class(gpio, patch_open): patch_open.assert_any_call('/sys/class/gpio/gpio10/value', 'wb+', buffering=0) patch_open.assert_any_call('/sys/class/gpio/gpio10/direction', 'w+') - patch_open().__enter__().write.assert_any_call('out') + patch_open().__enter__().write.assert_any_call(str(gpio.OUT)) + + +def test_setup_with_pull(gpio, patch_open): + with pytest.raises(ValueError): + gpio.setup(10, gpio.OUT, pullup=1) def test_class_already_setup(gpio, patch_open): @@ -63,6 +68,7 @@ def test_rpio_invalid_cleanup(gpio, patch_open): with pytest.raises(RuntimeError): gpio.cleanup(10, True) + def test_rpio_invalid_cleanup_list(gpio, patch_open): gpio.setup(10, gpio.OUT) with pytest.raises(RuntimeError): @@ -84,6 +90,14 @@ def test_cleanup_class_unexports_pin(gpio, patch_open): patch_open().__enter__().write.assert_any_call('10') +def test_setup_pin_is_not_int(gpio, patch_open): + with pytest.raises(ValueError): + gpio.setup('', gpio.OUT) + + with pytest.raises(ValueError): + pin = gpio.GPIOPin('', gpio.OUT) + + def test_cleanup_class_unregisters_self(gpio, patch_open): pin = gpio.GPIOPin(10, gpio.OUT) patch_open.reset_mock() @@ -96,8 +110,8 @@ def test_set_direction(gpio, patch_open): patch_open.reset_mock() pin.set_direction(gpio.OUT) pin.set_direction(gpio.IN) - patch_open().__enter__().write.assert_any_call('out') - patch_open().__enter__().write.assert_any_call('in') + patch_open().__enter__().write.assert_any_call(str(gpio.OUT)) + patch_open().__enter__().write.assert_any_call(str(gpio.IN)) def test_set_active_low(gpio, patch_open): @@ -117,6 +131,51 @@ def test_set_active_low(gpio, patch_open): mock.call().__enter__().flush(), )) + with pytest.raises(ValueError): + pin.set_active_low('') + + +def test_setup_active_low(gpio, patch_open): + pin = gpio.GPIOPin(10, gpio.OUT, active_low=False) + patch_open.assert_has_calls(( + mock.call().__enter__().write('0'), + mock.call().__enter__().flush(), + )) + pin.cleanup() + + patch_open.reset_mock() + pin = gpio.GPIOPin(10, gpio.OUT, active_low=True) + patch_open.assert_has_calls(( + mock.call().__enter__().write('1'), + mock.call().__enter__().flush(), + )) + + +def test_get_direction(gpio, patch_open): + pin = gpio.GPIOPin(10, gpio.IN) + + patch_open().__enter__().read.return_value = 'in\n' + assert pin.get_direction() == gpio.IN + assert gpio.mode(10) == gpio.IN + + patch_open().__enter__().read.return_value = 'out\n' + assert pin.get_direction() == gpio.OUT + assert gpio.mode(10) == gpio.OUT + + +def test_set_direction(gpio, patch_open): + pin = gpio.GPIOPin(10, gpio.IN) + + for direction in (gpio.IN, gpio.OUT): + patch_open.reset_mock() + pin.set_direction(direction) + patch_open.assert_has_calls(( + mock.call().__enter__().write(direction), + )) + + with pytest.raises(ValueError): + pin.set_direction('') + def test_unconfigured_runtimeerror(gpio, patch_open): with pytest.raises(RuntimeError): @@ -132,20 +191,32 @@ def test_write(gpio, patch_open): mock.call().write(b'0'), )) + patch_open.reset_mock() + gpio.write(10, False) + patch_open.assert_has_calls(( + mock.call().write(b'0'), + )) + patch_open.reset_mock() pin.write(True) patch_open.assert_has_calls(( mock.call().write(b'1'), )) + patch_open.reset_mock() + gpio.write(10, True) + patch_open.assert_has_calls(( + mock.call().write(b'1'), + )) + def test_read(gpio, patch_open): pin = gpio.GPIOPin(10, gpio.IN) patch_open().read.return_value = b'1\n' - value = pin.read() - assert value == gpio.HIGH + assert pin.read() == gpio.HIGH + assert gpio.read(10) == gpio.HIGH patch_open().read.return_value = b'0\n' - value = pin.read() - assert value == gpio.LOW + assert pin.read() == gpio.LOW + assert gpio.read(10) == gpio.LOW From 613935160c5eded2cecbbf8862e96fe1bbc93234 Mon Sep 17 00:00:00 2001 From: Phil Howard Date: Thu, 12 Aug 2021 19:51:34 +0100 Subject: [PATCH 6/6] Support generators in setup/cleanup - Support generators such as dict.keys() - Add comment to explain the use of list(dict.keys()) in cleanup - Add tests for generators - Use None instead of '' in tests, since '' results in an empty iterable and a quiet noop in cleanup/setup --- gpio/__init__.py | 13 +++++++------ tests/test_gpio.py | 21 +++++++++++++++++---- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/gpio/__init__.py b/gpio/__init__.py index 3468363..c6f37f7 100644 --- a/gpio/__init__.py +++ b/gpio/__init__.py @@ -2,6 +2,10 @@ __version__ = '1.0.0' from threading import Lock +try: + from collections.abc import Iterable +except ImportError: + from collections import Iterable import os @@ -148,12 +152,8 @@ def write(self, value): Args: value (bool): use either gpio.HIGH or gpio.LOW ''' - # Convert any truthy value explicitly to HIGH and vice versa - # this is about 3x faster than int(bool(value)) - value = HIGH if value else LOW # write as bytes, about 3x faster than string IO self.value.write(b'1' if value else b'0') - # state.value.write(str(value).encode()) # Slow alternate for Python 2 def cleanup(self): '''Clean up pin @@ -188,9 +188,10 @@ def cleanup(pin=None, assert_exists=False): pins = pin if pins is None: + # Must be converted to a list since _open_pins is potentially modified below pins = list(_open_pins.keys()) - if type(pins) not in (list, tuple): + if not isinstance(pins, Iterable): pins = [pins] for pin in pins: @@ -213,7 +214,7 @@ def setup(pins, mode, pullup=None, initial=LOW, active_low=None): active_low (bool, optional): Set the pin to active low. Default is None which leaves things as configured in sysfs ''' - if type(pins) not in (list, tuple): + if not isinstance(pins, Iterable): pins = [pins] if pullup is not None: diff --git a/tests/test_gpio.py b/tests/test_gpio.py index 587e31a..5120885 100644 --- a/tests/test_gpio.py +++ b/tests/test_gpio.py @@ -24,6 +24,19 @@ def test_setup_class(gpio, patch_open): patch_open().__enter__().write.assert_any_call(str(gpio.OUT)) +def test_setup_rpio_list(gpio, patch_open): + gpio.setup([9, 10, 11], gpio.OUT) + + +def test_setup_rpio_tuple(gpio, patch_open): + gpio.setup((9, 10, 11), gpio.OUT) + + +def test_setup_rpio_generator(gpio, patch_open): + pins = {9: 9, 10: 10, 11: 11} + gpio.setup(pins.keys(), gpio.OUT) + + def test_setup_with_pull(gpio, patch_open): with pytest.raises(ValueError): gpio.setup(10, gpio.OUT, pullup=1) @@ -92,10 +105,10 @@ def test_cleanup_class_unexports_pin(gpio, patch_open): def test_setup_pin_is_not_int(gpio, patch_open): with pytest.raises(ValueError): - gpio.setup('', gpio.OUT) + gpio.setup(None, gpio.OUT) with pytest.raises(ValueError): - pin = gpio.GPIOPin('', gpio.OUT) + pin = gpio.GPIOPin(None, gpio.OUT) def test_cleanup_class_unregisters_self(gpio, patch_open): @@ -132,7 +145,7 @@ def test_set_active_low(gpio, patch_open): )) with pytest.raises(ValueError): - pin.set_active_low('') + pin.set_active_low(None) def test_setup_active_low(gpio, patch_open): @@ -174,7 +187,7 @@ def test_set_direction(gpio, patch_open): )) with pytest.raises(ValueError): - pin.set_direction('') + pin.set_direction(None) def test_unconfigured_runtimeerror(gpio, patch_open):