Skip to content

Commit

Permalink
Merge branch 'release/2.1.2'
Browse files Browse the repository at this point in the history
  • Loading branch information
fgmacedo committed Oct 6, 2023
2 parents 0b2c6cd + 2d9b259 commit dbfc4e9
Show file tree
Hide file tree
Showing 23 changed files with 1,066 additions and 618 deletions.
1 change: 1 addition & 0 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ jobs:
- name: Install Poetry
uses: snok/install-poetry@v1
with:
version: 1.5.1
virtualenvs-create: true
virtualenvs-in-project: true
installer-parallel: true
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
prof/
.benchmarks/
htmlcov/
.tox/
.coverage
Expand Down
2 changes: 1 addition & 1 deletion docs/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ All actions and {ref}`guards` support multiple method signatures. They follow th

For each defined {ref}`state`, you can declare `enter` and `exit` callbacks.

### Declare state actions by naming convention
### Bind state actions by naming convention

Callbacks by naming convention will be searched on the StateMachine and on the
model, using the patterns:
Expand Down
20 changes: 20 additions & 0 deletions docs/releases/2.1.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# StateMachine 2.1.2

*October 6, 2023*

This release improves the setup performance of the library by a 10x factor, with a major
refactoring on how we handle the callbacks registry and validations.

