Skip to content

Commit 4fbf611

Browse files
wip
1 parent a530a99 commit 4fbf611

124 files changed

Lines changed: 801 additions & 568 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
- Added boot-partition Wi-Fi setup for _new_ Pioreactor images. Users can place a `wifi.ini` file with `ssid` and `passphrase` values on the boot partition, and the image will try those credentials before starting local access point mode.
1010
- Improved plugin install and uninstall reliability by moving the lifecycle into the Pioreactor package instead of requiring `/usr/local/bin` helper scripts on the operating system.
11+
- Added support for installing `.py` drop-in plugins from Pioreactor-managed USB drives on the Plugins page, alongside existing `.whl` plugin installs.
1112
- Reduced idle CPU usage from Huey on single-core Raspberry Pi leaders by starting the background task queue with fewer workers and a slower polling interval. Multi-core devices keep the existing Huey settings.
1213
- Reduced idle CPU usage from the monitor job on single-core Raspberry Pis by disabling button controls by default. The new `[monitor.config] enable_button` option supports `auto`, `true`, and `false`.
1314
- removed tty services from base images.

core/pioreactor/cli/pio.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,6 @@
1919
from pioreactor import exc
2020
from pioreactor import whoami
2121
from pioreactor.cli.lazy_group import LazyGroup
22-
from pioreactor.http_response import summarize_error_response
23-
from pioreactor.mureq import get
24-
from pioreactor.mureq import HTTPErrorStatus
2522

2623
lazy_subcommands = {
2724
"run": "pioreactor.cli.run.run",
@@ -929,6 +926,8 @@ def blink() -> None:
929926
"""
930927
monitor job is required to be running.
931928
"""
929+
from pioreactor.http_response import summarize_error_response
930+
from pioreactor.mureq import HTTPErrorStatus
932931
from pioreactor.pubsub import post_into_leader
933932

934933
response = post_into_leader(f"/api/workers/{whoami.get_unit_name()}/blink")
@@ -1753,6 +1752,7 @@ def get_non_prerelease_tags_of_pioreactor(repo: str) -> list[str]:
17531752
Returns a list of all the tag names associated with non-prerelease releases, sorted in descending order
17541753
"""
17551754
from packaging.version import Version
1755+
from pioreactor.mureq import get
17561756

17571757
url = f"https://api.github.com/repos/{repo}/releases"
17581758
headers = {"Accept": "application/vnd.github.v3+json"}

core/pioreactor/logging.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ def __init__(
119119
self,
120120
topic_prefix: str,
121121
client: "Client",
122+
owns_client: bool,
122123
qos: int = 0,
123124
retain: bool = False,
124125
**mqtt_kwargs: Any,
@@ -129,6 +130,7 @@ def __init__(
129130
self.retain = retain
130131
self.mqtt_kwargs = mqtt_kwargs
131132
self.client = client
133+
self.owns_client = owns_client
132134

133135
def emit(self, record: logging.LogRecord) -> None:
134136
payload = self.format(record)
@@ -152,7 +154,8 @@ def emit(self, record: logging.LogRecord) -> None:
152154
mqtt_msg.wait_for_publish(timeout=2)
153155

154156
def close(self) -> None:
155-
self.client.shutdown()
157+
if self.owns_client:
158+
self.client.shutdown()
156159
super().close()
157160

158161

@@ -230,6 +233,7 @@ def create_logger(
230233
logger.addHandler(console_handler)
231234

232235
if to_mqtt:
236+
mqtt_handler_owns_client = pub_client is None
233237
if pub_client is None:
234238
pub_client = create_client(
235239
client_id=f"{name}-logging-{unit}-{experiment}",
@@ -242,7 +246,7 @@ def create_logger(
242246
topic_prefix = (
243247
f"pioreactor/{unit}/{experiment}/logs/{source}" # NOTE: we later append the log-level, ex: /debug
244248
)
245-
mqtt_to_db_handler = MQTTHandler(topic_prefix, pub_client)
249+
mqtt_to_db_handler = MQTTHandler(topic_prefix, pub_client, owns_client=mqtt_handler_owns_client)
246250
mqtt_to_db_handler.setLevel(logging.DEBUG)
247251
mqtt_to_db_handler.setFormatter(CustomisedJSONFormatter())
248252

core/pioreactor/plugin_management/__init__.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,13 @@
11
# -*- coding: utf-8 -*-
2-
import glob
32
import importlib
43
import importlib.metadata as entry_point
54
import os
6-
from functools import cache
75
from typing import Any
86

97
import click
108
from msgspec import Struct
11-
from pioreactor import pubsub
12-
from pioreactor.plugin_management.install_plugin import click_install_plugin
13-
from pioreactor.plugin_management.list_plugins import click_list_plugins
14-
from pioreactor.plugin_management.uninstall_plugin import click_uninstall_plugin
159
from pioreactor.plugin_management.utils import discover_plugins_in_entry_points
1610
from pioreactor.plugin_management.utils import discover_plugins_in_local_folder
17-
from pioreactor.utils import networking
18-
from pioreactor.whoami import get_unit_name
1911

2012
"""
2113
How do plugins work? There are a few patterns we use to "register" plugins with the core app.
@@ -68,9 +60,31 @@ class Plugin(Struct):
6860
]
6961

