Skip to content

Commit 3dc1a81

Browse files
authored
chore: New example of model based on #358 (#363)
1 parent 1cef155 commit 3dc1a81

5 files changed

Lines changed: 187 additions & 7 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ requires = ["poetry-core"]
5656
build-backend = "poetry.core.masonry.api"
5757

5858
[tool.pytest.ini_options]
59-
addopts = "--ignore-glob=docs/conf.py --ignore-glob=statemachine/factory_* --ignore-glob=docs/auto_examples/* --ignore-glob=docs/_build/* --cov --cov-config .coveragerc --doctest-glob='*.md' --doctest-modules --doctest-continue-on-failure"
59+
addopts = "--ignore=docs/conf.py --ignore=docs/auto_examples/ --ignore=docs/_build/ --ignore=tests/examples/ --cov --cov-config .coveragerc --doctest-glob='*.md' --doctest-modules --doctest-continue-on-failure"
6060
doctest_optionflags = "ELLIPSIS IGNORE_EXCEPTION_DETAIL NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL"
6161

6262
[tool.mypy]
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""
2+
Persistent domain model
3+
=======================
4+
5+
An example originated from a question: "How to save state to disk?". There are many ways to
6+
implement this, but you can get an insight of one possibility. This example implements a custom
7+
domain model that persists it's state using a generic strategy that can be extended to any storage
8+
format.
9+
10+
Original `issue <https://github.com/fgmacedo/python-statemachine/issues/358>`_.
11+
12+
13+
Resource management state machine
14+
---------------------------------
15+
16+
Given a simple on/off machine for resource management.
17+
18+
"""
19+
20+
import tempfile
21+
from abc import ABC
22+
from abc import abstractmethod
23+
24+
from statemachine import State
25+
from statemachine import StateMachine
26+
27+
28+
class ResourceManagement(StateMachine):
29+
power_off = State(initial=True)
30+
power_on = State()
31+
32+
turn_on = power_off.to(power_on)
33+
shutdown = power_on.to(power_off)
34+
35+
36+
# %%
37+
# Abstract model with persistency protocol
38+
# ----------------------------------------
39+
#
40+
# Abstract Base Class for persistent models.
41+
# Subclasses should implement concrete strategies for:
42+
#
43+
# - `_read_state`: Read the state from the concrete persistent layer.
44+
# - `_write_state`: Write the state from the concrete persistent layer.
45+
46+
47+
class AbstractPersistentModel(ABC):
48+
"""Abstract Base Class for persistent models.
49+
50+
Subclasses should implement concrete strategies for:
51+
52+
- `_read_state`: Read the state from the concrete persistent layer.
53+
- `_write_state`: Write the state from the concrete persistent layer.
54+
"""
55+
56+
def __init__(self):
57+
self._state = None
58+
59+
def __repr__(self):
60+
return f"{type(self).__name__}(state={self.state})"
61+
62+
@property
63+
def state(self):
64+
if self._state is None:
65+
self._state = self._read_state()
66+
return self._state
67+
68+
@state.setter
69+
def state(self, value):
70+
self._state = value
71+
self._write_state(value)
72+
73+
@abstractmethod
74+
def _read_state(self):
75+
...
76+
77+
@abstractmethod
78+
def _write_state(self, value):
79+
...
80+
81+
82+
# %%
83+
# FilePersistentModel: Concrete model strategy
84+
# -----------------------
85+
#
86+
# A concrete implementation of the generic storage protocol above, that reads and writes to a file
87+
# in plain text.
88+
89+
90+
class FilePersistentModel(AbstractPersistentModel):
91+
"""A concrete implementation of a storage strategy for a Model
92+
that reads and writes to a file.
93+
"""
94+
95+
def __init__(self, file):
96+
super().__init__()
97+
self.file = file
98+
99+
def _read_state(self):
100+
self.file.seek(0)
101+
state = self.file.read().strip()
102+
return state if state != "" else None
103+
104+
def _write_state(self, value):
105+
self.file.seek(0)
106+
self.file.truncate(0)
107+
self.file.write(value)
108+
109+
110+
# %%
111+
# Given a temporary file to store our state.
112+
113+
state_file = tempfile.TemporaryFile(mode="r+")
114+
115+
# %%
116+
# Let's create instances and test the persistence.
117+
118+
model = FilePersistentModel(file=state_file)
119+
sm = ResourceManagement(model=model)
120+
121+
print(f"Initial state: {sm.current_state.id}")
122+
123+
sm.send("turn_on")
124+
125+
print(f"State after transition: {sm.current_state.id}")
126+
127+
# %%
128+
# Remove the instances from memory.
129+
130+
del sm
131+
del model
132+
133+
# %%
134+
# Restore the previous state from disk.
135+
136+
model = FilePersistentModel(file=state_file)
137+
sm = ResourceManagement(model=model)
138+
139+
print(f"State restored from file system: {sm.current_state.id}")
140+
141+
# %%
142+
# Closing the file (the temporary file will be removed).
143+
144+
state_file.close()

tests/helpers.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import importlib
2+
from pathlib import Path
3+
4+
5+
def import_module_by_path(src_file: Path):
6+
module_name = str(src_file).replace("/", ".")
7+
try:
8+
return importlib.import_module(module_name)
9+
except ModuleNotFoundError:
10+
return

tests/scrape_images.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import importlib
21
import re
32

43
from sphinx_gallery.scrapers import figure_rst
54

65
from statemachine.contrib.diagram import DotGraphMachine
76
from statemachine.factory import StateMachineMetaclass
87

8+
from .helpers import import_module_by_path
9+
910

1011
class MachineScraper:
1112
"""Scrapes images of the statemachines defined into the examples for the gallery"""
@@ -25,11 +26,7 @@ def _get_module(self, src_file):
2526
if len(module_name) != 1:
2627
return
2728

28-
module_name = module_name[0].replace("/", ".")
29-
try:
30-
return importlib.import_module(module_name)
31-
except ModuleNotFoundError:
32-
return
29+
return import_module_by_path(module_name[0])
3330

3431
def generate_image(self, sm_class, original_path):
3532
image_path = self.re_replace_png_extension.sub(".svg", original_path)

tests/test_examples.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
5+
from .helpers import import_module_by_path
6+
7+
8+
def pytest_generate_tests(metafunc):
9+
if "example_file_wrapper" not in metafunc.fixturenames:
10+
return
11+
12+
file_names = [
13+
pytest.param(example_path, id=f"{example_path}")
14+
for example_path in Path("tests/examples").glob("**/*_machine.py")
15+
]
16+
metafunc.parametrize("file_name", file_names)
17+
18+
19+
@pytest.fixture()
20+
def example_file_wrapper(file_name):
21+
def execute_file_wrapper():
22+
import_module_by_path(file_name)
23+
24+
return execute_file_wrapper
25+
26+
27+
def test_example(example_file_wrapper):
28+
"""Import the example file so the module is executed"""
29+
example_file_wrapper()

0 commit comments

Comments
 (0)