See [#401](https://github.com/fgmacedo/python-statemachine/issues/401) for the technical details.


## Python compatibility 2.1.2

StateMachine 2.1.2 supports Python 3.7, 3.8, 3.9, 3.10, 3.11 and 3.12.

On the next major release (3.0.0), we will drop support for Python 3.7.

## Bugfixes in 2.1.2

- Fixes [#406](https://github.com/fgmacedo/python-statemachine/issues/406) action callback being
called twice when mixing decorator syntax combined with the naming convention.
3 changes: 2 additions & 1 deletion docs/releases/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ Below are release notes through StateMachine and its patch releases.
### 2.0 releases

```{toctree}
:maxdepth: 1
:maxdepth: 2
2.1.2
2.1.1
2.1.0
2.0.0
Expand Down
633 changes: 343 additions & 290 deletions poetry.lock

Large diffs are not rendered by default.

12 changes: 8 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "python-statemachine"
version = "2.1.1"
version = "2.1.2"
description = "Python Finite State Machines made easy."
authors = ["Fernando Macedo <fgmacedo@gmail.com>"]
maintainers = [
Expand All @@ -26,14 +26,15 @@ classifiers = [
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Software Development :: Libraries"
]

[tool.poetry.extras]
diagrams = ["pydot"]

[tool.poetry.dependencies]
python = ">=3.7, <3.12"
python = ">=3.7, <3.13"

[tool.poetry.group.dev.dependencies]
pytest = "^7.2.0"
Expand All @@ -46,6 +47,8 @@ mypy = "^0.991"
black = "^22.12.0"
pdbpp = "^0.10.3"
pytest-mock = "^3.10.0"
pytest-profiling = "^1.7.0"
pytest-benchmark = "^4.0.0"

[tool.poetry.group.docs.dependencies]
Sphinx = "4.5.0"
Expand All @@ -60,13 +63,14 @@ requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.pytest.ini_options]
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"
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 --benchmark-autosave"
doctest_optionflags = "ELLIPSIS IGNORE_EXCEPTION_DETAIL NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL"

[tool.mypy]
python_version = "3.11"
python_version = "3.12"
warn_return_any = true
warn_unused_configs = true
disable_error_code = "annotation-unchecked"

[[tool.mypy.overrides]]
module = [
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.1"
__version__ = "2.1.2"

__all__ = ["StateMachine", "State"]
204 changes: 146 additions & 58 deletions statemachine/callbacks.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,39 @@
from collections import defaultdict
from collections import deque
from typing import Callable
from typing import Dict
from typing import List

from .exceptions import AttrNotFound
from .exceptions import InvalidDefinition
from .i18n import _
from .utils import ensure_iterable


class CallbackWrapper:
"""A thin wrapper that ensures the target callback is a proper callable.
def __init__(
self,
callback: Callable,
condition: Callable,
unique_key: str,
expected_value: "bool | None" = None,
) -> None:
self._callback = callback
self.condition = condition
self.unique_key = unique_key
self.expected_value = expected_value

def __repr__(self):
return f"{type(self).__name__}({self.unique_key})"

def __call__(self, *args, **kwargs):
result = self._callback(*args, **kwargs)
if self.expected_value is not None:
return bool(result) == self.expected_value
return result


class CallbackMeta:
"""A thin wrapper that register info about actions and guards.
At first, `func` can be a string or a callable, and even if it's already
a callable, his signature can mismatch.
Expand All @@ -14,12 +42,11 @@ class CallbackWrapper:
call is performed, to allow the proper callback resolution.
"""

def __init__(self, func, suppress_errors=False, cond=None):
def __init__(self, func, suppress_errors=False, cond=None, expected_value=None):
self.func = func
self.suppress_errors = suppress_errors
self.cond = Callbacks(factory=ConditionWrapper).add(cond)
self._callback = None
self._resolver_id = None
self.cond = CallbackMetaList().add(cond)
self.expected_value = expected_value

def __repr__(self):
return f"{type(self).__name__}({self.func!r})"
Expand All @@ -28,57 +55,67 @@ def __str__(self):
return getattr(self.func, "__name__", self.func)

def __eq__(self, other):
return self.func == other.func and self._resolver_id == other._resolver_id
return self.func == other.func

def __hash__(self):
return id(self)

def _update_func(self, func):
self.func = func

def setup(self, resolver):
def build(self, resolver) -> "CallbackWrapper | None":
"""
Resolves the `func` into a usable callable.
Args:
resolver (callable): A method responsible to build and return a valid callable that
can receive arbitrary parameters like `*args, **kwargs`.
"""
self.cond.setup(resolver)
try:
self._resolver_id = getattr(resolver, "id", id(resolver))
self._callback = resolver(self.func)
return True
except AttrNotFound:
if not self.suppress_errors:
raise
return False
callback = resolver(self.func)
if not callback.is_empty:
conditions = CallbacksExecutor()
conditions.add(self.cond, resolver)

return CallbackWrapper(
callback=callback,
condition=conditions.all,
unique_key=callback.unique_key,
expected_value=self.expected_value,
)

def __call__(self, *args, **kwargs):
if self._callback is None:
raise InvalidDefinition(
_("Callback {!r} not property configured.").format(self)
if not self.suppress_errors:
raise AttrNotFound(
_("Did not found name '{}' from model or statemachine").format(
self.func
)
)
return self._callback(*args, **kwargs)
return None


class BoolCallbackMeta(CallbackMeta):
"""A thin wrapper that register info about actions and guards.
At first, `func` can be a string or a callable, and even if it's already
a callable, his signature can mismatch.
After instantiation, `.setup(resolver)` must be called before any real
call is performed, to allow the proper callback resolution.
"""

class ConditionWrapper(CallbackWrapper):
def __init__(self, func, suppress_errors=False, expected_value=True):
super().__init__(func, suppress_errors)
def __init__(self, func, suppress_errors=False, cond=None, expected_value=True):
self.func = func
self.suppress_errors = suppress_errors
self.cond = CallbackMetaList().add(cond)
self.expected_value = expected_value

def __str__(self):
name = super().__str__()
return name if self.expected_value else f"!{name}"

def __call__(self, *args, **kwargs):
return bool(super().__call__(*args, **kwargs)) == self.expected_value


class Callbacks:
def __init__(self, resolver=None, factory=CallbackWrapper):
self.items = []
self._resolver = resolver
class CallbackMetaList:
def __init__(self, factory=CallbackMeta):
self.items: List[CallbackMeta] = []
self.factory = factory

def __repr__(self):
Expand All @@ -87,13 +124,6 @@ def __repr__(self):
def __str__(self):
return ", ".join(str(c) for c in self)

def setup(self, resolver):
"""Validate configurations"""
self._resolver = resolver
self.items = [
callback for callback in self.items if callback.setup(self._resolver)
]

def _add_unbounded_callback(self, func, is_event=False, transitions=None, **kwargs):
"""This list was a target for adding a func using decorator
`@<state|event>[.on|before|after|enter|exit]` syntax.
Expand Down Expand Up @@ -135,31 +165,19 @@ def __iter__(self):
def clear(self):
self.items = []

def call(self, *args, **kwargs):
return [
callback(*args, **kwargs)
for callback in self.items
if callback.cond.all(*args, **kwargs)
]

def all(self, *args, **kwargs):
return all(condition(*args, **kwargs) for condition in self)

def _add(self, func, resolver=None, prepend=False, **kwargs):
resolver = resolver or self._resolver

callback = self.factory(func, **kwargs)
if resolver is not None and not callback.setup(resolver):
def _add(self, func, registry=None, prepend=False, **kwargs):
meta = self.factory(func, **kwargs)
if registry is not None and not registry(self, meta, prepend=prepend):
return

if callback in self.items:
if meta in self.items:
return

if prepend:
self.items.insert(0, callback)
self.items.insert(0, meta)
else:
self.items.append(callback)
return callback
self.items.append(meta)
return meta

def add(self, callbacks, **kwargs):
if callbacks is None:
Expand All @@ -170,3 +188,73 @@ def add(self, callbacks, **kwargs):
self._add(func, **kwargs)

return self


class CallbacksExecutor:
def __init__(self):
self.items: List[CallbackWrapper] = deque()
self.items_already_seen = set()

def __iter__(self):
return iter(self.items)

def __repr__(self):
return f"{type(self).__name__}({self.items!r})"

def add_one(
self, callback_info: CallbackMeta, resolver: Callable, prepend: bool = False
) -> "CallbackWrapper | None":
callback = callback_info.build(resolver)
if callback is None:
return None

if callback.unique_key in self.items_already_seen:
return None

self.items_already_seen.add(callback.unique_key)
if prepend:
self.items.insert(0, callback)
else:
self.items.append(callback)
return callback

def add(self, items: CallbackMetaList, resolver: Callable):
"""Validate configurations"""
for item in items:
self.add_one(item, resolver)
return self

def call(self, *args, **kwargs):
return [
callback(*args, **kwargs)
for callback in self
if callback.condition(*args, **kwargs)
]

def all(self, *args, **kwargs):
return all(condition(*args, **kwargs) for condition in self)


class CallbacksRegistry:
def __init__(self) -> None:
self._registry: Dict[CallbackMetaList, CallbacksExecutor] = defaultdict(
CallbacksExecutor
)

def register(self, callbacks: CallbackMetaList, resolver):
executor_list = self[callbacks]
executor_list.add(callbacks, resolver)
return executor_list

def __getitem__(self, callbacks: CallbackMetaList) -> CallbacksExecutor:
return self._registry[callbacks]

def build_register_function_for_resolver(self, resolver):
def register(
meta_list: CallbackMetaList,
meta: CallbackMeta,
prepend: bool = False,
):
return self[meta_list].add_one(meta, resolver, prepend=prepend)

return register
Loading

0 comments on commit dbfc4e9

Please sign in to comment.