Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]

steps:
- uses: actions/checkout@v5
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ uv run mypy statemachine/ tests/

## Code style

- **Formatter/Linter:** ruff (line length 99, target Python 3.9)
- **Formatter/Linter:** ruff (line length 99, target Python 3.10)
- **Rules:** pycodestyle, pyflakes, isort, pyupgrade, flake8-comprehensions, flake8-bugbear, flake8-pytest-style
- **Imports:** single-line, sorted by isort. **Always prefer top-level imports** — only use
lazy (in-function) imports when strictly necessary to break circular dependencies
Expand Down
43 changes: 43 additions & 0 deletions docs/releases/3.2.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# StateChart 3.2.0

*Not released yet*

```{warning}
**Python 3.9 support dropped.** StateMachine 3.2.0 requires Python 3.10 or
later. If you cannot upgrade Python yet, pin to `python-statemachine<3.2`
(the 3.1.x series remains the last line supporting 3.9).
```

## Backward incompatible changes in 3.2.0

### Python 3.9 support dropped

Python 3.9 reached end-of-life on 2025-10-31 and is no longer supported by
the Python core team. StateMachine 3.2 now requires **Python 3.10+**.

Rationale:

- Python 3.9 represented around 1.4% of PyPI downloads of
`python-statemachine` in the 180 days prior to this release;
Python 3.10+ accounts for the vast majority of attributable traffic.
- Dropping 3.9 lets the codebase adopt `match`/`case` (PEP 634), PEP 604
union syntax (`X | Y`), PEP 585 built-in generics (`list[int]` instead
of `List[int]`), and `zip(strict=True)` (PEP 618) internally.
- The same minimum has already been adopted by the major libraries in
the ecosystem (pandas, FastAPI, SQLAlchemy, NumPy, Django 5).

#### Migration

- Upgrade your interpreter to Python 3.10 or later, **or**
- Pin `python-statemachine<3.2` to stay on the 3.1.x line.

No public API was changed by this drop. Code that runs on 3.10+ today
will continue to run unchanged on 3.2.

## What's new in 3.2.0

TODO

## Bug fixes in 3.2.0

TODO
3 changes: 2 additions & 1 deletion docs/releases/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ Upgrading from 2.x? See [](upgrade_2x_to_3.md) for a step-by-step migration guid

## 3.x releases

Requires Python 3.9+.
Requires Python 3.10+.