7062

63+
def __getattr__(attr: str) -> Any:
64+
if attr == "click_install_plugin":
65+
from pioreactor.plugin_management.install_plugin import click_install_plugin
66+
67+
return click_install_plugin
68+
elif attr == "click_list_plugins":
69+
from pioreactor.plugin_management.list_plugins import click_list_plugins
70+
71+
return click_list_plugins
72+
elif attr == "click_uninstall_plugin":
73+
from pioreactor.plugin_management.uninstall_plugin import click_uninstall_plugin
74+
75+
return click_uninstall_plugin
76+
raise AttributeError(attr)
77+
78+
7179
def get_plugin_api_url(py_file: str) -> str:
80+
from pioreactor.config import config
81+
from pioreactor.utils.networking import resolve_to_address
82+
from pioreactor.whoami import get_unit_name
83+
7284
endpoint = f"/unit_api/plugins/installed/{py_file}"
73-
return pubsub.create_webserver_path(networking.resolve_to_address(get_unit_name()), endpoint)
85+
port = config.getint("ui", "port", fallback=80)
86+
proto = config.get("ui", "proto", fallback="http")
87+
return f"{proto}://{resolve_to_address(get_unit_name())}:{port}/{endpoint.removeprefix('/')}"
7488

7589

7690
def get_plugins() -> dict[str, Plugin]:
@@ -89,7 +103,6 @@ def get_plugins() -> dict[str, Plugin]:
89103
# get entry point plugins
90104
# Users can use Python's entry point system to create rich plugins, see
91105
# example here: https://github.com/Pioreactor/pioreactor-air-bubbler
92-
93106
for plugin in discover_plugins_in_entry_points():
94107
try:
95108
md = entry_point.metadata(plugin.name)
@@ -120,11 +133,12 @@ def get_plugins() -> dict[str, Plugin]:
120133
try:
121134
module = importlib.import_module(module_name)
122135
plugin_name = getattr(module, "__plugin_name__", module_name)
136+
plugin_homepage = getattr(module, "__plugin_homepage__", None)
123137
plugins[plugin_name] = Plugin(
124138
module,
125139
getattr(module, "__plugin_summary__", BLANK),
126140
getattr(module, "__plugin_version__", BLANK),
127-
getattr(module, "__plugin_homepage__", get_plugin_api_url(py_file.name)),
141+
plugin_homepage if plugin_homepage is not None else get_plugin_api_url(py_file.name),
128142
getattr(module, "__plugin_author__", BLANK),
129143
f"plugins/{py_file.name}",
130144
)

core/pioreactor/plugin_management/utils.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,4 @@ def discover_plugins_in_local_folder() -> list[Path]:
3333

3434

3535
def discover_plugins_in_entry_points() -> list[entry_point.EntryPoint]:
36-
eps = entry_point.entry_points()
37-
return list(eps.select(group="pioreactor.plugins"))
36+
return list(entry_point.entry_points(group="pioreactor.plugins"))

core/pioreactor/pubsub.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,21 @@
99
from time import sleep
1010
from typing import Any
1111
from typing import Callable
12+
from typing import TYPE_CHECKING
1213

1314
from msgspec import Struct
1415
from msgspec.json import decode as loads
1516
from paho.mqtt.client import Client as PahoClient
1617
from paho.mqtt.enums import CallbackAPIVersion
1718
from paho.mqtt.enums import MQTTErrorCode
18-
from pioreactor import mureq
1919
from pioreactor import types as pt
2020
from pioreactor.config import config
2121
from pioreactor.config import leader_address
2222
from pioreactor.config import mqtt_address
2323

24+
if TYPE_CHECKING:
25+
from pioreactor import mureq
26+
2427

