diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2c7eca4..7ef4c1d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/obniz/obniz/__init__.py b/obniz/obniz/__init__.py index f776745..1af602e 100644 --- a/obniz/obniz/__init__.py +++ b/obniz/obniz/__init__.py @@ -1,5 +1,6 @@ import asyncio +from .libs.utils.eventloop import ensure_future, get_event_loop from .obniz_uis import ObnizUIs name = "obniz" @@ -15,6 +16,7 @@ def __init__(self, id, options=None): def repeat(self, callback, interval=100): if self.looper: self.looper = callback + self.repeatInterval = interval return self.looper = callback @@ -28,12 +30,23 @@ def _call_on_connect(self): self.loop() def loop(self): - if self.looper: - prom = self.looper() - if prom: - prom() + if not self.looper: + return + + ret = self.looper() + if asyncio.iscoroutine(ret): + # wait for the async callback to finish before scheduling the next run + async def _run(): + await ret + self._schedule_next_loop() - asyncio.get_event_loop().call_later(1, self.loop) + ensure_future(_run()) + else: + self._schedule_next_loop() + + def _schedule_next_loop(self): + if self.looper: + get_event_loop().call_later(self.repeatInterval / 1000, self.loop) def ws_on_close(self): super().ws_on_close() @@ -43,7 +56,7 @@ def ws_on_close(self): def message(self, target, message): targets = [] if type(target) is str: - targets.push(target) + targets.append(target) else: targets = target @@ -66,7 +79,7 @@ def notify_to_module(self, obj): self.error({"alert": "error", "message": msg}) if self.ondebug: - self.ondebug(obj.debug) + self.ondebug(obj["debug"]) ####################### # ReadParts diff --git a/obniz/obniz/libs/embeds/ble/ble_attribute_abstract.py b/obniz/obniz/libs/embeds/ble/ble_attribute_abstract.py index 96f4c9e..05fc3ae 100644 --- a/obniz/obniz/libs/embeds/ble/ble_attribute_abstract.py +++ b/obniz/obniz/libs/embeds/ble/ble_attribute_abstract.py @@ -1,7 +1,7 @@ from pyee import AsyncIOEventEmitter -import asyncio from .ble_helper import BleHelper +from ...utils.eventloop import get_event_loop from ...utils.util import ObnizUtil @@ -119,8 +119,7 @@ def write_number(self, val, need_response=False): # } def read_wait(self): - # get_running_loop() function is preferred on Python >= 3.7 - future = asyncio.get_event_loop().create_future() + future = get_event_loop().create_future() def cb(params): if params["result"] == "success": future.set_result(params["data"]) diff --git a/obniz/obniz/libs/embeds/switch.py b/obniz/obniz/libs/embeds/switch.py index e2790ef..f49edcf 100644 --- a/obniz/obniz/libs/embeds/switch.py +++ b/obniz/obniz/libs/embeds/switch.py @@ -1,4 +1,4 @@ -import asyncio +from ..utils.eventloop import get_event_loop class ObnizSwitch: def __init__(self, obniz): @@ -19,8 +19,7 @@ def add_observer(self, future): self.observers.append(future) def get_wait(self): - # get_running_loop() function is preferred on Python >= 3.7 - future = asyncio.get_event_loop().create_future() + future = get_event_loop().create_future() self.add_observer(future) obj = {} obj["switch"] = "get" @@ -28,8 +27,7 @@ def get_wait(self): return future def state_wait(self, is_pressed): - # get_running_loop() function is preferred on Python >= 3.7 - future = asyncio.get_event_loop().create_future() + future = get_event_loop().create_future() def on_change_for_state_wait(pressed): def noop(*args): pass diff --git a/obniz/obniz/libs/io_peripherals/ad.py b/obniz/obniz/libs/io_peripherals/ad.py index 98b9814..5a75ee7 100644 --- a/obniz/obniz/libs/io_peripherals/ad.py +++ b/obniz/obniz/libs/io_peripherals/ad.py @@ -1,4 +1,4 @@ -import asyncio +from ..utils.eventloop import get_event_loop class PeripheralAD: def __init__(self, obniz, id): @@ -23,8 +23,7 @@ def start(self, callback=None): return self.value def get_wait(self): - # get_running_loop() function is preferred on Python >= 3.7 - future = asyncio.get_event_loop().create_future() + future = get_event_loop().create_future() self.add_observer(future) obj = {} obj["ad" + str(self.id)] = {"stream": False} diff --git a/obniz/obniz/libs/io_peripherals/i2c.py b/obniz/obniz/libs/io_peripherals/i2c.py index a8d68f7..8b49117 100644 --- a/obniz/obniz/libs/io_peripherals/i2c.py +++ b/obniz/obniz/libs/io_peripherals/i2c.py @@ -1,6 +1,6 @@ +from ..utils.eventloop import get_event_loop from ..utils.util import ObnizUtil -import asyncio class PeripheralI2C: def __init__(self, obniz, id): @@ -146,7 +146,7 @@ def read_wait(self, address, length): if length > 1024: raise Exception("i2c: data length should be under 1024 bytes") - future = asyncio.get_event_loop().create_future() + future = get_event_loop().create_future() self.add_observer(future) obj = {} obj["i2c" + str(self.id)] = {"address": address, "read": length} diff --git a/obniz/obniz/libs/io_peripherals/io.py b/obniz/obniz/libs/io_peripherals/io.py index 380d8f4..c5e1429 100644 --- a/obniz/obniz/libs/io_peripherals/io.py +++ b/obniz/obniz/libs/io_peripherals/io.py @@ -1,4 +1,4 @@ -import asyncio +from ..utils.eventloop import get_event_loop class PeripheralIO: def __init__(self, obniz, id): @@ -66,7 +66,7 @@ def input(self, callback): return self.value def input_wait(self): - future = asyncio.get_event_loop().create_future() + future = get_event_loop().create_future() self.add_observer(future) obj = {} obj["io" + str(self.id)] = {"direction": "input", "stream": False} diff --git a/obniz/obniz/libs/io_peripherals/io_.py b/obniz/obniz/libs/io_peripherals/io_.py index de42bc9..ee9b444 100644 --- a/obniz/obniz/libs/io_peripherals/io_.py +++ b/obniz/obniz/libs/io_peripherals/io_.py @@ -1,6 +1,8 @@ import asyncio import datetime +from ..utils.eventloop import get_event_loop + class PeripheralIO_: # noqa: N801 def __init__(self, obniz): @@ -53,8 +55,7 @@ def repeat_wait(self, array, repeat): if (type(repeat) is not int): raise Exception("please provide integer number like 1, 2, 3,,,") - # get_running_loop() function is preferred on Python >= 3.7 - future = asyncio.get_event_loop().create_future() + future = get_event_loop().create_future() name = "_repeatwait" + datetime.datetime.now().strftime("%Y%m%d%H%M%S") if self._animation_identifier + 1 > 1000: self._animation_identifier = 0 diff --git a/obniz/obniz/libs/io_peripherals/spi.py b/obniz/obniz/libs/io_peripherals/spi.py index 0c8cabb..a8469e0 100644 --- a/obniz/obniz/libs/io_peripherals/spi.py +++ b/obniz/obniz/libs/io_peripherals/spi.py @@ -1,7 +1,8 @@ +import semver + +from ..utils.eventloop import get_event_loop from ..utils.util import ObnizUtil -import asyncio -import semver class PeripheralSPI: def __init__(self, obniz, id): @@ -99,7 +100,7 @@ def write_wait(self, data): + ". Please update obniz firmware" ) - future = asyncio.get_event_loop().create_future() + future = get_event_loop().create_future() self.add_observer(future) obj = {} obj["spi" + str(self.id)] = {"data": data, "read": True} diff --git a/obniz/obniz/libs/measurements/logicanalyzer.py b/obniz/obniz/libs/measurements/logicanalyzer.py index 4553015..34cdbd9 100644 --- a/obniz/obniz/libs/measurements/logicanalyzer.py +++ b/obniz/obniz/libs/measurements/logicanalyzer.py @@ -8,6 +8,7 @@ def __init__(self, obniz): def _reset(self): self.onmeasured = None + self.measured = None def start(self, params): err = ObnizUtil._required_keys(params, ["io", "interval", "duration"]) @@ -47,4 +48,4 @@ def notified(self, obj): if not self.measured: self.measured = [] - self.measured.push(obj["data"]) + self.measured.append(obj["data"]) diff --git a/obniz/obniz/libs/utils/eventloop.py b/obniz/obniz/libs/utils/eventloop.py new file mode 100644 index 0000000..5724264 --- /dev/null +++ b/obniz/obniz/libs/utils/eventloop.py @@ -0,0 +1,33 @@ +import asyncio +import warnings + + +def get_event_loop(): + """Return a usable event loop on all supported Python versions. + + Prefers the running loop. Otherwise returns the loop registered for the + current thread, creating and registering a new one when none exists — + Python 3.14 removed the implicit creation from asyncio.get_event_loop(), + and 3.10-3.13 emit DeprecationWarning for it. + """ + try: + return asyncio.get_running_loop() + except RuntimeError: + pass + + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + loop = asyncio.get_event_loop() + if loop.is_closed(): + raise RuntimeError("event loop is closed") + return loop + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop + + +def ensure_future(coro): + """Schedule a coroutine on the loop returned by get_event_loop().""" + return asyncio.ensure_future(coro, loop=get_event_loop()) diff --git a/obniz/obniz/obniz_connection.py b/obniz/obniz/obniz_connection.py index bedf12b..88b77c3 100644 --- a/obniz/obniz/obniz_connection.py +++ b/obniz/obniz/obniz_connection.py @@ -1,10 +1,11 @@ -import asyncio +import inspect import json from pyee import AsyncIOEventEmitter import websockets from .__version__ import __version__ +from .libs.utils.eventloop import ensure_future, get_event_loop from .libs.utils.util import ObnizUtil class ObnizConnection: @@ -104,7 +105,7 @@ def cb(connected): self.emitter.once("closed", lambda: cb(False)) if timeout: - asyncio.get_event_loop().call_later(timeout, lambda: cb(False)) + get_event_loop().call_later(timeout, lambda: cb(False)) self.connect() @@ -177,7 +178,7 @@ async def connecting(): self.error(e) break - asyncio.ensure_future(connecting()) + ensure_future(connecting()) # _connectLocal(host) { # const url = 'ws://' + host @@ -285,7 +286,7 @@ def close(self): if self.socket.open: # Connecting & Connected self.connection_state = 'closing' - self.socket.close(1000, "close") + ensure_future(self.socket.close(1000, "close")) self.clear_socket(self.socket) self.socket = None @@ -312,8 +313,8 @@ def _call_on_connect(self): if should_call: if self.onconnect: try: - if asyncio.iscoroutinefunction(self.onconnect): - asyncio.ensure_future(self.onconnect(self)) + if inspect.iscoroutinefunction(self.onconnect): + ensure_future(self.onconnect(self)) else: self.onconnect(self) except Exception as e: @@ -348,7 +349,7 @@ def send(self, obj, options=None): self._send_routed(send_data) def _send_routed(self, data): - if self.socket_local and self.socket_local.on and type(data) is not str: + if self.socket_local is not None and type(data) is not str: self.print_debug("send via local") self.socket_local.send(data) if self.socket_local.buffered_amount > self.bufferd_amound_warn_bytes: @@ -358,7 +359,7 @@ def _send_routed(self, data): return if self.socket and self.socket.open: - asyncio.ensure_future(self.socket.send(data)) + ensure_future(self.socket.send(data)) # if self.socket.buffered_amount > self.bufferd_amound_warn_bytes: # self.warning( # f'over {self.socket.buffered_amount} bytes queued' @@ -435,7 +436,7 @@ def handle_ws_command(self, ws_obj): self.print_debug("WS connection changed to " + server) # close current ws immidiately - self.socket.close(1000, "close") + ensure_future(self.socket.close(1000, "close")) self.clear_socket(self.socket) self.socket = None diff --git a/obniz/obniz/obniz_system_methods.py b/obniz/obniz/obniz_system_methods.py index ca49266..7db565c 100644 --- a/obniz/obniz/obniz_system_methods.py +++ b/obniz/obniz/obniz_system_methods.py @@ -2,6 +2,8 @@ from random import random from time import time import asyncio + +from .libs.utils.eventloop import get_event_loop from .obniz_components import ObnizComponents @@ -57,8 +59,7 @@ def ping_wait(self, unixtime=None, rand=None, force_global_network=None): obj = {"system": {"ping": {"key": buf}}} self.send(obj, {"local_connect": not force_global_network}) - # get_running_loop() function is preferred on Python >= 3.7 - future = asyncio.get_event_loop().create_future() + future = get_event_loop().create_future() def cb(system_obj): for i, b in enumerate(buf): if b != system_obj["pong"]["key"][i]: diff --git a/obniz/parts/DistanceSensor/HC-SR04/__init__.py b/obniz/parts/DistanceSensor/HC-SR04/__init__.py index 6db0597..6e11537 100644 --- a/obniz/parts/DistanceSensor/HC-SR04/__init__.py +++ b/obniz/parts/DistanceSensor/HC-SR04/__init__.py @@ -1,8 +1,9 @@ +import math + from attrdict import AttrDefault -import asyncio +from obniz.obniz.libs.utils.eventloop import get_event_loop -import math class HCSR04: def __init__(self): @@ -62,8 +63,7 @@ def anonym_func(edges): ) def measure_wait(self): - # get_running_loop() function is preferred on Python >= 3.7 - future = asyncio.get_event_loop().create_future() + future = get_event_loop().create_future() self.measure(future=future) return future diff --git a/obniz/parts/MovementSensor/Button/__init__.py b/obniz/parts/MovementSensor/Button/__init__.py index c3c7f6f..c5cd701 100644 --- a/obniz/parts/MovementSensor/Button/__init__.py +++ b/obniz/parts/MovementSensor/Button/__init__.py @@ -1,6 +1,7 @@ from attrdict import AttrDefault -import asyncio +from obniz.obniz.libs.utils.eventloop import get_event_loop + class Button: def __init__(self): @@ -33,8 +34,7 @@ async def is_pressed_wait(self): return ret == False def state_wait(self, is_pressed): - # get_running_loop() function is preferred on Python >= 3.7 - future = asyncio.get_event_loop().create_future() + future = get_event_loop().create_future() def onpress(pressed): def nothing(*args): pass diff --git a/setup.py b/setup.py index ab40aa4..120a83d 100644 --- a/setup.py +++ b/setup.py @@ -45,6 +45,8 @@ 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', + 'Programming Language :: Python :: 3.14', "Operating System :: OS Independent", ], ) diff --git a/tests/conftest.py b/tests/conftest.py index 794aa95..82d8efc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,35 @@ +import asyncio + import pytest from .utils import release_obnize, setup_obniz, receive_json, assert_send +@pytest.fixture(autouse=True) +def _event_loop(): + # Python 3.14: asyncio.get_event_loop() no longer creates a loop + # implicitly, so register a fresh one for each test. + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + yield loop + try: + # let cleanup tasks run first — websockets' server.close() releases + # its listen socket in a task, so cancelling it would leak the port + loop.run_until_complete(asyncio.sleep(0.02)) + # then cancel whatever is still running (ws connect/recv loops etc.) + # so closing the loop does not leak "Event loop is closed" errors + pending = asyncio.all_tasks(loop) + for task in pending: + task.cancel() + if pending: + loop.run_until_complete( + asyncio.gather(*pending, return_exceptions=True) + ) + finally: + loop.close() + asyncio.set_event_loop(None) + + @pytest.fixture(scope="function") def obniz(mocker): obniz = setup_obniz(mocker) @@ -12,6 +39,7 @@ def obniz(mocker): yield obniz release_obnize(obniz) + @pytest.fixture(scope="function") def uninitialized_obniz(mocker): obniz = setup_obniz(mocker) diff --git a/tests/libs/test_system.py b/tests/libs/test_system.py index 045ccfc..9a878d3 100644 --- a/tests/libs/test_system.py +++ b/tests/libs/test_system.py @@ -82,7 +82,8 @@ def test_self_check(self, obniz): assert_finished(obniz) def test_wait(self, obniz): - obniz.wait(500) + coro = obniz.wait(500) + coro.close() # payload check only; skip the actual sleep assert_send(obniz, [{"system": {"wait": 500}}]) assert_finished(obniz) diff --git a/tests/test_runtime_fixes.py b/tests/test_runtime_fixes.py new file mode 100644 index 0000000..5dd54d3 --- /dev/null +++ b/tests/test_runtime_fixes.py @@ -0,0 +1,90 @@ +import asyncio + +from .utils import assert_finished, assert_send, receive_json + + +class TestMessage: + def test_message_to_string_target(self, obniz): + obniz.message("12345678", "button pressed") + + assert_send( + obniz, [{"message": {"to": ["12345678"], "data": "button pressed"}}] + ) + assert_finished(obniz) + + def test_message_to_list_target(self, obniz): + obniz.message(["12345678", "87654321"], "button pressed") + + assert_send( + obniz, + [{"message": {"to": ["12345678", "87654321"], "data": "button pressed"}}], + ) + assert_finished(obniz) + + +class TestOndebug: + def test_ondebug_called_with_dict(self, mocker, obniz): + stub = mocker.stub() + obniz.ondebug = stub + + receive_json( + obniz, [{"debug": {"warning": {"message": "unknown command"}}}] + ) + + assert stub.call_count == 1 + assert stub.call_args[0][0] == {"warning": {"message": "unknown command"}} + + +class TestLogicAnalyzerMeasured: + def test_measured_accumulates_without_onmeasured(self, obniz): + obniz.logicAnalyzer.start({"io": 1, "interval": 0.1, "duration": 100}) + assert_send( + obniz, [{"logic_analyzer": {"interval": 0.1, "io": [1], "duration": 100}}] + ) + + data1 = [0, 1] * 100 + data2 = [1, 0] * 100 + receive_json(obniz, [{"logic_analyzer": {"data": data1}}]) + receive_json(obniz, [{"logic_analyzer": {"data": data2}}]) + + assert obniz.logicAnalyzer.measured == [data1, data2] + assert_finished(obniz) + + +class TestClose: + def test_close_awaits_socket_close(self, obniz): + socket = obniz.socket + obniz.close() + + loop = asyncio.get_event_loop() + loop.run_until_complete(asyncio.sleep(0)) + + socket.close.assert_awaited_once_with(1000, "close") + assert obniz.socket is None + assert obniz.connection_state == "closed" + + +class TestRepeat: + def test_repeat_with_sync_callback(self, obniz): + calls = [] + obniz.repeat(lambda: calls.append(1), interval=10) + + loop = asyncio.get_event_loop() + loop.run_until_complete(asyncio.sleep(0.05)) + obniz.looper = None + + assert len(calls) >= 2 + + def test_repeat_with_async_callback(self, obniz): + calls = [] + + async def looper(): + calls.append(1) + + obniz.repeat(looper, interval=10) + + loop = asyncio.get_event_loop() + loop.run_until_complete(asyncio.sleep(0.05)) + obniz.looper = None + + assert len(calls) >= 2 diff --git a/tests/utils.py b/tests/utils.py index 2ec08e8..b3f1f6e 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -28,7 +28,7 @@ def setup_obniz(mocker): stub = mocker.MagicMock(buffered_amount=0) # stub.on = mocker.stub() stub.send = AsyncMock() - # stub.close = mocker.stub() + stub.close = AsyncMock() # stub.removeAllListeners = mocker.stub() mocker.patch("obniz.Obniz.wsconnect") diff --git a/tox.ini b/tox.ini index 37a8d87..724f6de 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ # and then run "tox" from this directory. [tox] -envlist = py38, py39, py310, py311, py312, py313 +envlist = py38, py39, py310, py311, py312, py313, py314 [testenv] deps =