Skip to content

Commit

Permalink
Merge branch 'release/2.1.1'
Browse files Browse the repository at this point in the history
  • Loading branch information
fgmacedo committed Aug 3, 2023
2 parents f95391a + 1b0664c commit 0b2c6cd
Show file tree
Hide file tree
Showing 11 changed files with 123 additions and 35 deletions.
2 changes: 1 addition & 1 deletion .readthedocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ build:
- poetry config virtualenvs.create false
# Install dependencies with 'docs' dependency group
# https://python-poetry.org/docs/managing-dependencies/#dependency-groups
- poetry install --with diagrams,docs
- poetry install --extras diagrams --with docs

# Build documentation in the docs/ directory with Sphinx
sphinx:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ https://python-statemachine.readthedocs.io.
- If you found this project helpful, please consider giving it a star on GitHub.

- **Contribute code**: If you would like to contribute code to this project, please submit a pull
request. For more information on how to contribute, please see our [contributing.md]contributing.md) file.
request. For more information on how to contribute, please see our [contributing.md](contributing.md) file.

- **Report bugs**: If you find any bugs in this project, please report them by opening an issue
on our GitHub issue tracker.
Expand Down
10 changes: 10 additions & 0 deletions docs/releases/2.1.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# StateMachine 2.1.1

*August 3, 2023*


## Bugfixes in 2.1.1

