From 402667782f20c2fd815ae78062d023faade95dd7 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Mon, 10 Jun 2024 18:41:41 -0300 Subject: [PATCH 1/3] Fix regression with asyncio and threading (#445) * Fixes issue 443 https://github.com/fgmacedo/python-statemachine/issues/443 Uses `new_event_loop` call when there is no running asyncio event_loop, instead of deprecated `get_event_loop`. * chore: Using threadlocal to cache the loop used on sync codebases, one loop per thread * docs: Adding JS Bueno as contributor --------- Co-authored-by: Joao S O Bueno --- docs/authors.md | 1 + statemachine/utils.py | 14 +++-- tests/test_deepcopy.py | 2 +- tests/test_threading.py | 127 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 tests/test_threading.py diff --git a/docs/authors.md b/docs/authors.md index 2ad90c6b..f225ad1a 100644 --- a/docs/authors.md +++ b/docs/authors.md @@ -9,6 +9,7 @@ * [Guilherme Nepomuceno](mailto:piercio@loggi.com) * [Rafael Rêgo](mailto:crafards@gmail.com) * [Raphael Schrader](mailto:raphael@schradercloud.de) +* [João S. O. Bueno](mailto:gwidion@gmail.com) ## Scaffolding diff --git a/statemachine/utils.py b/statemachine/utils.py index 30d972f8..6d0edcd6 100644 --- a/statemachine/utils.py +++ b/statemachine/utils.py @@ -1,4 +1,8 @@ import asyncio +import threading + +_cached_loop = threading.local() +"""Loop that will be used when the SM is running in a synchronous context. One loop per thread.""" def qualname(cls): @@ -23,11 +27,13 @@ def ensure_iterable(obj): def run_async_from_sync(coroutine): """ - Run an async coroutine from a synchronous context. + Compatibility layer to run an async coroutine from a synchronous context. """ + global _cached_loop try: - loop = asyncio.get_running_loop() + asyncio.get_running_loop() return asyncio.ensure_future(coroutine) except RuntimeError: - loop = asyncio.get_event_loop() - return loop.run_until_complete(coroutine) + if not hasattr(_cached_loop, "loop"): + _cached_loop.loop = asyncio.new_event_loop() + return _cached_loop.loop.run_until_complete(coroutine) diff --git a/tests/test_deepcopy.py b/tests/test_deepcopy.py index d7e6cfa6..13b53b33 100644 --- a/tests/test_deepcopy.py +++ b/tests/test_deepcopy.py @@ -71,7 +71,7 @@ def test_deepcopy_with_observers(caplog): assert sm1.model is not sm2.model - caplog.set_level(logging.DEBUG) + caplog.set_level(logging.DEBUG, logger="tests") def assertions(sm, _reference): caplog.clear() diff --git a/tests/test_threading.py b/tests/test_threading.py new file mode 100644 index 00000000..90bedb9a --- /dev/null +++ b/tests/test_threading.py @@ -0,0 +1,127 @@ +import threading +import time + +from statemachine.state import State +from statemachine.statemachine import StateMachine + + +def test_machine_should_allow_multi_thread_event_changes(): + """ + Test for https://github.com/fgmacedo/python-statemachine/issues/443 + """ + + class CampaignMachine(StateMachine): + "A workflow machine" + + draft = State(initial=True) + producing = State() + closed = State() + add_job = draft.to(producing) | producing.to(closed) + + machine = CampaignMachine() + + def off_thread_change_state(): + time.sleep(0.01) + machine.add_job() + + thread = threading.Thread(target=off_thread_change_state) + thread.start() + thread.join() + assert machine.current_state.id == "producing" + + +def test_regression_443(): + """ + Test for https://github.com/fgmacedo/python-statemachine/issues/443 + """ + time_collecting = 0.2 + time_to_send = 0.125 + time_sampling_current_state = 0.05 + + class TrafficLightMachine(StateMachine): + "A traffic light machine" + + green = State(initial=True) + yellow = State() + red = State() + + cycle = green.to(yellow) | yellow.to(red) | red.to(green) + + class Controller: + def __init__(self): + self.statuses_history = [] + self.fsm = TrafficLightMachine() + # set up thread + t = threading.Thread(target=self.recv_cmds) + t.start() + + def recv_cmds(self): + """Pretend we receive a command triggering a state change after Xs.""" + waiting_time = 0 + sent = False + while waiting_time < time_collecting: + if waiting_time >= time_to_send and not sent: + self.fsm.cycle() + sent = True + + waiting_time += time_sampling_current_state + self.statuses_history.append(self.fsm.current_state.id) + time.sleep(time_sampling_current_state) + + c1 = Controller() + c2 = Controller() + time.sleep(time_collecting + 0.01) + assert c1.statuses_history == ["green", "green", "green", "yellow"] + assert c2.statuses_history == ["green", "green", "green", "yellow"] + + +def test_regression_443_with_modifications(): + """ + Test for https://github.com/fgmacedo/python-statemachine/issues/443 + """ + time_collecting = 0.2 + time_to_send = 0.125 + time_sampling_current_state = 0.05 + + class TrafficLightMachine(StateMachine): + "A traffic light machine" + + green = State(initial=True) + yellow = State() + red = State() + + cycle = green.to(yellow) | yellow.to(red) | red.to(green) + + def __init__(self, name): + self.name = name + self.statuses_history = [] + super().__init__() + + def beat(self): + waiting_time = 0 + sent = False + while waiting_time < time_collecting: + if waiting_time >= time_to_send and not sent: + self.cycle() + sent = True + + self.statuses_history.append(f"{self.name}.{self.current_state.id}") + + time.sleep(time_sampling_current_state) + waiting_time += time_sampling_current_state + + class Controller: + def __init__(self, name): + self.fsm = TrafficLightMachine(name) + # set up thread + t = threading.Thread(target=self.fsm.beat) + t.start() + + c1 = Controller("c1") + c2 = Controller("c2") + c3 = Controller("c3") + time.sleep(time_collecting + 0.01) + + assert c1.fsm.statuses_history == ["c1.green", "c1.green", "c1.green", "c1.yellow"] + assert c2.fsm.statuses_history == ["c2.green", "c2.green", "c2.green", "c2.yellow"] + assert c3.fsm.statuses_history == ["c3.green", "c3.green", "c3.green", "c3.yellow"] From 042a3e1725432e1800dd9288c43b379f0c7373ad Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Mon, 10 Jun 2024 18:43:51 -0300 Subject: [PATCH 2/3] docs: Fix typo on README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4b14d905..ae1f2dea 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Python [finite-state machines](https://en.wikipedia.org/wiki/Finite-state_machin Welcome to python-statemachine, an intuitive and powerful state machine library designed for a -great developer experience. We provide an _pythonic_ and expressive API for implementing state +great developer experience. We provide a _pythonic_ and expressive API for implementing state machines in sync or asynchonous Python codebases. ## Features From c1a6a63ef77eb7969fa32614d6f7ccd42a0cc20c Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Mon, 10 Jun 2024 18:50:02 -0300 Subject: [PATCH 3/3] chore: New 2.3.1 release --- docs/releases/index.md | 1 + pyproject.toml | 2 +- statemachine/__init__.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/releases/index.md b/docs/releases/index.md index aea3fca5..896ec0b6 100644 --- a/docs/releases/index.md +++ b/docs/releases/index.md @@ -15,6 +15,7 @@ Below are release notes through StateMachine and its patch releases. ```{toctree} :maxdepth: 2 +2.3.1 2.3.0 2.2.0 2.1.2 diff --git a/pyproject.toml b/pyproject.toml index 31d1d1ce..ab17e7be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-statemachine" -version = "2.3.0" +version = "2.3.1" description = "Python Finite State Machines made easy." authors = ["Fernando Macedo "] maintainers = [ diff --git a/statemachine/__init__.py b/statemachine/__init__.py index 158ad8c9..ffcc387c 100644 --- a/statemachine/__init__.py +++ b/statemachine/__init__.py @@ -3,6 +3,6 @@ __author__ = """Fernando Macedo""" __email__ = "fgmacedo@gmail.com" -__version__ = "2.3.0" +__version__ = "2.3.1" __all__ = ["StateMachine", "State"]