2528
def add_hash_suffix(s: str) -> str:
2629
"""Adds random 4-character hash to the end of a string.
@@ -428,12 +431,14 @@ def create_webserver_path(address: str, endpoint: str) -> str:
428431
return f"{proto}://{address}:{port}/{endpoint}"
429432

430433

431-
def get_from(address: str, endpoint: str, **kwargs: Any) -> mureq.Response:
434+
def get_from(address: str, endpoint: str, **kwargs: Any) -> "mureq.Response":
432435
# pioreactor cluster specific
436+
from pioreactor import mureq
437+
433438
return mureq.get(create_webserver_path(address, endpoint), **kwargs)
434439

435440

436-
def get_from_leader(endpoint: str, timeout: int = 5, **kwargs: Any) -> mureq.Response:
441+
def get_from_leader(endpoint: str, timeout: int = 5, **kwargs: Any) -> "mureq.Response":
437442
return get_from(leader_address, endpoint, timeout=timeout, **kwargs)
438443

439444

@@ -443,8 +448,10 @@ def put_into(
443448
body: bytes | None = None,
444449
json: dict[str, Any] | Struct | None = None,
445450
**kwargs: Any,
446-
) -> mureq.Response:
451+
) -> "mureq.Response":
447452
# pioreactor cluster specific
453+
from pioreactor import mureq
454+
448455
return mureq.put(create_webserver_path(address, endpoint), body=body, json=json, **kwargs)
449456

450457

@@ -454,7 +461,7 @@ def put_into_leader(
454461
json: dict[str, Any] | Struct | None = None,
455462
timeout: int = 5,
456463
**kwargs: Any,
457-
) -> mureq.Response:
464+
) -> "mureq.Response":
458465
return put_into(leader_address, endpoint, body=body, json=json, timeout=timeout, **kwargs)
459466

460467

@@ -464,8 +471,10 @@ def patch_into(
464471
body: bytes | None = None,
465472
json: dict[str, Any] | Struct | None = None,
466473
**kwargs: Any,
467-
) -> mureq.Response:
474+
) -> "mureq.Response":
468475
# pioreactor cluster specific
476+
from pioreactor import mureq
477+
469478
return mureq.patch(create_webserver_path(address, endpoint), body=body, json=json, **kwargs)
470479

471480

@@ -475,7 +484,7 @@ def patch_into_leader(
475484
json: dict[str, Any] | Struct | None = None,
476485
timeout: int = 5,
477486
**kwargs: Any,
478-
) -> mureq.Response:
487+
) -> "mureq.Response":
479488
return patch_into(leader_address, endpoint, body=body, json=json, timeout=timeout, **kwargs)
480489

481490

@@ -485,8 +494,10 @@ def post_into(
485494
body: bytes | None = None,
486495
json: dict[str, Any] | Struct | None = None,
487496
**kwargs: Any,
488-
) -> mureq.Response:
497+
) -> "mureq.Response":
489498
# pioreactor cluster specific
499+
from pioreactor import mureq
500+
490501
return mureq.post(create_webserver_path(address, endpoint), body=body, json=json, **kwargs)
491502

492503

@@ -496,14 +507,16 @@ def post_into_leader(
496507
json: dict[str, Any] | Struct | None = None,
497508
timeout: int = 5,
498509
**kwargs: Any,
499-
) -> mureq.Response:
510+
) -> "mureq.Response":
500511
return post_into(leader_address, endpoint, body=body, json=json, timeout=timeout, **kwargs)
501512

502513

503-
def delete_from(address: str, endpoint: str, **kwargs: Any) -> mureq.Response:
514+
def delete_from(address: str, endpoint: str, **kwargs: Any) -> "mureq.Response":
504515
# pioreactor cluster specific
516+
from pioreactor import mureq
517+
505518
return mureq.delete(create_webserver_path(address, endpoint), **kwargs)
506519

507520

508-
def delete_from_leader(endpoint: str, timeout: int = 5, **kwargs: Any) -> mureq.Response:
521+
def delete_from_leader(endpoint: str, timeout: int = 5, **kwargs: Any) -> "mureq.Response":
509522
return delete_from(leader_address, endpoint, timeout=timeout, **kwargs)

core/pioreactor/structs.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,10 @@ class InstallPluginFromUsbRequest(Struct, forbid_unknown_fields=True):
726726
filepath: str
727727

728728

729+
class InstallPluginFromLeaderCopyRequest(Struct, forbid_unknown_fields=True):
730+
filename: str
731+
732+
729733
class CreateCalibrationRequest(Struct, forbid_unknown_fields=True):
730734
calibration_data: str
731735
set_as_active: bool = False

core/pioreactor/utils/usb.py

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,19 +64,20 @@ def as_dict(self) -> dict[str, str]:
6464
return {"path": str(self.path), "version": self.version}
6565

6666

67-
class UsbPluginWheel(msgspec.Struct, frozen=True):
67+
class UsbPluginArtifact(msgspec.Struct, frozen=True):
6868
path: Path
6969
name: str
7070
version: str | None
71+
kind: str
7172

7273
def as_dict(self) -> dict[str, str | None]:
73-
return {"path": str(self.path), "name": self.name, "version": self.version}
74+
return {"path": str(self.path), "name": self.name, "version": self.version, "kind": self.kind}
7475

7576

7677
class UsbScan(msgspec.Struct, frozen=True):
7778
mountpoint: Path
7879
updates: tuple[UsbUpdateArchive, ...]
79-
plugins: tuple[UsbPluginWheel, ...]
80+
plugins: tuple[UsbPluginArtifact, ...]
8081
writable: bool
8182
free_bytes: int
8283

@@ -208,8 +209,8 @@ def scan_usb_mount(mountpoint: Path) -> UsbScan:
208209
)
209210
)
210211
plugins = tuple(
211-
UsbPluginWheel(path=path, name=name, version=version)
212-
for path, name, version in _find_plugin_wheels(mountpoint)
212+
UsbPluginArtifact(path=path, name=name, version=version, kind=kind)
213+
for path, name, version, kind in _find_plugin_artifacts(mountpoint)
213214
)
214215
usage = shutil.disk_usage(mountpoint)
215216
return UsbScan(
@@ -310,7 +311,17 @@ def choose_usb_mountpoint(mountpoint: str | None = None) -> Path:
310311
def resolve_usb_plugin_wheel(filepath: str) -> Path:
311312
path = Path(filepath)
312313
if path.suffix != ".whl":
313-
raise ValueError("USB plugin installs currently support .whl files only.")
314+
raise ValueError("USB wheel plugin installs support .whl files only.")
315+
316+
return resolve_usb_plugin_artifact(filepath)
317+
318+
319+
def resolve_usb_plugin_artifact(filepath: str) -> Path:
320+
path = Path(filepath)
321+
if path.suffix not in (".whl", ".py"):
322+
raise ValueError("USB plugin installs currently support .whl and .py files only.")
323+
if path.suffix == ".py" and not is_valid_python_plugin_filename(path.name):
324+
raise ValueError(f"{path.name} is not a valid Python plugin filename.")
314325
if not path.exists():
315326
raise ValueError(f"{filepath} does not exist.")
316327

@@ -451,20 +462,23 @@ def _pioreactor_managed_mountpoints(partition: UsbPartition) -> list[Path]:
451462
]
452463

453464

454-
def _find_plugin_wheels(mountpoint: Path) -> list[tuple[Path, str, str | None]]:
465+
def _find_plugin_artifacts(mountpoint: Path) -> list[tuple[Path, str, str | None, str]]:
455466
candidates = [mountpoint, mountpoint / "pioreactor" / "plugins"]
456467
seen: set[Path] = set()
457-
plugins: list[tuple[Path, str, str | None]] = []
468+
plugins: list[tuple[Path, str, str | None, str]] = []
458469

459470
for directory in candidates:
460471
if not directory.exists():
461472
continue
462-
for path in sorted(directory.glob("*.whl")):
463-
if path in seen:
473+
for path in sorted(directory.iterdir()):
474+
if path in seen or path.suffix not in (".whl", ".py"):
464475
continue
465476
seen.add(path)
466-
name, version = parse_wheel_name(path.name)
467-
plugins.append((path, name, version))
477+
if path.suffix == ".whl":
478+
name, version = parse_wheel_name(path.name)
479+
plugins.append((path, name, version, "wheel"))
480+
elif is_valid_python_plugin_filename(path.name):
481+
plugins.append((path, path.stem, None, "python_file"))
468482

469483
return plugins
470484

@@ -476,6 +490,11 @@ def parse_wheel_name(filename: str) -> tuple[str, str | None]:
476490
return parts[0].replace("_", "-"), parts[1]
477491

478492

493+
def is_valid_python_plugin_filename(filename: str) -> bool:
494+
path = Path(filename)
495+
return path.name == filename and path.suffix == ".py" and path.stem.isidentifier()
496+
497+
479498
def _as_bool(value: object) -> bool:
480499
return value is True or value == 1 or value == "1"
481500

core/pioreactor/web/api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2493,7 +2493,7 @@ def install_plugin_across_cluster(pioreactor_unit: str) -> DelayedResponseReturn
24932493
@api_bp.route("/units/<pioreactor_unit>/plugins/install-from-leader-usb", methods=["POST", "PATCH"])
24942494
def install_plugin_from_leader_usb_on_machine(pioreactor_unit: str) -> DelayedResponseReturnValue:
24952495
"""
2496-
Install one wheel plugin from the leader's Pioreactor-managed USB mount onto selected unit(s).
2496+
Install one plugin artifact from the leader's Pioreactor-managed USB mount onto selected unit(s).
24972497
24982498
JSON body:
24992499
{

0 commit comments

Comments
 (0)