- Fixes [#391](https://github.com/fgmacedo/python-statemachine/issues/391) adding support to
[pytest-mock](https://pytest-mock.readthedocs.io/en/latest/index.html) `spy` method.
- Improved factory type hints [#399](https://github.com/fgmacedo/python-statemachine/pull/399).
1 change: 1 addition & 0 deletions docs/releases/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Below are release notes through StateMachine and its patch releases.
```{toctree}
:maxdepth: 1
2.1.1
2.1.0
2.0.0
Expand Down
20 changes: 19 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "python-statemachine"
version = "2.1.0"
version = "2.1.1"
description = "Python Finite State Machines made easy."
authors = ["Fernando Macedo <fgmacedo@gmail.com>"]
maintainers = [
Expand Down Expand Up @@ -45,6 +45,7 @@ pre-commit = "^2.21.0"
mypy = "^0.991"
black = "^22.12.0"
pdbpp = "^0.10.3"
pytest-mock = "^3.10.0"

[tool.poetry.group.docs.dependencies]
Sphinx = "4.5.0"
Expand Down
2 changes: 1 addition & 1 deletion statemachine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@

__author__ = """Fernando Macedo"""
__email__ = "fgmacedo@gmail.com"
__version__ = "2.1.0"
__version__ = "2.1.1"

__all__ = ["StateMachine", "State"]
66 changes: 36 additions & 30 deletions statemachine/factory.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import TYPE_CHECKING
from typing import Any
from typing import Dict
from typing import List
from typing import Tuple
from uuid import uuid4

Expand Down Expand Up @@ -31,7 +32,13 @@ def __init__(cls, name: str, bases: Tuple[type], attrs: Dict[str, Any]):
cls.add_inherited(bases)
cls.add_from_attributes(attrs)

cls._set_special_states()
try:
cls.initial_state: State = next(s for s in cls.states if s.initial)
except StopIteration:
cls.initial_state = None # Abstract SM still don't have states

cls.final_states: List[State] = [state for state in cls.states if state.final]

cls._check()

if TYPE_CHECKING:
Expand All @@ -40,35 +47,6 @@ def __init__(cls, name: str, bases: Tuple[type], attrs: Dict[str, Any]):
def __getattr__(self, attribute: str) -> Any:
...

def _set_special_states(cls):
if not cls.states:
return
initials = [s for s in cls.states if s.initial]
if len(initials) != 1:
raise InvalidDefinition(
_(
"There should be one and only one initial state. "
"Your currently have these: {!r}"
).format([s.id for s in initials])
)
cls.initial_state = initials[0]
cls.final_states = [state for state in cls.states if state.final]

def _disconnected_states(cls, starting_state):
visitable_states = set(visit_connected_states(starting_state))
return set(cls.states) - visitable_states

def _check_disconnected_state(cls):
disconnected_states = cls._disconnected_states(cls.initial_state)
if disconnected_states:
raise InvalidDefinition(
_(
"There are unreachable states. "
"The statemachine graph should have a single component. "
"Disconnected states: {}"
).format([s.id for s in disconnected_states])
)

def _check(cls):
has_states = bool(cls.states)
has_events = bool(cls._events)
Expand All @@ -85,8 +63,21 @@ def _check(cls):
if not has_events:
raise InvalidDefinition(_("There are no events."))

cls._check_initial_state()
cls._check_final_states()
cls._check_disconnected_state()

def _check_initial_state(cls):
initials = [s for s in cls.states if s.initial]
if len(initials) != 1:
raise InvalidDefinition(
_(
"There should be one and only one initial state. "
"Your currently have these: {!r}"
).format([s.id for s in initials])
)

def _check_final_states(cls):
final_state_with_invalid_transitions = [
state for state in cls.final_states if state.transitions
]
Expand All @@ -98,6 +89,21 @@ def _check(cls):
).format([s.id for s in final_state_with_invalid_transitions])
)

def _disconnected_states(cls, starting_state):
visitable_states = set(visit_connected_states(starting_state))
return set(cls.states) - visitable_states

def _check_disconnected_state(cls):
disconnected_states = cls._disconnected_states(cls.initial_state)
if disconnected_states:
raise InvalidDefinition(
_(
"There are unreachable states. "
"The statemachine graph should have a single component. "
"Disconnected states: {}"
).format([s.id for s in disconnected_states])
)

def add_inherited(cls, bases):
for base in bases:
for state in getattr(base, "states", []):
Expand Down
10 changes: 10 additions & 0 deletions statemachine/signature.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any:
ba = self.bind_expected(*args, **kwargs)
return self.method(*ba.args, **ba.kwargs)

@classmethod
def from_callable(cls, method):
if hasattr(method, "__signature__"):
sig = method.__signature__
return SignatureAdapter(
sig.parameters.values(),
return_annotation=sig.return_annotation,
)
return super().from_callable(method)

def bind_expected(self, *args: Any, **kwargs: Any) -> BoundArguments: # noqa: C901
"""Get a BoundArguments object, that maps the passed `args`
and `kwargs` to the function's signature. It avoids to raise `TypeError`
Expand Down
25 changes: 25 additions & 0 deletions tests/test_mock_compatibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from statemachine import State
from statemachine import StateMachine


def test_minimal(mocker):
class Observer:
def on_enter_state(self, event, model, source, target, state):
...

obs = Observer()
on_enter_state = mocker.spy(obs, "on_enter_state")

class Machine(StateMachine):
a = State("Init", initial=True)
b = State("Fin")

cycle = a.to(b) | b.to(a)

state = Machine().add_observer(obs)
assert state.a.is_active

state.cycle()

assert state.b.is_active
on_enter_state.assert_called_once()
17 changes: 17 additions & 0 deletions tests/test_statemachine.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,23 @@ class CampaignMachine(StateMachine):
deliver = producing.to(closed)


def test_machine_should_activate_initial_state():
class CampaignMachine(StateMachine):
"A workflow machine"
producing = State()
closed = State()
draft = State(initial=True)

add_job = draft.to(draft) | producing.to(producing)
produce = draft.to(producing)
deliver = producing.to(closed)

sm = CampaignMachine()

assert sm.current_state == sm.draft
assert sm.current_state.is_active


def test_machine_should_not_allow_transitions_from_final_state():
with pytest.raises(exceptions.InvalidDefinition):

Expand Down

0 comments on commit 0b2c6cd

Please sign in to comment.