diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..87200ef --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,41 @@ +name: Build + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + name: Python ${{ matrix.python }} + runs-on: ubuntu-latest + strategy: + matrix: + python: ['3.9', '3.10', '3.11'] + + env: + RELEASE_FILE: ${{ github.event.repository.name }}-${{ github.event.release.tag_name || github.sha }}-py${{ matrix.python }} + + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python }} + + - name: Install Dependencies + run: | + make dev-deps + + - name: Build Packages + run: | + make build + + - name: Upload Packages + uses: actions/upload-artifact@v3 + with: + name: ${{ env.RELEASE_FILE }} + path: dist/ diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml new file mode 100644 index 0000000..4f85883 --- /dev/null +++ b/.github/workflows/qa.yml @@ -0,0 +1,36 @@ +name: QA + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + name: linting & spelling + runs-on: ubuntu-latest + + env: + TERM: xterm-256color + + steps: + - name: Checkout Code + uses: actions/checkout@v2 + + - name: Set up Python '3,11' + uses: actions/setup-python@v3 + with: + python-version: '3.11' + + - name: Install Dependencies + run: | + make dev-deps + + - name: Run Quality Assurance + run: | + make qa + + - name: Run Code Checks + run: | + make check diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..016a678 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,41 @@ +name: Tests + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + name: Python ${{ matrix.python }} + runs-on: ubuntu-latest + strategy: + matrix: + python: ['3.9', '3.10', '3.11'] + + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python }} + + - name: Install Dependencies + run: | + make dev-deps + + - name: Run Tests + run: | + make pytest + + - name: Coverage + if: ${{ matrix.python == '3.9' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python -m pip install coveralls + coveralls --service=github + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa45562 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +build/ +_build/ +*.o +*.so +*.a +*.py[cod] +*.egg-info +dist/ +__pycache__ +.DS_Store +*.deb +*.dsc +*.build +*.changes +*.orig.* +packaging/*tar.xz +library/debian/ +.coverage +.pytest_cache +.tox diff --git a/.stickler.yml b/.stickler.yml new file mode 100644 index 0000000..2466815 --- /dev/null +++ b/.stickler.yml @@ -0,0 +1,5 @@ +--- +linters: + flake8: + python: 3 + max-line-length: 160 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a9e0187 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +0.0.1 +----- + +* Initial Release diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..edd3445 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Pimoroni Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9e0c15c --- /dev/null +++ b/Makefile @@ -0,0 +1,60 @@ +LIBRARY_NAME := $(shell hatch project metadata name 2> /dev/null) +LIBRARY_VERSION := $(shell hatch version 2> /dev/null) + +.PHONY: usage install uninstall check pytest qa build-deps check tag wheel sdist clean dist testdeploy deploy +usage: +ifdef LIBRARY_NAME + @echo "Library: ${LIBRARY_NAME}" + @echo "Version: ${LIBRARY_VERSION}\n" +else + @echo "WARNING: You should 'make dev-deps'\n" +endif + @echo "Usage: make , where target is one of:\n" + @echo "install: install the library locally from source" + @echo "uninstall: uninstall the local library" + @echo "dev-deps: install Python dev dependencies" + @echo "check: perform basic integrity checks on the codebase" + @echo "qa: run linting and package QA" + @echo "pytest: run Python test fixtures" + @echo "clean: clean Python build and dist directories" + @echo "build: build Python distribution files" + @echo "testdeploy: build and upload to test PyPi" + @echo "deploy: build and upload to PyPi" + @echo "tag: tag the repository with the current version\n" + +install: + ./install.sh --unstable + +uninstall: + ./uninstall.sh + +dev-deps: + python3 -m pip install -r requirements-dev.txt + sudo apt install dos2unix + +check: + @bash check.sh + +qa: + tox -e qa + +pytest: + tox -e py + +nopost: + @bash check.sh --nopost + +tag: + git tag -a "v${LIBRARY_VERSION}" -m "Version ${LIBRARY_VERSION}" + +build: check + @hatch build + +clean: + -rm -r dist + +testdeploy: build + twine upload --repository testpypi dist/* + +deploy: nopost build + twine upload dist/* diff --git a/README.md b/README.md new file mode 100644 index 0000000..e45c499 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# gpiodevice + +[![Build Status](https://img.shields.io/github/actions/workflow/status/pimoroni/gpiodevice-python/test.yml?branch=main)](https://github.com/pimoroni/gpiodevice-python/actions/workflows/test.yml) +[![Coverage Status](https://coveralls.io/repos/github/pimoroni/gpiodevice-python/badge.svg?branch=master)](https://coveralls.io/github/pimoroni/gpiodevice-python?branch=master) +[![PyPi Package](https://img.shields.io/pypi/v/gpiodevice.svg)](https://pypi.python.org/pypi/gpiodevice) +[![Python Versions](https://img.shields.io/pypi/pyversions/gpiodevice.svg)](https://pypi.python.org/pypi/gpiodevice) + +A GPIO counterpart to [i2cdevice](https://github.com/pimoroni/i2cdevice-python), generated from [the Pimoroni Python Boilerplate](https://github.com/pimoroni/boilerplate-python). + +## What is gpiodevice? + +gpiodevice is a simple middleware library intended to make some user-facing aspects of interfacing with Linux's GPIO character device ABI (via gpiod) simpler and friendlier. + +gpiodevice is not intended to replace gpiod, but collects some common patterns into a reusable library for GPIO-based Python projects. + diff --git a/check.sh b/check.sh new file mode 100755 index 0000000..cbb1565 --- /dev/null +++ b/check.sh @@ -0,0 +1,87 @@ +#!/bin/bash + +# This script handles some basic QA checks on the source + +NOPOST=$1 +LIBRARY_NAME=`hatch project metadata name` +LIBRARY_VERSION=`hatch version | awk -F "." '{print $1"."$2"."$3}'` +POST_VERSION=`hatch version | awk -F "." '{print substr($4,0,length($4))}'` + +success() { + echo -e "$(tput setaf 2)$1$(tput sgr0)" +} + +inform() { + echo -e "$(tput setaf 6)$1$(tput sgr0)" +} + +warning() { + echo -e "$(tput setaf 1)$1$(tput sgr0)" +} + +while [[ $# -gt 0 ]]; do + K="$1" + case $K in + -p|--nopost) + NOPOST=true + shift + ;; + *) + if [[ $1 == -* ]]; then + printf "Unrecognised option: $1\n"; + exit 1 + fi + POSITIONAL_ARGS+=("$1") + shift + esac +done + +inform "Checking $LIBRARY_NAME $LIBRARY_VERSION\n" + +inform "Checking for trailing whitespace..." +grep -IUrn --color "[[:blank:]]$" --exclude-dir=dist --exclude-dir=.tox --exclude-dir=.git --exclude=PKG-INFO +if [[ $? -eq 0 ]]; then + warning "Trailing whitespace found!" + exit 1 +else + success "No trailing whitespace found." +fi +printf "\n" + +inform "Checking for DOS line-endings..." +grep -lIUrn --color $'\r' --exclude-dir=dist --exclude-dir=.tox --exclude-dir=.git --exclude=Makefile +if [[ $? -eq 0 ]]; then + warning "DOS line-endings found!" + exit 1 +else + success "No DOS line-endings found." +fi +printf "\n" + +inform "Checking CHANGELOG.md..." +cat CHANGELOG.md | grep ^${LIBRARY_VERSION} > /dev/null 2>&1 +if [[ $? -eq 1 ]]; then + warning "Changes missing for version ${LIBRARY_VERSION}! Please update CHANGELOG.md." + exit 1 +else + success "Changes found for version ${LIBRARY_VERSION}." +fi +printf "\n" + +inform "Checking for git tag ${LIBRARY_VERSION}..." +git tag -l | grep -E "${LIBRARY_VERSION}$" +if [[ $? -eq 1 ]]; then + warning "Missing git tag for version ${LIBRARY_VERSION}" +fi +printf "\n" + +if [[ $NOPOST ]]; then + inform "Checking for .postN on library version..." + if [[ "$POST_VERSION" != "" ]]; then + warning "Found .$POST_VERSION on library version." + inform "Please only use these for testpypi releases." + exit 1 + else + success "OK" + fi +fi diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..df635b4 --- /dev/null +++ b/examples/README.md @@ -0,0 +1 @@ +# Examples diff --git a/examples/gpiotool b/examples/gpiotool new file mode 100755 index 0000000..616c2e7 --- /dev/null +++ b/examples/gpiotool @@ -0,0 +1,26 @@ +#!/bin/env python3 +import sys + +import gpiodevice as gd + +action = sys.argv[1] + +gd.friendly_errors = True + + +if action == "find_chip_by_pins": + chip = gd.find_chip_by_pins(sys.argv[2], ignore_claimed=False) + print(chip) + sys.exit(0) + +if action == "find_chip_by_label": + if len(sys.argv) == 4: + pins = {} + for pin in sys.argv[3].split(","): + label, line = pin.split(":") + pins[label] = int(line) + chip = gd.find_chip_by_label(sys.argv[2], pins=pins) + else: + chip = gd.find_chip_by_label(sys.argv[2]) + print(chip) + sys.exit(0) diff --git a/gpiodevice/__init__.py b/gpiodevice/__init__.py new file mode 100644 index 0000000..bc9866d --- /dev/null +++ b/gpiodevice/__init__.py @@ -0,0 +1,176 @@ +import glob +import os + +import gpiod + +__version__ = "0.0.1" + +DEBUG = os.getenv("GPIODEVICE_DEBUG", None) is not None +CHIP_GLOB = "/dev/gpiochip*" + +friendly_errors: bool = False + + +class ErrorDigest(RuntimeError): + pass + + +class GPIOBaseError: + def __init__(self, message: str, icon: str = " "): + self.icon = icon + self.message = message + + def __str__(self): + return f" {self.icon} {self.message}" + + def __repr__(self): + return str(self) + + +class GPIOError(GPIOBaseError): + def __init__(self, message: str, icon: str = "⚠️ "): + GPIOBaseError.__init__(self, message, icon) + + +class GPIONotFound(GPIOBaseError): + def __init__(self, message: str, icon: str = "❌"): + GPIOBaseError.__init__(self, message, icon) + + +class GPIOFound(GPIOBaseError): + def __init__(self, message: str, icon: str = "✅"): + GPIOBaseError.__init__(self, message, icon) + + +def error_digest(fn): + def wrapper(*args, **kwargs): + errors = [] + + i = iter(fn(*args, **kwargs)) + + while True: + try: + errors.append(next(i)) + except StopIteration as e: + return e.value + except ErrorDigest as e: + msg = f"{e}\n" + "\n".join([str(e) for e in errors]) + if DEBUG: + raise RuntimeError(msg) from None + else: + raise SystemExit(f"Woah there, {msg}") + + return wrapper + + +@error_digest +def check_pins_available(chip: gpiod.Chip, pins) -> bool: + """Check if a list of pins are in use on a given gpiochip device. + + Raise a RuntimeError with a friendly list of in-use pins and their consumer if + any are in used. + + """ + if pins is None: + return True + + used = 0 + + for (label, pin) in pins.items(): + if isinstance(pin, str): + try: + pin = chip.line_offset_from_id(pin) + except OSError: + yield GPIOError(f"{label}: (line {pin}) not found!") + continue + + line_info = chip.get_line_info(pin) + + if line_info.used: + used += 1 + yield GPIOError(f"{label}: (line {pin}) currently claimed by {line_info.consumer}") + + if used and friendly_errors: + raise ErrorDigest("some pins we need are in use!") + + return used == 0 + + +@error_digest +def find_chip_by_label(labels: (list[str], tuple[str], str), pins: dict[str, (int, str)] = None): + """Try to find a gpiochip device matching one of a set of labels. + + Raise a RuntimeError with a friendly error digest if one is not found. + + """ + if isinstance(labels, str): + labels = (labels,) + + for path in glob.glob(CHIP_GLOB): + if gpiod.is_gpiochip_device(path): + try: + label = gpiod.Chip(path).get_info().label + except PermissionError: + yield GPIOError(f"{path}: Permission error!") + continue + + if label in labels: + chip = gpiod.Chip(path) + if check_pins_available(chip, pins): + return chip + else: + yield GPIONotFound(f"{path}: this is not the GPIO we're looking for! ({label})") + + if friendly_errors: + raise ErrorDigest("suitable gpiochip device not found!") + + return None + + +@error_digest +def find_chip_by_pins(pins: (list[str], tuple[str], str), ignore_claimed: bool = False): + """Try to find a gpiochip device that includes all of the named pins. + + Does not care whether pins are in use or not. + + "pins" can be a single string, a list/tuple or a comma-separated string of names. + + """ + if isinstance(pins, str): + if "," in pins: + pins = [pin.strip() for pin in pins.split(",")] + else: + pins = (pins,) + + for path in glob.glob(CHIP_GLOB): + if gpiod.is_gpiochip_device(path): + try: + chip = gpiod.Chip(path) + except PermissionError: + yield GPIOError(f"{path}: Permission error!") + + label = chip.get_info().label + errors = False + + for id in pins: + try: + offset = chip.line_offset_from_id(id) + yield GPIOFound(f"{id}: (line {offset}) found - {path} ({label})!") + except OSError: + errors = True + yield GPIONotFound(f"{id}: not found - {path} ({label})!") + continue + + line_info = chip.get_line_info(offset) + + if not ignore_claimed and line_info.used: + errors = True + yield GPIOError(f"{id}: (line {offset}) currently claimed by {line_info.consumer}") + + if not errors: + return chip + + if friendly_errors: + raise ErrorDigest("suitable gpiochip not found!") + + return None diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..d3c9aa9 --- /dev/null +++ b/install.sh @@ -0,0 +1,294 @@ +#!/bin/bash +LIBRARY_NAME=`grep -m 1 name pyproject.toml | awk -F" = " '{print substr($2,2,length($2)-2)}'` +CONFIG=/boot/config.txt +DATESTAMP=`date "+%Y-%m-%d-%H-%M-%S"` +CONFIG_BACKUP=false +APT_HAS_UPDATED=false +RESOURCES_TOP_DIR=$HOME/Pimoroni +PY_VENV_DIR=$HOME/.virtualenvs/pimoroni +WD=`pwd` +USAGE="./install.sh (--unstable)" +POSITIONAL_ARGS=() +FORCE=false +UNSTABLE=false +PYTHON="python" + + +user_check() { + if [ $(id -u) -eq 0 ]; then + printf "Script should not be run as root. Try './install.sh'\n" + exit 1 + fi +} + +confirm() { + if $FORCE; then + true + else + read -r -p "$1 [y/N] " response < /dev/tty + if [[ $response =~ ^(yes|y|Y)$ ]]; then + true + else + false + fi + fi +} + +prompt() { + read -r -p "$1 [y/N] " response < /dev/tty + if [[ $response =~ ^(yes|y|Y)$ ]]; then + true + else + false + fi +} + +success() { + echo -e "$(tput setaf 2)$1$(tput sgr0)" +} + +inform() { + echo -e "$(tput setaf 6)$1$(tput sgr0)" +} + +warning() { + echo -e "$(tput setaf 1)$1$(tput sgr0)" +} + +venv_bash_snippet() { + if [ ! -f $BASH_SNIPPET ]; then + cat << EOF > $BASH_SNIPPET +# Add `source $RESOURCES_DIR/auto_venv.sh` to your ~/.bashrc to activate +# the Pimoroni virtual environment automagically! +PY_VENV_DIR="$PY_VENV_DIR" +if [ ! -f \$PY_VENV_DIR/bin/activate ]; then + printf "Creating user Python environment in \$PY_VENV_DIR, please wait...\n" + mkdir -p \$PY_VENV_DIR + python3 -m venv --system-site-packages \$PY_VENV_DIR +fi +printf " ↓ ↓ ↓ ↓ Hello, we've activated a Python venv for you. To exit, type \"deactivate\".\n" +source \$PY_VENV_DIR/bin/activate +EOF + fi +} + +venv_check() { + PYTHON_BIN=`which $PYTHON` + if [[ $VIRTUAL_ENV == "" ]] || [[ $PYTHON_BIN != $VIRTUAL_ENV* ]]; then + printf "This script should be run in a virtual Python environment.\n" + if confirm "Would you like us to create one for you?"; then + if [ ! -f $PY_VENV_DIR/bin/activate ]; then + inform "Creating virtual Python environment in $PY_VENV_DIR, please wait...\n" + mkdir -p $PY_VENV_DIR + /usr/bin/python3 -m venv $PY_VENV_DIR --system-site-packages Pimoroni + venv_bash_snippet() + else + inform "Found existing virtual Python environment in $PY_VENV_DIR\n" + fi + inform "Activating virtual Python environment in $PY_VENV_DIR..." + inform "source $PY_VENV_DIR/bin/activate\n" + source $PY_VENV_DIR/bin/activate + + else + exit 1 + fi + fi +} + +function do_config_backup { + if [ ! $CONFIG_BACKUP == true ]; then + CONFIG_BACKUP=true + FILENAME="config.preinstall-$LIBRARY_NAME-$DATESTAMP.txt" + inform "Backing up $CONFIG to /boot/$FILENAME\n" + sudo cp $CONFIG /boot/$FILENAME + mkdir -p $RESOURCES_TOP_DIR/config-backups/ + cp $CONFIG $RESOURCES_TOP_DIR/config-backups/$FILENAME + if [ -f "$UNINSTALLER" ]; then + echo "cp $RESOURCES_TOP_DIR/config-backups/$FILENAME $CONFIG" >> $UNINSTALLER + fi + fi +} + +function apt_pkg_install { + PACKAGES=() + PACKAGES_IN=("$@") + for ((i = 0; i < ${#PACKAGES_IN[@]}; i++)); do + PACKAGE="${PACKAGES_IN[$i]}" + if [ "$PACKAGE" == "" ]; then continue; fi + printf "Checking for $PACKAGE\n" + dpkg -L $PACKAGE > /dev/null 2>&1 + if [ "$?" == "1" ]; then + PACKAGES+=("$PACKAGE") + fi + done + PACKAGES="${PACKAGES[@]}" + if ! [ "$PACKAGES" == "" ]; then + echo "Installing missing packages: $PACKAGES" + if [ ! $APT_HAS_UPDATED ]; then + sudo apt update + APT_HAS_UPDATED=true + fi + sudo apt install -y $PACKAGES + if [ -f "$UNINSTALLER" ]; then + echo "apt uninstall -y $PACKAGES" >> $UNINSTALLER + fi + fi +} + +function pip_pkg_install { + PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring $PYTHON -m pip install --upgrade "$@" +} + +while [[ $# -gt 0 ]]; do + K="$1" + case $K in + -u|--unstable) + UNSTABLE=true + shift + ;; + -f|--force) + FORCE=true + shift + ;; + -p|--python) + PYTHON=$2 + shift + shift + ;; + *) + if [[ $1 == -* ]]; then + printf "Unrecognised option: $1\n"; + printf "Usage: $USAGE\n"; + exit 1 + fi + POSITIONAL_ARGS+=("$1") + shift + esac +done + +user_check +venv_check + +if [ ! -f `which $PYTHON` ]; then + printf "Python path $PYTHON not found!\n" + exit 1 +fi + +PYTHON_VER=`$PYTHON --version` + +printf "$LIBRARY_NAME Python Library: Installer\n\n" + +inform "Checking Dependencies. Please wait..." + +pip_pkg_install toml + +CONFIG_VARS=`$PYTHON - < $UNINSTALLER +printf "It's recommended you run these steps manually.\n" +printf "If you want to run the full script, open it in\n" +printf "an editor and remove 'exit 1' from below.\n" +exit 1 +source $VIRTUAL_ENV/bin/activate +EOF + +if $UNSTABLE; then + warning "Installing unstable library from source.\n\n" +else + printf "Installing stable library from pypi.\n\n" +fi + +inform "Installing for $PYTHON_VER...\n" +apt_pkg_install "${APT_PACKAGES[@]}" +if $UNSTABLE; then + pip_pkg_install . +else + pip_pkg_install $LIBRARY_NAME +fi +if [ $? -eq 0 ]; then + success "Done!\n" + echo "$PYTHON -m pip uninstall $LIBRARY_NAME" >> $UNINSTALLER +fi + +cd $WD + +for ((i = 0; i < ${#SETUP_CMDS[@]}; i++)); do + CMD="${SETUP_CMDS[$i]}" + # Attempt to catch anything that touches /boot/config.txt and trigger a backup + if [[ "$CMD" == *"raspi-config"* ]] || [[ "$CMD" == *"$CONFIG"* ]] || [[ "$CMD" == *"\$CONFIG"* ]]; then + do_config_backup + fi + eval $CMD +done + +for ((i = 0; i < ${#CONFIG_TXT[@]}; i++)); do + CONFIG_LINE="${CONFIG_TXT[$i]}" + if ! [ "$CONFIG_LINE" == "" ]; then + do_config_backup + inform "Adding $CONFIG_LINE to $CONFIG\n" + sudo sed -i "s/^#$CONFIG_LINE/$CONFIG_LINE/" $CONFIG + if ! grep -q "^$CONFIG_LINE" $CONFIG; then + printf "$CONFIG_LINE\n" | sudo tee --append $CONFIG + fi + fi +done + +if [ -d "examples" ]; then + if confirm "Would you like to copy examples to $RESOURCES_DIR?"; then + inform "Copying examples to $RESOURCES_DIR" + cp -r examples/ $RESOURCES_DIR + echo "rm -r $RESOURCES_DIR" >> $UNINSTALLER + success "Done!" + fi +fi + +printf "\n" + +if confirm "Would you like to generate documentation?"; then + pip_pkg_install pdoc + printf "Generating documentation.\n" + $PYTHON -m pdoc $LIBRARY_NAME -o $RESOURCES_DIR/docs > /dev/null + if [ $? -eq 0 ]; then + inform "Documentation saved to $RESOURCES_DIR/docs" + success "Done!" + else + warning "Error: Failed to generate documentation." + fi +fi + +success "\nAll done!" +inform "If this is your first time installing you should reboot for hardware changes to take effect.\n" +inform "Find uninstall steps in $UNINSTALLER\n" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..573e459 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,118 @@ +[build-system] +requires = ["hatchling", "hatch-fancy-pypi-readme"] +build-backend = "hatchling.build" + +[project] +name = "gpiodevice" +dynamic = ["version", "readme"] +description = "Helper library for working with Linux gpiochip devices." +license = {file = "LICENSE"} +requires-python = ">= 3.7" +authors = [ + { name = "Philip Howard", email = "phil@pimoroni.com" }, +] +maintainers = [ + { name = "Philip Howard", email = "phil@pimoroni.com" }, +] +keywords = [ + "Pi", + "Raspberry", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries", + "Topic :: System :: Hardware", +] +dependencies = [ + "gpiod" +] + +[project.urls] +GitHub = "https://www.github.com/pimoroni/gpiodevice-python" +Homepage = "https://www.pimoroni.com" + +[tool.hatch.version] +path = "gpiodevice/__init__.py" + +[tool.hatch.build] +include = [ + "gpiodevice", + "README.md", + "CHANGELOG.md", + "LICENSE" +] + +[tool.hatch.build.targets.sdist] +include = [ + "*" +] +exclude = [ + ".*", + "dist" +] + +[tool.hatch.metadata.hooks.fancy-pypi-readme] +content-type = "text/markdown" +fragments = [ + { path = "README.md" }, + { text = "\n" }, + { path = "CHANGELOG.md" } +] + +[tool.ruff] +exclude = [ + '.tox', + '.egg', + '.git', + '__pycache__', + 'build', + 'dist' +] +line-length = 200 + +[tool.black] +line-length = 200 + +[tool.codespell] +skip = """ +./.tox,\ +./.egg,\ +./.git,\ +./__pycache__,\ +./build,\ +./dist.\ +""" + +[tool.isort] +line_length = 200 + +[tool.check-manifest] +ignore = [ + '.stickler.yml', + 'boilerplate.md', + 'check.sh', + 'install.sh', + 'uninstall.sh', + 'Makefile', + 'tox.ini', + 'tests/*', + 'examples/*', + '.coveragerc', + 'requirements-dev.txt' +] + +[pimoroni] +apt_packages = [] +configtxt = [] +commands = [] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..525b042 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,9 @@ +check-manifest +ruff +codespell +isort +twine +hatch +hatch-fancy-pypi-readme +tox +pdoc diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c80e11e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +import sys + +import mock +import pytest + + +@pytest.fixture(scope="function", autouse=False) +def gpiod(): + gpiopd = mock.Mock() + sys.modules["gpiod"] = gpiopd + yield gpiod + del sys.modules["gpiod"] diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 0000000..38a1cb2 --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,4 @@ +def test_version(gpiod): + import gpiodevice + + assert gpiodevice.find_chip_by_label("test") is None diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..44c8654 --- /dev/null +++ b/tox.ini @@ -0,0 +1,34 @@ +[tox] +envlist = py,qa +skip_missing_interpreters = True +isolated_build = true +minversion = 4.0.0 + +[testenv] +commands = + coverage run -m pytest -v -r wsx + coverage report +deps = + mock + pytest>=3.1 + pytest-cov + build + +[testenv:qa] +commands = + check-manifest + python -m build --no-isolation + python -m twine check dist/* + isort --check . + ruff . + codespell . +deps = + check-manifest + ruff + codespell + isort + twine + build + hatch + hatch-fancy-pypi-readme + diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..f213fc5 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,72 @@ +#!/bin/bash + +FORCE=false +LIBRARY_NAME=`grep -m 1 name pyproject.toml | awk -F" = " '{print substr($2,2,length($2)-2)}'` +RESOURCES_DIR=$HOME/Pimoroni/$LIBRARY_NAME +PYTHON="python" + + +venv_check() { + PYTHON_BIN=`which $PYTHON` + if [[ $VIRTUAL_ENV == "" ]] || [[ $PYTHON_BIN != $VIRTUAL_ENV* ]]; then + printf "This script should be run in a virtual Python environment.\n" + exit 1 + fi +} + +user_check() { + if [ $(id -u) -eq 0 ]; then + printf "Script should not be run as root. Try './uninstall.sh'\n" + exit 1 + fi +} + +confirm() { + if $FORCE; then + true + else + read -r -p "$1 [y/N] " response < /dev/tty + if [[ $response =~ ^(yes|y|Y)$ ]]; then + true + else + false + fi + fi +} + +prompt() { + read -r -p "$1 [y/N] " response < /dev/tty + if [[ $response =~ ^(yes|y|Y)$ ]]; then + true + else + false + fi +} + +success() { + echo -e "$(tput setaf 2)$1$(tput sgr0)" +} + +inform() { + echo -e "$(tput setaf 6)$1$(tput sgr0)" +} + +warning() { + echo -e "$(tput setaf 1)$1$(tput sgr0)" +} + +printf "$LIBRARY_NAME Python Library: Uninstaller\n\n" + +user_check +venv_check + +printf "Uninstalling for Python 3...\n" +$PYTHON -m pip uninstall $LIBRARY_NAME + +if [ -d $RESOURCES_DIR ]; then + if confirm "Would you like to delete $RESOURCES_DIR?"; then + rm -r $RESOURCES_DIR + fi +fi + +printf "Done!\n"