```{toctree}
:maxdepth: 2

3.2.0
3.1.1
3.1.0
3.0.0
Expand Down
45 changes: 19 additions & 26 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "python-statemachine"
version = "3.1.1"
version = "3.2.0"
description = "Python Finite State Machines made easy."
authors = [{ name = "Fernando Macedo", email = "fgmacedo@gmail.com" }]
maintainers = [{ name = "Fernando Macedo", email = "fgmacedo@gmail.com" }]
Expand All @@ -18,11 +18,10 @@ classifiers = [
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.9",
"Topic :: Home Automation",
"Topic :: Software Development :: Libraries",
]
requires-python = ">=3.9"
requires-python = ">=3.10"

[project.urls]
homepage = "https://github.com/fgmacedo/python-statemachine"
Expand All @@ -35,30 +34,26 @@ dev = [
"ruff >=0.15.13",
"pre-commit",
"mypy",
"pytest >=9.0.3; python_version >='3.10'",
"pytest >=8.4.2; python_version <'3.10'",
"pytest-cov >=7.1.0; python_version >='3.9'",
"pytest-cov; python_version <'3.9'",
"pytest >=9.0.3",
"pytest-cov >=7.1.0",
"pytest-sugar >=1.1.1",
"pytest-mock >=3.15.1",
"pytest-benchmark >=5.2.3",
"pytest-asyncio >=1.3.0; python_version >='3.10'",
"pytest-asyncio >=0.25.0; python_version <'3.10'",
"pytest-asyncio >=1.3.0",
"pydot",
"django >=6.0.3; python_version >='3.12'",
"django >=5.2.14; python_version >='3.10' and python_version <'3.12'",
"pytest-django >=4.12.0; python_version >='3.10'",
"Sphinx; python_version >'3.8'",
"sphinx-gallery; python_version >'3.8'",
"myst-parser; python_version >'3.8'",
"pillow >=12.2.0; python_version >='3.10'",
"pillow; python_version <'3.10'",
"sphinx-autobuild; python_version >'3.8'",
"furo >=2025.12.19; python_version >'3.8'",
"sphinx-copybutton >=0.5.2; python_version >'3.8'",
"sphinxcontrib-mermaid; python_version >'3.8'",
"pdbr>=0.9.7; python_version >'3.8'",
"babel >=2.18.0; python_version >='3.8'",
"django >=5.2.14; python_version <'3.12'",
"pytest-django >=4.12.0",
"Sphinx",
"sphinx-gallery",
"myst-parser",
"pillow >=12.2.0",
"sphinx-autobuild",
"furo >=2025.12.19",
"sphinx-copybutton >=0.5.2",
"sphinxcontrib-mermaid",
"pdbr>=0.9.7",
"babel >=2.18.0",
"pytest-xdist>=3.8.0",
"pytest-timeout>=2.4.0",
"pyright>=1.1.408",
Expand Down Expand Up @@ -154,7 +149,7 @@ ignore_missing_imports = true
src = ["statemachine"]

line-length = 99
target-version = "py39"
target-version = "py310"

# Exclude a variety of commonly ignored directories.
exclude = [
Expand Down Expand Up @@ -191,8 +186,6 @@ select = [
"PT", # flake8-pytest-style
]
ignore = [
"UP006", # `use-pep585-annotation` Requires Python3.9+
"UP035", # `use-pep585-annotation` Requires Python3.9+
"UP037", # `remove-quotes-from-type-annotation` Not safe without `from __future__ import annotations`
"UP042", # `use-str-enum` Requires Python3.11+
]
Expand Down Expand Up @@ -221,6 +214,6 @@ fixture-parentheses = true
mark-parentheses = true

[tool.pyright]
pythonVersion = "3.9"
pythonVersion = "3.10"
typeCheckingMode = "basic"
include = ["statemachine"]
2 changes: 1 addition & 1 deletion statemachine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

__author__ = """Fernando Macedo"""
__email__ = "fgmacedo@gmail.com"
__version__ = "3.1.1"
__version__ = "3.2.0"

__all__ = [
"StateChart",
Expand Down
16 changes: 5 additions & 11 deletions statemachine/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,17 @@
from bisect import insort
from collections import defaultdict
from collections import deque
from collections.abc import Callable
from enum import IntEnum
from enum import IntFlag
from enum import auto
from functools import partial
from inspect import isawaitable
from typing import TYPE_CHECKING
from typing import Callable
from typing import Dict
from typing import List

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

if TYPE_CHECKING:
from typing import Set


def allways_true(*args, **kwargs):
return True
Expand Down Expand Up @@ -66,7 +60,7 @@ class CallbackSpec:
before any real call is performed.
"""

names_not_found: "Set[str] | None" = None
names_not_found: "set[str] | None" = None
"""List of names that were not found on the model or statemachine"""

def __init__(
Expand Down Expand Up @@ -155,9 +149,9 @@ class CallbackSpecList:
"""List of {ref}`CallbackSpec` instances"""

def __init__(self, factory=CallbackSpec):
self.items: List[CallbackSpec] = []
self.items: list[CallbackSpec] = []
self.conventional_specs = set()
self._groupers: Dict[CallbackGroup, SpecListGrouper] = {}
self._groupers: dict[CallbackGroup, SpecListGrouper] = {}
self.factory = factory

def __repr__(self):
Expand Down Expand Up @@ -380,7 +374,7 @@ async def async_visit(self, visitor_fn, *args, **kwargs):

class CallbacksRegistry:
def __init__(self) -> None:
self._registry: Dict[str, CallbacksExecutor] = defaultdict(CallbacksExecutor)
self._registry: dict[str, CallbacksExecutor] = defaultdict(CallbacksExecutor)
self.has_async_callbacks: bool = False

def __getitem__(self, key: str) -> CallbacksExecutor:
Expand Down
7 changes: 3 additions & 4 deletions statemachine/configuration.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
from collections.abc import Mapping
from collections.abc import MutableSet
from typing import TYPE_CHECKING
from typing import Any
from typing import Dict
from typing import Mapping
from typing import MutableSet

from .exceptions import InvalidStateValue
from .i18n import _
Expand Down Expand Up @@ -37,7 +36,7 @@ def __init__(
instance_states: "Mapping[str, State]",
model: Any,
state_field: str,
states_map: "Dict[Any, State]",
states_map: "dict[Any, State]",
):
self._instance_states = instance_states
self._model = model
Expand Down
47 changes: 23 additions & 24 deletions statemachine/contrib/diagram/extract.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
from typing import TYPE_CHECKING
from typing import List
from typing import Set
from typing import Union

from .model import ActionType
from .model import DiagramAction
Expand All @@ -11,12 +8,14 @@
from .model import StateType

if TYPE_CHECKING:
from typing import TypeAlias

from statemachine.state import State
from statemachine.statemachine import StateChart
from statemachine.transition import Transition

# A StateChart class or instance — both expose the same structural metadata.
MachineRef = Union["StateChart", "type[StateChart]"]
MachineRef: TypeAlias = StateChart | type[StateChart]


def _determine_state_type(state: "State") -> StateType:
Expand Down Expand Up @@ -50,8 +49,8 @@ def getter(grouper):
return getter


def _extract_state_actions(state: "State", getter) -> List[DiagramAction]:
actions: List[DiagramAction] = []
def _extract_state_actions(state: "State", getter) -> list[DiagramAction]:
actions: list[DiagramAction] = []

entry = str(getter(state.enter))
exit_ = str(getter(state.exit))
Expand Down Expand Up @@ -82,7 +81,7 @@ def _extract_state(
is_active = state.value in active_values
is_parallel_area = bool(state.parent and getattr(state.parent, "parallel", False))

children: List[DiagramState] = []
children: list[DiagramState] = []
for substate in state.states:
children.append(_extract_state(substate, machine, getter, active_values))
for history_state in getattr(state, "history", []):
Expand Down Expand Up @@ -116,8 +115,8 @@ def _format_event_names(transition: "Transition") -> str:

all_ids = {str(e) for e in events}

seen_ids: Set[str] = set()
display: List[str] = []
seen_ids: set[str] = set()
display: list[str] = []
for event in events:
eid = str(event)
# Skip dot-form aliases (e.g. "done.invoke.X") when the underscore
Expand All @@ -131,9 +130,9 @@ def _format_event_names(transition: "Transition") -> str:
return " ".join(display)


def _extract_transitions_from_state(state: "State") -> List[DiagramTransition]:
def _extract_transitions_from_state(state: "State") -> list[DiagramTransition]:
"""Extract transitions from a single state (non-recursive)."""
result: List[DiagramTransition] = []
result: list[DiagramTransition] = []
for transition in state.transitions:
targets = transition.targets if transition.targets else []
target_ids = [t.id for t in targets]
Expand All @@ -152,9 +151,9 @@ def _extract_transitions_from_state(state: "State") -> List[DiagramTransition]:
return result


def _extract_all_transitions(states) -> List[DiagramTransition]:
def _extract_all_transitions(states) -> list[DiagramTransition]:
"""Recursively extract transitions from all states."""
result: List[DiagramTransition] = []
result: list[DiagramTransition] = []
for state in states:
result.extend(_extract_transitions_from_state(state))
if state.states:
Expand All @@ -166,9 +165,9 @@ def _extract_all_transitions(states) -> List[DiagramTransition]:
return result


def _collect_compound_ids(states: List[DiagramState]) -> Set[str]:
def _collect_compound_ids(states: list[DiagramState]) -> set[str]:
"""Collect IDs of states that have children (compound/parallel)."""
result: Set[str] = set()
result: set[str] = set()
for state in states:
if state.children:
result.add(state.id)
Expand All @@ -177,12 +176,12 @@ def _collect_compound_ids(states: List[DiagramState]) -> Set[str]:


def _collect_bidirectional_compound_ids(
transitions: List[DiagramTransition],
compound_ids: Set[str],
) -> Set[str]:
transitions: list[DiagramTransition],
compound_ids: set[str],
) -> set[str]:
"""Find compound states that have both outgoing and incoming explicit edges."""
outgoing: Set[str] = set()
incoming: Set[str] = set()
outgoing: set[str] = set()
incoming: set[str] = set()
for t in transitions:
if t.is_internal:
continue
Expand All @@ -198,16 +197,16 @@ def _collect_bidirectional_compound_ids(


def _mark_initial_transitions(
transitions: List[DiagramTransition],
compound_ids: Set[str],
transitions: list[DiagramTransition],
compound_ids: set[str],
) -> None:
"""Mark implicit initial transitions (compound state → child, no event)."""
for t in transitions:
if t.source in compound_ids and not t.event and t.targets and not t.is_internal:
t.is_initial = True


def _resolve_initial_states(states: List[DiagramState]) -> None:
def _resolve_initial_states(states: list[DiagramState]) -> None:
"""Ensure exactly one state per level has is_initial=True.

Skips parallel areas and history states. Falls back to document order
Expand Down Expand Up @@ -268,7 +267,7 @@ class itself thanks to the metaclass. Active-state highlighting is only
if isinstance(machine, StateChart) and hasattr(machine, "configuration_values"):
active_values = set(machine.configuration_values)

states: List[DiagramState] = []
states: list[DiagramState] = []
for state in machine.states:
states.append(_extract_state(state, machine, getter, active_values))

Expand Down
Loading
Loading