tinyfsm 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ Copyright 2026 Maciej Wiatrzyk <maciej.wiatrzyk@gmail.com>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the “Software”), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
7
+ of the Software, and to permit persons to whom the Software is furnished to do
8
+ so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
tinyfsm-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.3
2
+ Name: tinyfsm
3
+ Version: 0.0.1
4
+ Summary: A tiny and minimal finite state machine for Python.
5
+ License: MIT
6
+ Author: Maciej Wiatrzyk
7
+ Author-email: maciej.wiatrzyk@gmail.com
8
+ Requires-Python: >=3.9,<4
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Description-Content-Type: text/markdown
17
+
18
+ # TinyFSM
19
+
20
+ A tiny and minimal finite state machine for Python.
21
+
22
+ ## About
23
+
24
+ **TinyFSM** is a fast and minimal finite state machine engine for Python. It
25
+ runs the finite state defined as list of traversals, where each traversal is
26
+ given by current and next state name and a traversal function that is evaluated
27
+ on currently processed event. Once traversal function returns true, the current
28
+ state is changed to the next state according to matched traversal.
29
+
30
+ This library can be used as a backbone for creating larger scale FSM-based
31
+ tokenizers and parsers.
32
+
33
+ ## Installation
34
+
35
+ ```shell
36
+ $ pip install tinyfsm
37
+ ```
38
+
39
+ ## Quickstart
40
+
41
+ Below you'll find a simple text parser that split text into WORD and SPACE
42
+ tokens, where WORD is a consecutive collection of alpha characters, and SPACE
43
+ is consecutive collection of space characters:
44
+
45
+ ```python
46
+ from tinyfsm.api import Traversal, StateMachineRunner, EventRejectedError
47
+
48
+ # Declaration of the state machine as list of Traversal objects.
49
+ # Each object contains source state, destination state, and a function that
50
+ # checks if current input (or event) should trigger state traversal.
51
+ definition = [
52
+ Traversal[str]("initial", "word", lambda event: event.isalpha()),
53
+ Traversal[str]("initial", "space", lambda event: event == " "),
54
+ Traversal[str]("word", "word", lambda event: event.isalpha()),
55
+ Traversal[str]("word", "space", lambda event: event == " "),
56
+ Traversal[str]("word", "final", lambda event: event == ""),
57
+ Traversal[str]("space", "space", lambda event: event == " "),
58
+ Traversal[str]("space", "word", lambda event: event.isalpha()),
59
+ Traversal[str]("space", "final", lambda event: event == ""),
60
+ ]
61
+
62
+ class Listener:
63
+ """Event listener for the state machine.
64
+
65
+ It listens for events like state change, or input value dispatching end.
66
+ Implementations (like this one) can use these methods to buffer inputs and
67
+ emit tokens.
68
+ """
69
+
70
+ def __init__(self, output: list[tuple[str, str]]):
71
+ self._output = output
72
+ self._buffer = ""
73
+
74
+ def on_state_change(self, event: str, prev_state: str, current_state: str):
75
+ if prev_state != current_state:
76
+ if prev_state == "word":
77
+ self._output.append(("WORD", self._buffer))
78
+ if prev_state == "space":
79
+ self._output.append(("SPACE", self._buffer))
80
+ self._buffer = ""
81
+
82
+ def on_dispatch_done(self, event: str, current_state: str):
83
+ self._buffer += event
84
+
85
+ def tokenize(text: str) -> list[tuple[str, str]]:
86
+ """Tokenization function.
87
+
88
+ This is just an example, but in general a some sort of function gluing all
89
+ parts together is recommended. Here the function parses given text and
90
+ outputs list of tokens parsed from it.
91
+ """
92
+ out = []
93
+ listener = Listener(out)
94
+ runner = StateMachineRunner(definition, listener)
95
+ with runner:
96
+ for char in text:
97
+ runner.dispatch(char)
98
+ runner.dispatch("")
99
+ return out
100
+ ```
101
+
@@ -0,0 +1,83 @@
1
+ # TinyFSM
2
+
3
+ A tiny and minimal finite state machine for Python.
4
+
5
+ ## About
6
+
7
+ **TinyFSM** is a fast and minimal finite state machine engine for Python. It
8
+ runs the finite state defined as list of traversals, where each traversal is
9
+ given by current and next state name and a traversal function that is evaluated
10
+ on currently processed event. Once traversal function returns true, the current
11
+ state is changed to the next state according to matched traversal.
12
+
13
+ This library can be used as a backbone for creating larger scale FSM-based
14
+ tokenizers and parsers.
15
+
16
+ ## Installation
17
+
18
+ ```shell
19
+ $ pip install tinyfsm
20
+ ```
21
+
22
+ ## Quickstart
23
+
24
+ Below you'll find a simple text parser that split text into WORD and SPACE
25
+ tokens, where WORD is a consecutive collection of alpha characters, and SPACE
26
+ is consecutive collection of space characters:
27
+
28
+ ```python
29
+ from tinyfsm.api import Traversal, StateMachineRunner, EventRejectedError
30
+
31
+ # Declaration of the state machine as list of Traversal objects.
32
+ # Each object contains source state, destination state, and a function that
33
+ # checks if current input (or event) should trigger state traversal.
34
+ definition = [
35
+ Traversal[str]("initial", "word", lambda event: event.isalpha()),
36
+ Traversal[str]("initial", "space", lambda event: event == " "),
37
+ Traversal[str]("word", "word", lambda event: event.isalpha()),
38
+ Traversal[str]("word", "space", lambda event: event == " "),
39
+ Traversal[str]("word", "final", lambda event: event == ""),
40
+ Traversal[str]("space", "space", lambda event: event == " "),
41
+ Traversal[str]("space", "word", lambda event: event.isalpha()),
42
+ Traversal[str]("space", "final", lambda event: event == ""),
43
+ ]
44
+
45
+ class Listener:
46
+ """Event listener for the state machine.
47
+
48
+ It listens for events like state change, or input value dispatching end.
49
+ Implementations (like this one) can use these methods to buffer inputs and
50
+ emit tokens.
51
+ """
52
+
53
+ def __init__(self, output: list[tuple[str, str]]):
54
+ self._output = output
55
+ self._buffer = ""
56
+
57
+ def on_state_change(self, event: str, prev_state: str, current_state: str):
58
+ if prev_state != current_state:
59
+ if prev_state == "word":
60
+ self._output.append(("WORD", self._buffer))
61
+ if prev_state == "space":
62
+ self._output.append(("SPACE", self._buffer))
63
+ self._buffer = ""
64
+
65
+ def on_dispatch_done(self, event: str, current_state: str):
66
+ self._buffer += event
67
+
68
+ def tokenize(text: str) -> list[tuple[str, str]]:
69
+ """Tokenization function.
70
+
71
+ This is just an example, but in general a some sort of function gluing all
72
+ parts together is recommended. Here the function parses given text and
73
+ outputs list of tokens parsed from it.
74
+ """
75
+ out = []
76
+ listener = Listener(out)
77
+ runner = StateMachineRunner(definition, listener)
78
+ with runner:
79
+ for char in text:
80
+ runner.dispatch(char)
81
+ runner.dispatch("")
82
+ return out
83
+ ```
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "tinyfsm"
3
+ version = "0.0.1"
4
+ description = "A tiny and minimal finite state machine for Python."
5
+ authors = [
6
+ {name = "Maciej Wiatrzyk",email = "maciej.wiatrzyk@gmail.com"}
7
+ ]
8
+ license = {text = "MIT"}
9
+ readme = "README.md"
10
+ requires-python = ">=3.9,<4"
11
+ dependencies = [
12
+ ]
13
+
14
+
15
+ [build-system]
16
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
17
+ build-backend = "poetry.core.masonry.api"
18
+
19
+ [tool.poetry.group.dev.dependencies]
20
+ pytest = "8.4.2"
21
+ pytest-cov = "^7.1.0"
22
+ mockify = "^0.14.0"
23
+ invoke = "^3.0.3"
24
+ ruff = "^0.15.14"
25
+ bumpify = "^0.5.1"
26
+
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
@@ -0,0 +1,10 @@
1
+ from typing import TypeVar
2
+
3
+ T = TypeVar("T")
4
+
5
+
6
+ class ExportList(list):
7
+ def __call__(self, func_or_cls: T) -> T:
8
+ name = getattr(func_or_cls, "__name__")
9
+ self.append(name)
10
+ return func_or_cls
@@ -0,0 +1,13 @@
1
+ from .runner import __all__ as _runner_all
2
+ from .exc import __all__ as _exc_all
3
+ from .interface import __all__ as _interface_all
4
+
5
+ from .runner import *
6
+ from .exc import *
7
+ from .interface import *
8
+
9
+ __all__ = (
10
+ *_runner_all,
11
+ *_exc_all,
12
+ *_interface_all,
13
+ ) # type: ignore
@@ -0,0 +1,60 @@
1
+ from typing import Any
2
+
3
+ from . import _export_list
4
+
5
+ __all__ = export = _export_list.ExportList() # type: ignore
6
+
7
+
8
+ @export
9
+ class TinyFSMError(Exception):
10
+ """Common base class for all exception this library may raise."""
11
+
12
+
13
+ @export
14
+ class EventRejectedError(TinyFSMError):
15
+ """Raised when event was rejected by the state machine.
16
+
17
+ Event is rejected only if there was no matching traversal found.
18
+ """
19
+
20
+ #: The rejected event object.
21
+ event: Any
22
+
23
+ #: The current state name.
24
+ current_state: str
25
+
26
+ def __init__(self, event: Any, current_state: str):
27
+ super().__init__()
28
+ self.event = event
29
+ self.current_state = current_state
30
+
31
+ def __str__(self) -> str:
32
+ return f"event {self.event!r} was rejected; no traversal found for current state {self.current_state!r}"
33
+
34
+
35
+ @export
36
+ class FinalStateNotReached(TinyFSMError):
37
+ """Raised when closing state machine if final state was not reached.
38
+
39
+ This means that either the input sequence of event is incomplete, or that
40
+ the state machine definition is malformed and final state is
41
+ unreachable.
42
+ """
43
+
44
+ #: The last dispatched event.
45
+ last_event: Any
46
+
47
+ #: The name of the final state.
48
+ final_state: str
49
+
50
+ #: The name of the current state.
51
+ current_state: str
52
+
53
+ def __init__(self, last_event: Any, final_state: str, current_state: str):
54
+ super().__init__()
55
+ self.last_event = last_event
56
+ self.final_state = final_state
57
+ self.current_state = current_state
58
+
59
+ def __str__(self) -> str:
60
+ return f"final state {self.final_state!r} was not reached; current state is {self.current_state!r}, last event was {self.last_event!r}"
@@ -0,0 +1,79 @@
1
+ import dataclasses
2
+ from typing import Callable, Generic, Protocol, TypeVar
3
+
4
+ from . import _export_list
5
+
6
+ __all__ = export = _export_list.ExportList() # type: ignore
7
+
8
+ T = TypeVar("T")
9
+
10
+ Tc = TypeVar("Tc", contravariant=True)
11
+
12
+
13
+ @export
14
+ @dataclasses.dataclass
15
+ class Traversal(Generic[T]):
16
+ """Object for creating state traversal definitions.
17
+
18
+ This is the basic building block for creating state machines. It is used to
19
+ declare source and destination states, and a traversal function that is
20
+ used to check if current event should cause traversal to the destination
21
+ state.
22
+ """
23
+
24
+ #: Source event name.
25
+ src: str
26
+
27
+ #: Destination event name.
28
+ dest: str
29
+
30
+ #: Condition function.
31
+ #:
32
+ #: It will be called with the current event and if the return value is #:
33
+ #: ``True`` then state traversal from :attr:`src` to :attr:`dest`).is
34
+ #: performed. If the current state is not the source state, then the
35
+ #: function will not be called.
36
+ cond: Callable[[T], bool]
37
+
38
+
39
+ @export
40
+ class StateMachineListener(Protocol, Generic[Tc]):
41
+ """State machine listener protocol.
42
+
43
+ Defines interface to be used by custom state machine listeners receiving
44
+ notifications about event objects and state traversals. Implementations may
45
+ use this to add events to internal buffers, queues etc.
46
+ """
47
+
48
+ def on_state_change(self, event: Tc, prev_state: str, current_state: str):
49
+ """Triggered when *event* caused state traversal from *prev_state* to
50
+ *current_state*.
51
+
52
+ This will only be called if one of state traversal condition functions
53
+ returned ``True``.
54
+
55
+ :param event:
56
+ The event that caused traversal.
57
+
58
+ :param prev_state:
59
+ The name of a previous state.
60
+
61
+ :param current_state:
62
+ The name of a current state.
63
+ """
64
+ ...
65
+
66
+ def on_dispatch_done(self, event: Tc, current_state: str):
67
+ """Triggered when event dispatching ends.
68
+
69
+ This method will be called for every successfully accepted event even
70
+ if there was no state change.
71
+
72
+ :param event:
73
+ The event object.
74
+
75
+ :param current_state:
76
+ The name of the state the state machine was left in once *event*
77
+ was processed.
78
+ """
79
+ ...
@@ -0,0 +1,131 @@
1
+ from typing import Generic, Optional, Sequence, TypeVar
2
+
3
+ from . import _export_list
4
+ from .exc import EventRejectedError, FinalStateNotReached
5
+ from .interface import StateMachineListener, Traversal
6
+
7
+ __all__ = export = _export_list.ExportList() # type: ignore
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ @export
13
+ class StateMachineRunner(Generic[T]):
14
+ """State machine runner.
15
+
16
+ Each instance of this class can only parse single chain of events (as it
17
+ keeps the state between :meth:`dispatch` calls) and therefore a brand new
18
+ instance must be created to parse another chain of events.
19
+
20
+ You can feed state machine with events by using :meth:`dispatch` method.
21
+ Each dispatched event may cause state traversal, or event rejection (if the
22
+ input chain of events could not be parsed using defined state machine). It
23
+ is recommended, although not required, to call :meth:`close` once all the
24
+ events are dispatched.
25
+
26
+ The state machine can also be used as a context manager to automatically
27
+ call :meth:`close` on context exit::
28
+
29
+ sm = StateMachine(...)
30
+ with sm:
31
+ for event in sequence_of_events:
32
+ sm.dispatch(event)
33
+
34
+ :param definition:
35
+ State machine definition as a sequence of state traversals defined
36
+ using :class:`tinyfsm.interface.Traversal` objects.
37
+
38
+ :param listener:
39
+ Instance of :class:`StateMachineListener` protocol.
40
+
41
+ This object will receive notifications from the state machine once it
42
+ is processing events.
43
+
44
+ :param initial_state:
45
+ The name of the initial state.
46
+
47
+ It is required to have at least one initial state name used in the
48
+ state machine definition as a :attr:`tinyfsm.interface.Traversal.src`
49
+ attribute.
50
+
51
+ :param final_state:
52
+ The name of the final state.
53
+
54
+ It is required to have at least one final state name used in the state
55
+ machine definition as a :attr:`tinyfsm.interface.Traversal.dest`
56
+ attribute.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ definition: Sequence[Traversal[T]],
62
+ listener: StateMachineListener[T],
63
+ initial_state: str = "initial",
64
+ final_state: str = "final",
65
+ ):
66
+ self.__traversal_map: dict[str, list[Traversal]] = {}
67
+ has_initial = has_final = False
68
+ for traversal in definition:
69
+ if traversal.src == initial_state:
70
+ has_initial = True
71
+ if traversal.dest == final_state:
72
+ has_final = True
73
+ self.__traversal_map.setdefault(traversal.src, []).append(traversal)
74
+ if not has_initial:
75
+ raise TypeError(f"no initial state found: {initial_state}")
76
+ if not has_final:
77
+ raise TypeError(f"no final state found: {final_state}")
78
+ self.__listener = listener
79
+ self.__initial_state = initial_state
80
+ self.__final_state = final_state
81
+ self.__current_state = self.__initial_state
82
+ self.__last_event: Optional[T] = None
83
+
84
+ def __enter__(self) -> "StateMachineRunner":
85
+ return self
86
+
87
+ def __exit__(self, exc_type, exc, tb):
88
+ if exc is not None:
89
+ return None
90
+ return self.close()
91
+
92
+ def dispatch(self, event: T):
93
+ """Dispatch event to the state machine.
94
+
95
+ This should be called for each event, and state machine can either
96
+ accept the event, and maybe traverse to a different state in response
97
+ for that event, or reject it by raising :exc:`EventRejectedError`
98
+ exception.
99
+
100
+ :param event:
101
+ The event object to dispatch.
102
+ """
103
+ self.__last_event = event
104
+ current_state_traversals = self.__traversal_map.get(self.__current_state)
105
+ if current_state_traversals is None:
106
+ raise EventRejectedError(event, self.__current_state)
107
+ print(current_state_traversals)
108
+ for traversal in current_state_traversals:
109
+ if traversal.cond(event):
110
+ next_state = traversal.dest
111
+ self.__listener.on_state_change(event, self.__current_state, next_state)
112
+ self.__current_state = next_state
113
+ break
114
+ else:
115
+ raise EventRejectedError(event, self.__current_state)
116
+ self.__listener.on_dispatch_done(event, self.__current_state)
117
+
118
+ def close(self):
119
+ """Close this state machine.
120
+
121
+ This method should be called after event dispatching ends. Its role is
122
+ to check if the final state was reached; if the final state was
123
+ reached, the method silently returns. Otherwise it raises
124
+ :exc:`tinyfsm.exc.FinalStateNotReached` error.
125
+ """
126
+ if not self.is_final():
127
+ raise FinalStateNotReached(self.__last_event, self.__final_state, self.__current_state)
128
+
129
+ def is_final(self) -> bool:
130
+ """Check if the final state is reached."""
131
+ return self.__current_state == self.__final_state