Skip to content

Commit 53245bb

Browse files
committed
Install platform dependencies at runtime
1 parent 37a4ff5 commit 53245bb

7 files changed

Lines changed: 272 additions & 54 deletions

File tree

README.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ running CircuitPython and would likely conflict in unhappy ways.
4949
The test suites in the test/src folder under **testing.universal** are by design
5050
intended to run on *either* CircuitPython *or* CPython/Micropython+compatibility layer to prove conformance.
5151

52+
Platform-specific dependencies are detected when Blinka starts. When Blinka is
53+
running in an interactive terminal and a dependency is missing, it offers to
54+
install the packages needed by the detected board into the current Python
55+
environment. In non-interactive environments, the existing import error includes
56+
the command needed to install the missing platform package.
57+
5258
Installing from PyPI
5359
=====================
5460

requirements.txt

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,3 @@ pyftdi>=0.40.0
55
adafruit-circuitpython-typing
66
sysv_ipc>=1.1.0;sys_platform=='linux' and platform_machine!='mips'
77
toml>=0.10.2;python_version<'3.11'
8-
Jetson.GPIO;sys_platform=='linux' and platform_machine=='aarch64'
9-
rpi_ws281x>=4.0.0;sys_platform=='linux' and (platform_machine=='armv6l' or platform_machine=='armv7l' or platform_machine=='aarch64')
10-
lgpio>=0.2.2.0; sys_platform=='linux' and python_version<'3.13' and (platform_machine=='aarch64' or platform_machine=='armv7l')
11-
adafruit-lgpio>=0.2.2.0; sys_platform=='linux' and python_version>='3.13' and (platform_machine=='aarch64' or platform_machine=='armv7l')
12-
RPi.GPIO;sys_platform=='linux' and (platform_machine=='armv6l' or platform_machine=='armv7l' or platform_machine=='aarch64')
13-
Adafruit-Blinka-Raspberry-Pi5-Neopixel;sys_platform=='linux' and platform_machine=='aarch64' and python_version>='3.11'
14-
Adafruit_BBIO>=1.2.4;sys_platform=='linux' and platform_machine=='armv7l'

setup.py

Lines changed: 1 addition & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
import io
1212
import os
13-
import sys
1413

1514
from setuptools import setup, find_packages
1615

@@ -22,48 +21,6 @@
2221
with io.open(os.path.join(here, "README.rst"), encoding="utf-8") as f:
2322
long_description = "\n" + f.read()
2423

25-
board_reqs = []
26-
if os.path.exists("/proc/device-tree/compatible"):
27-
with open("/proc/device-tree/compatible", "rb") as f:
28-
compat = f.read()
29-
# Jetson Nano, TX2, Xavier, etc
30-
if b"nvidia,tegra" in compat:
31-
board_reqs = ["Jetson.GPIO"]
32-
# Pi 5 and Earlier
33-
elif (
34-
b"brcm,bcm2835" in compat
35-
or b"brcm,bcm2836" in compat
36-
or b"brcm,bcm2837" in compat
37-
or b"brcm,bcm2838" in compat
38-
or b"brcm,bcm2711" in compat
39-
or b"brcm,bcm2712" in compat
40-
):
41-
_pyver = (sys.version_info.major, sys.version_info.minor)
42-
# adafruit-lgpio provides pre-built wheels for Python 3.13+ (aarch64 + armv7l)
43-
if _pyver >= (3, 13):
44-
lgpio_req = "adafruit-lgpio>=0.2.2.0"
45-
else:
46-
lgpio_req = "lgpio>=0.2.2.0"
47-
try:
48-
import lgpio
49-
except ImportError:
50-
print(
51-
"\n*** lgpio is not installed. On Raspberry Pi OS, install it with:\n"
52-
" sudo apt-get install -y python3-lgpio\n"
53-
" Then recreate your virtual environment with --system-site-packages\n"
54-
" or install adafruit-lgpio from PyPI:\n"
55-
" pip install adafruit-lgpio\n"
56-
)
57-
board_reqs = [
58-
"rpi_ws281x>=4.0.0",
59-
lgpio_req,
60-
"RPi.GPIO",
61-
"Adafruit-Blinka-Raspberry-Pi5-Neopixel",
62-
]
63-
# BeagleBone Black, Green, PocketBeagle, BeagleBone AI, etc.
64-
elif b"ti,am335x" in compat:
65-
board_reqs = ["Adafruit_BBIO"]
66-
6724
setup(
6825
name="Adafruit-Blinka",
6926
use_scm_version={
@@ -120,8 +77,7 @@
12077
"adafruit-circuitpython-typing",
12178
"sysv_ipc>=1.1.0;sys_platform=='linux' and platform_machine!='mips'",
12279
"toml>=0.10.2;python_version<'3.11'",
123-
]
124-
+ board_reqs,
80+
],
12581
license="MIT",
12682
classifiers=[
12783
# Trove classifiers

src/adafruit_blinka/importing.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
* Author(s): Melissa LeBlanc-Williams
1010
"""
1111

12+
import sys
1213

1314
try:
1415
from importlib import import_module
@@ -24,7 +25,6 @@ def import_module(module_name: str, package: str = None):
2425

2526
from adafruit_blinka.agnostic import detector
2627

27-
2828
PLATFORM_DEPENDENCY_INSTALLS = {
2929
"Adafruit_BBIO": "pip install Adafruit_BBIO",
3030
"Jetson": "pip install Jetson.GPIO",
@@ -39,7 +39,17 @@ def import_module(module_name: str, package: str = None):
3939

4040
def raise_for_missing_platform_dependency(error: ModuleNotFoundError):
4141
"""Raise a helpful message for known optional platform dependencies."""
42-
install_command = PLATFORM_DEPENDENCY_INSTALLS.get(error.name)
42+
install_command = None
43+
if sys.implementation.name == "cpython":
44+
from adafruit_blinka.platform_dependencies import (
45+
get_platform_requirement_for_import,
46+
)
47+
48+
requirement = get_platform_requirement_for_import(detector, error.name)
49+
if requirement is not None:
50+
install_command = f"pip install {requirement}"
51+
if install_command is None:
52+
install_command = PLATFORM_DEPENDENCY_INSTALLS.get(error.name)
4353
if install_command is None:
4454
raise error
4555

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# SPDX-FileCopyrightText: 2026 Melissa LeBlanc-Williams for Adafruit Industries
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
"""Runtime installation helpers for platform-specific dependencies."""
6+
7+
import importlib
8+
import importlib.util
9+
import subprocess
10+
import sys
11+
12+
13+
def get_platform_dependencies(detector, python_version=None):
14+
"""Return ``(import name, pip requirement)`` pairs for detected hardware."""
15+
if python_version is None:
16+
python_version = sys.version_info[:2]
17+
18+
if detector.board.any_raspberry_pi_5_board:
19+
lgpio_requirement = (
20+
"adafruit-lgpio>=0.2.2.0" if python_version >= (3, 13) else "lgpio>=0.2.2.0"
21+
)
22+
dependencies = [("lgpio", lgpio_requirement)]
23+
if python_version >= (3, 11):
24+
dependencies.append(
25+
(
26+
"adafruit_raspberry_pi5_neopixel_write",
27+
"Adafruit-Blinka-Raspberry-Pi5-Neopixel",
28+
)
29+
)
30+
return dependencies
31+
32+
if detector.board.any_raspberry_pi:
33+
return [
34+
("RPi.GPIO", "RPi.GPIO"),
35+
("_rpi_ws281x", "rpi_ws281x>=4.0.0"),
36+
]
37+
38+
if detector.board.any_jetson_board:
39+
return [("Jetson.GPIO", "Jetson.GPIO")]
40+
41+
if detector.chip.id == "AM33XX":
42+
return [("Adafruit_BBIO", "Adafruit_BBIO>=1.2.4")]
43+
44+
return []
45+
46+
47+
def _module_available(module_name):
48+
"""Return whether an import can be resolved without importing the module."""
49+
try:
50+
return importlib.util.find_spec(module_name) is not None
51+
except (ImportError, ModuleNotFoundError):
52+
return False
53+
54+
55+
def get_missing_platform_dependencies(detector, python_version=None):
56+
"""Return platform requirements whose import modules are unavailable."""
57+
return [
58+
requirement
59+
for module_name, requirement in get_platform_dependencies(
60+
detector, python_version
61+
)
62+
if not _module_available(module_name)
63+
]
64+
65+
66+
def get_platform_requirement_for_import(detector, import_name, python_version=None):
67+
"""Return the detected platform's pip requirement for an import name."""
68+
for module_name, requirement in get_platform_dependencies(detector, python_version):
69+
if import_name in (module_name, module_name.split(".", maxsplit=1)[0]):
70+
return requirement
71+
return None
72+
73+
74+
def install_missing_platform_dependencies(
75+
detector, python_version=None, input_func=input
76+
):
77+
"""Offer to install missing dependencies into the running Python environment."""
78+
missing = get_missing_platform_dependencies(detector, python_version)
79+
if not missing:
80+
return False
81+
82+
if (
83+
sys.stdin is None
84+
or sys.stdout is None
85+
or not (sys.stdin.isatty() and sys.stdout.isatty())
86+
):
87+
return False
88+
89+
print("\nBlinka detected missing platform dependencies:")
90+
for requirement in missing:
91+
print(f" - {requirement}")
92+
93+
try:
94+
response = input_func(
95+
"Install them into the current Python environment? [Y/n] "
96+
)
97+
except EOFError:
98+
return False
99+
if response.strip().lower() not in ("", "y", "yes"):
100+
return False
101+
102+
command = [sys.executable, "-m", "pip", "install", *missing]
103+
try:
104+
subprocess.run(command, check=True)
105+
except (OSError, subprocess.CalledProcessError) as error:
106+
install_command = " ".join(command)
107+
raise RuntimeError(
108+
f"Unable to install the platform dependencies. Try: {install_command}"
109+
) from error
110+
111+
importlib.invalidate_caches()
112+
return True

src/board.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@
2626

2727
SCL = SDA = SCLK = MOSI = MISO = None
2828

29+
if sys.implementation.name == "cpython":
30+
from adafruit_blinka.platform_dependencies import (
31+
install_missing_platform_dependencies,
32+
)
33+
34+
install_missing_platform_dependencies(detector)
35+
2936
# Start with micropython boards as importlib isn't available on those chips:
3037

3138
# Go through the board_list and import the first one that matches
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# SPDX-FileCopyrightText: 2026 Melissa LeBlanc-Williams for Adafruit Industries
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
"""Tests for runtime platform dependency handling."""
6+
7+
from types import SimpleNamespace
8+
9+
from adafruit_blinka import platform_dependencies
10+
11+
12+
def _detector(chip_id=None, **board_values):
13+
values = {
14+
"any_raspberry_pi_5_board": False,
15+
"any_raspberry_pi": False,
16+
"any_jetson_board": False,
17+
"any_beaglebone": False,
18+
}
19+
values.update(board_values)
20+
return SimpleNamespace(
21+
board=SimpleNamespace(**values), chip=SimpleNamespace(id=chip_id)
22+
)
23+
24+
25+
def test_raspberry_pi_5_uses_adafruit_lgpio_on_python_313():
26+
dependencies = platform_dependencies.get_platform_dependencies(
27+
_detector(any_raspberry_pi_5_board=True, any_raspberry_pi=True),
28+
python_version=(3, 13),
29+
)
30+
31+
assert dependencies == [
32+
("lgpio", "adafruit-lgpio>=0.2.2.0"),
33+
(
34+
"adafruit_raspberry_pi5_neopixel_write",
35+
"Adafruit-Blinka-Raspberry-Pi5-Neopixel",
36+
),
37+
]
38+
39+
40+
def test_raspberry_pi_5_uses_upstream_lgpio_before_python_313():
41+
dependencies = platform_dependencies.get_platform_dependencies(
42+
_detector(any_raspberry_pi_5_board=True, any_raspberry_pi=True),
43+
python_version=(3, 12),
44+
)
45+
46+
assert ("lgpio", "lgpio>=0.2.2.0") in dependencies
47+
48+
49+
def test_raspberry_pi_5_neopixel_requires_python_311():
50+
dependencies = platform_dependencies.get_platform_dependencies(
51+
_detector(any_raspberry_pi_5_board=True, any_raspberry_pi=True),
52+
python_version=(3, 10),
53+
)
54+
55+
assert all(
56+
module_name != "adafruit_raspberry_pi5_neopixel_write"
57+
for module_name, _ in dependencies
58+
)
59+
60+
61+
def test_earlier_raspberry_pi_does_not_install_lgpio():
62+
dependencies = platform_dependencies.get_platform_dependencies(
63+
_detector(any_raspberry_pi=True), python_version=(3, 13)
64+
)
65+
66+
assert dependencies == [
67+
("RPi.GPIO", "RPi.GPIO"),
68+
("_rpi_ws281x", "rpi_ws281x>=4.0.0"),
69+
]
70+
71+
72+
def test_import_requirement_uses_detected_python_version():
73+
detector = _detector(any_raspberry_pi_5_board=True, any_raspberry_pi=True)
74+
75+
requirement = platform_dependencies.get_platform_requirement_for_import(
76+
detector, "lgpio", python_version=(3, 12)
77+
)
78+
79+
assert requirement == "lgpio>=0.2.2.0"
80+
81+
82+
def test_installer_uses_running_python(monkeypatch):
83+
detector = _detector(chip_id="AM33XX", any_beaglebone=True)
84+
commands = []
85+
86+
monkeypatch.setattr(
87+
platform_dependencies,
88+
"get_missing_platform_dependencies",
89+
lambda *_args, **_kwargs: ["Adafruit_BBIO>=1.2.4"],
90+
)
91+
monkeypatch.setattr(platform_dependencies.sys.stdin, "isatty", lambda: True)
92+
monkeypatch.setattr(platform_dependencies.sys.stdout, "isatty", lambda: True)
93+
monkeypatch.setattr(
94+
platform_dependencies.subprocess,
95+
"run",
96+
lambda command, check: commands.append((command, check)),
97+
)
98+
99+
installed = platform_dependencies.install_missing_platform_dependencies(
100+
detector, input_func=lambda _prompt: "y"
101+
)
102+
103+
assert installed is True
104+
assert commands == [
105+
(
106+
[
107+
platform_dependencies.sys.executable,
108+
"-m",
109+
"pip",
110+
"install",
111+
"Adafruit_BBIO>=1.2.4",
112+
],
113+
True,
114+
)
115+
]
116+
117+
118+
def test_installer_skips_prompt_without_terminal(monkeypatch):
119+
detector = _detector(chip_id="AM33XX", any_beaglebone=True)
120+
prompted = []
121+
122+
monkeypatch.setattr(
123+
platform_dependencies,
124+
"get_missing_platform_dependencies",
125+
lambda *_args, **_kwargs: ["Adafruit_BBIO>=1.2.4"],
126+
)
127+
monkeypatch.setattr(platform_dependencies.sys.stdin, "isatty", lambda: False)
128+
129+
installed = platform_dependencies.install_missing_platform_dependencies(
130+
detector, input_func=prompted.append
131+
)
132+
133+
assert installed is False
134+
assert not prompted

0 commit comments

Comments
 (0)