snanosm 1.0.0__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.
- snanosm-1.0.0/.coveragerc +3 -0
- snanosm-1.0.0/.gitignore +4 -0
- snanosm-1.0.0/LICENSE +21 -0
- snanosm-1.0.0/PKG-INFO +226 -0
- snanosm-1.0.0/README.md +210 -0
- snanosm-1.0.0/examples/detector.py +52 -0
- snanosm-1.0.0/examples/inverter.py +27 -0
- snanosm-1.0.0/publish.sh +4 -0
- snanosm-1.0.0/pyproject.toml +23 -0
- snanosm-1.0.0/src/snanosm/__init__.py +0 -0
- snanosm-1.0.0/src/snanosm/mealy.py +154 -0
- snanosm-1.0.0/tests/__init__.py +0 -0
- snanosm-1.0.0/tests/test_mealy.py +254 -0
snanosm-1.0.0/.gitignore
ADDED
snanosm-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Studiosi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
snanosm-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: snanosm
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A pure python small Mealy state machine library
|
|
5
|
+
Author-email: ITStudiosi <david@studiosi.es>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# SNanoSM
|
|
18
|
+
|
|
19
|
+
A pure Python, minimalistic, and fully typed library for the implementation of **Mealy Finite State Machines**.
|
|
20
|
+
|
|
21
|
+
Part of Nobody Industry's **MFFP (Made From First Principles)** set of libraries.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Table of Contents
|
|
26
|
+
1. [Overview & Core Concept](#overview--core-concept)
|
|
27
|
+
2. [Installation](#installation)
|
|
28
|
+
3. [Quick Start (Binary Inverter)](#quick-start-binary-inverter)
|
|
29
|
+
4. [Advanced Usage (Sequence Detector & Fallback Matching)](#advanced-usage-sequence-detector--fallback-matching)
|
|
30
|
+
5. [API Reference](#api-reference)
|
|
31
|
+
6. [Testing & Verification](#testing--verification)
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Overview & Core Concept
|
|
36
|
+
|
|
37
|
+
A **Mealy State Machine** is a finite-state machine whose output values are determined both by its current state and its current inputs.
|
|
38
|
+
|
|
39
|
+
In [mealy.py](src/snanosm/mealy.py), this is modeled by:
|
|
40
|
+
- **States**: Unique nodes in the machine.
|
|
41
|
+
- **Transitions**: Directed connections between states triggered by specific inputs.
|
|
42
|
+
- **Action Functions (Outputs)**: Arbitrary callbacks associated with transitions that receive a mutable user-defined context object.
|
|
43
|
+
|
|
44
|
+
> [!NOTE]
|
|
45
|
+
> By passing a mutable context down to transition actions, you can build rich state-dependent behavior while keeping the machine's state logic simple and decoupled.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
Since the library uses [pyproject.toml](pyproject.toml) with the Hatchling build backend, you can install it locally in editable mode or build it using standard tools:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Install in editable mode
|
|
55
|
+
pip install -e .
|
|
56
|
+
|
|
57
|
+
# Or build the package
|
|
58
|
+
python -m build
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Quick Start (Binary Inverter)
|
|
64
|
+
|
|
65
|
+
Here is a simple example demonstrating how to invert a binary string (`"0"` becomes `"1"`, `"1"` becomes `"0"`) using [inverter.py](examples/inverter.py).
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from typing import Tuple, TypedDict
|
|
69
|
+
from snanosm.mealy import Machine
|
|
70
|
+
|
|
71
|
+
# Define a context to hold our state machine's output and metadata
|
|
72
|
+
class Context(TypedDict):
|
|
73
|
+
result: str
|
|
74
|
+
n_chars: int
|
|
75
|
+
|
|
76
|
+
def add_to_result(context: Context, c: str) -> None:
|
|
77
|
+
context["result"] += c
|
|
78
|
+
context["n_chars"] += 1
|
|
79
|
+
|
|
80
|
+
def inverter(input_string: str) -> Tuple[str, int]:
|
|
81
|
+
# Initialize the mutable context
|
|
82
|
+
context: Context = {
|
|
83
|
+
"result": "",
|
|
84
|
+
"n_chars": 0
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Create the machine with the context
|
|
88
|
+
m = Machine(context)
|
|
89
|
+
|
|
90
|
+
# Add a start state "S"
|
|
91
|
+
m.add_state("S", is_start_state=True)
|
|
92
|
+
|
|
93
|
+
# Define transitions: when in state "S" and input is "0", execute action and stay in "S"
|
|
94
|
+
m.add_transition("0", "S", "S", lambda ctx: add_to_result(ctx, "1"))
|
|
95
|
+
m.add_transition("1", "S", "S", lambda ctx: add_to_result(ctx, "0"))
|
|
96
|
+
|
|
97
|
+
# Process inputs sequentially
|
|
98
|
+
for c in input_string:
|
|
99
|
+
m.process_input(c)
|
|
100
|
+
|
|
101
|
+
return context["result"], context["n_chars"]
|
|
102
|
+
|
|
103
|
+
if __name__ == '__main__':
|
|
104
|
+
res, count = inverter("000011110010")
|
|
105
|
+
print(f"Result: {res}, Characters processed: {count}")
|
|
106
|
+
# Output: Result: 111100001101, Characters processed: 12
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Advanced Usage (Sequence Detector & Fallback Matching)
|
|
112
|
+
|
|
113
|
+
The sequence detector in [detector.py](examples/detector.py) searches for the substring `"AB"` within a stream of characters. It illustrates the use of `TransitionInputEnum` to define catch-all transitions when no specific input matches.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from typing import TypedDict
|
|
117
|
+
from snanosm.mealy import Machine, TransitionInputEnum
|
|
118
|
+
|
|
119
|
+
class Context(TypedDict):
|
|
120
|
+
count: int
|
|
121
|
+
position: int
|
|
122
|
+
matches: int
|
|
123
|
+
|
|
124
|
+
def dinc(context: Context):
|
|
125
|
+
context["count"] += 1
|
|
126
|
+
|
|
127
|
+
def dset(context: Context):
|
|
128
|
+
context["position"] = context["count"]
|
|
129
|
+
context["count"] += 1
|
|
130
|
+
|
|
131
|
+
def dprn(context: Context):
|
|
132
|
+
context["count"] += 1
|
|
133
|
+
print(f"SUBSTRING FOUND AT POSITION: {context['position']}")
|
|
134
|
+
context["matches"] += 1
|
|
135
|
+
|
|
136
|
+
def detector(input_string: str) -> int:
|
|
137
|
+
initial_context: Context = {
|
|
138
|
+
"count": 0,
|
|
139
|
+
"position": 0,
|
|
140
|
+
"matches": 0,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
m = Machine(initial_context)
|
|
144
|
+
m.add_state("Q0", is_start_state=True)
|
|
145
|
+
m.add_state("Q1")
|
|
146
|
+
|
|
147
|
+
# Q0 -> Q1 on 'A', saving the start position
|
|
148
|
+
m.add_transition("A", "Q0", "Q1", lambda context: dset(context))
|
|
149
|
+
# Catch-all transition: Q0 -> Q0 for any input other than 'A'
|
|
150
|
+
m.add_transition(TransitionInputEnum.MATCH_REST, "Q0", "Q0", lambda context: dinc(context))
|
|
151
|
+
|
|
152
|
+
# Q1 -> Q0 on 'B', printing match information
|
|
153
|
+
m.add_transition("B", "Q1", "Q0", lambda context: dprn(context))
|
|
154
|
+
# Catch-all transition: Q1 -> Q0 for any input other than 'B'
|
|
155
|
+
m.add_transition(TransitionInputEnum.MATCH_REST, "Q1", "Q0", lambda context: dinc(context))
|
|
156
|
+
|
|
157
|
+
for c in input_string:
|
|
158
|
+
m.process_input(c)
|
|
159
|
+
|
|
160
|
+
return initial_context["matches"]
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## API Reference
|
|
166
|
+
|
|
167
|
+
### Core Abstractions
|
|
168
|
+
|
|
169
|
+
| Class/Type | Description |
|
|
170
|
+
| :--- | :--- |
|
|
171
|
+
| `InputProtocol` | A typing protocol requiring `__eq__` and `__hash__`. Any hashable, equatable Python object can serve as machine input. |
|
|
172
|
+
| `TransitionInputEnum` | Enum containing special transition inputs (e.g., `MATCH_REST`). |
|
|
173
|
+
| `State` | Represents a state node in the state machine. |
|
|
174
|
+
| `Transition` | Represents an edge between states triggered by a transition input. |
|
|
175
|
+
| `Machine` | The core finite state machine runner. |
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
### API Details
|
|
180
|
+
|
|
181
|
+
#### `InputProtocol`
|
|
182
|
+
```python
|
|
183
|
+
class InputProtocol(Protocol):
|
|
184
|
+
def __eq__(self, __o: Self) -> bool: ...
|
|
185
|
+
def __hash__(self) -> int: ...
|
|
186
|
+
```
|
|
187
|
+
Any custom object used as an input to `Machine.process_input` must implement this protocol (or be natively hashable and equatable, e.g. strings, integers, frozen dataclasses).
|
|
188
|
+
|
|
189
|
+
#### `TransitionInputEnum`
|
|
190
|
+
- `MATCH_REST`: Activates if no matching transition input is found for the current state. Useful for defining default fallback transitions.
|
|
191
|
+
|
|
192
|
+
#### `State`
|
|
193
|
+
- `get_name() -> str`: Returns the state's name.
|
|
194
|
+
- `__str__() -> str`: Returns `[State <state_name>]`.
|
|
195
|
+
|
|
196
|
+
#### `Transition`
|
|
197
|
+
- `execute_output(context)`: Executes the output callback if it was supplied.
|
|
198
|
+
- `get_destination_name_hash() -> int`: Returns the hash of the destination state name.
|
|
199
|
+
- `__str__() -> str`: Returns `[Transition (<origin>, <destination>, <input>)]`.
|
|
200
|
+
|
|
201
|
+
#### `Machine`
|
|
202
|
+
- `__init__(initial_context: object = None)`: Initializes the machine. Sets up the configuration using an optional initial context. If not provided, an empty dict `{}` is instantiated.
|
|
203
|
+
- `add_state(state_name: str, is_start_state: bool = False, is_end_state: bool = False) -> None`: Registers a new state node in the machine.
|
|
204
|
+
> [!WARNING]
|
|
205
|
+
> State names must not start with `#` (reserved for internal configurations). A machine cannot have multiple start states.
|
|
206
|
+
- `add_transition(transition_input: TransitionInput, origin_name: str, destination_name: str, output_function: Optional[Callable[[Optional[object]], None]]) -> None`: Registers a transition edge between two existing states.
|
|
207
|
+
- `transition_input`: An input conforming to `InputProtocol` or `TransitionInputEnum`.
|
|
208
|
+
- `output_function`: A callable accepting context, run upon transitioning.
|
|
209
|
+
- `process_input(i: Input) -> None`: Processes a single input token. It evaluates transitions registered under the current state.
|
|
210
|
+
- If a transition matching `i` is registered, it will be executed.
|
|
211
|
+
- If no matching transition is found but a `MATCH_REST` transition is registered, that fallback is executed.
|
|
212
|
+
- If no valid transition is found, it raises a `ValueError`.
|
|
213
|
+
- `reset() -> None`: Resets the state machine's active state back to the start state, and re-assigns the context back to `initial_context`.
|
|
214
|
+
- `get_current_state() -> Optional[State]`: Returns the current `State` object, or `None` if the machine has not started or processed any inputs.
|
|
215
|
+
- `is_in_final_state() -> bool`: Returns `True` if the machine's current state is registered as an end state.
|
|
216
|
+
- `__str__() -> str`: Returns a structured string layout of the machine structure, lists of states, and transitions.
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
## Testing & Verification
|
|
221
|
+
|
|
222
|
+
Unit tests are located in [test_mealy.py](tests/test_mealy.py). To run the test suite, navigate to the project directory and execute:
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
PYTHONPATH=src python -m unittest discover -s tests
|
|
226
|
+
```
|
snanosm-1.0.0/README.md
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# SNanoSM
|
|
2
|
+
|
|
3
|
+
A pure Python, minimalistic, and fully typed library for the implementation of **Mealy Finite State Machines**.
|
|
4
|
+
|
|
5
|
+
Part of Nobody Industry's **MFFP (Made From First Principles)** set of libraries.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Table of Contents
|
|
10
|
+
1. [Overview & Core Concept](#overview--core-concept)
|
|
11
|
+
2. [Installation](#installation)
|
|
12
|
+
3. [Quick Start (Binary Inverter)](#quick-start-binary-inverter)
|
|
13
|
+
4. [Advanced Usage (Sequence Detector & Fallback Matching)](#advanced-usage-sequence-detector--fallback-matching)
|
|
14
|
+
5. [API Reference](#api-reference)
|
|
15
|
+
6. [Testing & Verification](#testing--verification)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Overview & Core Concept
|
|
20
|
+
|
|
21
|
+
A **Mealy State Machine** is a finite-state machine whose output values are determined both by its current state and its current inputs.
|
|
22
|
+
|
|
23
|
+
In [mealy.py](src/snanosm/mealy.py), this is modeled by:
|
|
24
|
+
- **States**: Unique nodes in the machine.
|
|
25
|
+
- **Transitions**: Directed connections between states triggered by specific inputs.
|
|
26
|
+
- **Action Functions (Outputs)**: Arbitrary callbacks associated with transitions that receive a mutable user-defined context object.
|
|
27
|
+
|
|
28
|
+
> [!NOTE]
|
|
29
|
+
> By passing a mutable context down to transition actions, you can build rich state-dependent behavior while keeping the machine's state logic simple and decoupled.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
Since the library uses [pyproject.toml](pyproject.toml) with the Hatchling build backend, you can install it locally in editable mode or build it using standard tools:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# Install in editable mode
|
|
39
|
+
pip install -e .
|
|
40
|
+
|
|
41
|
+
# Or build the package
|
|
42
|
+
python -m build
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Quick Start (Binary Inverter)
|
|
48
|
+
|
|
49
|
+
Here is a simple example demonstrating how to invert a binary string (`"0"` becomes `"1"`, `"1"` becomes `"0"`) using [inverter.py](examples/inverter.py).
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from typing import Tuple, TypedDict
|
|
53
|
+
from snanosm.mealy import Machine
|
|
54
|
+
|
|
55
|
+
# Define a context to hold our state machine's output and metadata
|
|
56
|
+
class Context(TypedDict):
|
|
57
|
+
result: str
|
|
58
|
+
n_chars: int
|
|
59
|
+
|
|
60
|
+
def add_to_result(context: Context, c: str) -> None:
|
|
61
|
+
context["result"] += c
|
|
62
|
+
context["n_chars"] += 1
|
|
63
|
+
|
|
64
|
+
def inverter(input_string: str) -> Tuple[str, int]:
|
|
65
|
+
# Initialize the mutable context
|
|
66
|
+
context: Context = {
|
|
67
|
+
"result": "",
|
|
68
|
+
"n_chars": 0
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Create the machine with the context
|
|
72
|
+
m = Machine(context)
|
|
73
|
+
|
|
74
|
+
# Add a start state "S"
|
|
75
|
+
m.add_state("S", is_start_state=True)
|
|
76
|
+
|
|
77
|
+
# Define transitions: when in state "S" and input is "0", execute action and stay in "S"
|
|
78
|
+
m.add_transition("0", "S", "S", lambda ctx: add_to_result(ctx, "1"))
|
|
79
|
+
m.add_transition("1", "S", "S", lambda ctx: add_to_result(ctx, "0"))
|
|
80
|
+
|
|
81
|
+
# Process inputs sequentially
|
|
82
|
+
for c in input_string:
|
|
83
|
+
m.process_input(c)
|
|
84
|
+
|
|
85
|
+
return context["result"], context["n_chars"]
|
|
86
|
+
|
|
87
|
+
if __name__ == '__main__':
|
|
88
|
+
res, count = inverter("000011110010")
|
|
89
|
+
print(f"Result: {res}, Characters processed: {count}")
|
|
90
|
+
# Output: Result: 111100001101, Characters processed: 12
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Advanced Usage (Sequence Detector & Fallback Matching)
|
|
96
|
+
|
|
97
|
+
The sequence detector in [detector.py](examples/detector.py) searches for the substring `"AB"` within a stream of characters. It illustrates the use of `TransitionInputEnum` to define catch-all transitions when no specific input matches.
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from typing import TypedDict
|
|
101
|
+
from snanosm.mealy import Machine, TransitionInputEnum
|
|
102
|
+
|
|
103
|
+
class Context(TypedDict):
|
|
104
|
+
count: int
|
|
105
|
+
position: int
|
|
106
|
+
matches: int
|
|
107
|
+
|
|
108
|
+
def dinc(context: Context):
|
|
109
|
+
context["count"] += 1
|
|
110
|
+
|
|
111
|
+
def dset(context: Context):
|
|
112
|
+
context["position"] = context["count"]
|
|
113
|
+
context["count"] += 1
|
|
114
|
+
|
|
115
|
+
def dprn(context: Context):
|
|
116
|
+
context["count"] += 1
|
|
117
|
+
print(f"SUBSTRING FOUND AT POSITION: {context['position']}")
|
|
118
|
+
context["matches"] += 1
|
|
119
|
+
|
|
120
|
+
def detector(input_string: str) -> int:
|
|
121
|
+
initial_context: Context = {
|
|
122
|
+
"count": 0,
|
|
123
|
+
"position": 0,
|
|
124
|
+
"matches": 0,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
m = Machine(initial_context)
|
|
128
|
+
m.add_state("Q0", is_start_state=True)
|
|
129
|
+
m.add_state("Q1")
|
|
130
|
+
|
|
131
|
+
# Q0 -> Q1 on 'A', saving the start position
|
|
132
|
+
m.add_transition("A", "Q0", "Q1", lambda context: dset(context))
|
|
133
|
+
# Catch-all transition: Q0 -> Q0 for any input other than 'A'
|
|
134
|
+
m.add_transition(TransitionInputEnum.MATCH_REST, "Q0", "Q0", lambda context: dinc(context))
|
|
135
|
+
|
|
136
|
+
# Q1 -> Q0 on 'B', printing match information
|
|
137
|
+
m.add_transition("B", "Q1", "Q0", lambda context: dprn(context))
|
|
138
|
+
# Catch-all transition: Q1 -> Q0 for any input other than 'B'
|
|
139
|
+
m.add_transition(TransitionInputEnum.MATCH_REST, "Q1", "Q0", lambda context: dinc(context))
|
|
140
|
+
|
|
141
|
+
for c in input_string:
|
|
142
|
+
m.process_input(c)
|
|
143
|
+
|
|
144
|
+
return initial_context["matches"]
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## API Reference
|
|
150
|
+
|
|
151
|
+
### Core Abstractions
|
|
152
|
+
|
|
153
|
+
| Class/Type | Description |
|
|
154
|
+
| :--- | :--- |
|
|
155
|
+
| `InputProtocol` | A typing protocol requiring `__eq__` and `__hash__`. Any hashable, equatable Python object can serve as machine input. |
|
|
156
|
+
| `TransitionInputEnum` | Enum containing special transition inputs (e.g., `MATCH_REST`). |
|
|
157
|
+
| `State` | Represents a state node in the state machine. |
|
|
158
|
+
| `Transition` | Represents an edge between states triggered by a transition input. |
|
|
159
|
+
| `Machine` | The core finite state machine runner. |
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
### API Details
|
|
164
|
+
|
|
165
|
+
#### `InputProtocol`
|
|
166
|
+
```python
|
|
167
|
+
class InputProtocol(Protocol):
|
|
168
|
+
def __eq__(self, __o: Self) -> bool: ...
|
|
169
|
+
def __hash__(self) -> int: ...
|
|
170
|
+
```
|
|
171
|
+
Any custom object used as an input to `Machine.process_input` must implement this protocol (or be natively hashable and equatable, e.g. strings, integers, frozen dataclasses).
|
|
172
|
+
|
|
173
|
+
#### `TransitionInputEnum`
|
|
174
|
+
- `MATCH_REST`: Activates if no matching transition input is found for the current state. Useful for defining default fallback transitions.
|
|
175
|
+
|
|
176
|
+
#### `State`
|
|
177
|
+
- `get_name() -> str`: Returns the state's name.
|
|
178
|
+
- `__str__() -> str`: Returns `[State <state_name>]`.
|
|
179
|
+
|
|
180
|
+
#### `Transition`
|
|
181
|
+
- `execute_output(context)`: Executes the output callback if it was supplied.
|
|
182
|
+
- `get_destination_name_hash() -> int`: Returns the hash of the destination state name.
|
|
183
|
+
- `__str__() -> str`: Returns `[Transition (<origin>, <destination>, <input>)]`.
|
|
184
|
+
|
|
185
|
+
#### `Machine`
|
|
186
|
+
- `__init__(initial_context: object = None)`: Initializes the machine. Sets up the configuration using an optional initial context. If not provided, an empty dict `{}` is instantiated.
|
|
187
|
+
- `add_state(state_name: str, is_start_state: bool = False, is_end_state: bool = False) -> None`: Registers a new state node in the machine.
|
|
188
|
+
> [!WARNING]
|
|
189
|
+
> State names must not start with `#` (reserved for internal configurations). A machine cannot have multiple start states.
|
|
190
|
+
- `add_transition(transition_input: TransitionInput, origin_name: str, destination_name: str, output_function: Optional[Callable[[Optional[object]], None]]) -> None`: Registers a transition edge between two existing states.
|
|
191
|
+
- `transition_input`: An input conforming to `InputProtocol` or `TransitionInputEnum`.
|
|
192
|
+
- `output_function`: A callable accepting context, run upon transitioning.
|
|
193
|
+
- `process_input(i: Input) -> None`: Processes a single input token. It evaluates transitions registered under the current state.
|
|
194
|
+
- If a transition matching `i` is registered, it will be executed.
|
|
195
|
+
- If no matching transition is found but a `MATCH_REST` transition is registered, that fallback is executed.
|
|
196
|
+
- If no valid transition is found, it raises a `ValueError`.
|
|
197
|
+
- `reset() -> None`: Resets the state machine's active state back to the start state, and re-assigns the context back to `initial_context`.
|
|
198
|
+
- `get_current_state() -> Optional[State]`: Returns the current `State` object, or `None` if the machine has not started or processed any inputs.
|
|
199
|
+
- `is_in_final_state() -> bool`: Returns `True` if the machine's current state is registered as an end state.
|
|
200
|
+
- `__str__() -> str`: Returns a structured string layout of the machine structure, lists of states, and transitions.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Testing & Verification
|
|
205
|
+
|
|
206
|
+
Unit tests are located in [test_mealy.py](tests/test_mealy.py). To run the test suite, navigate to the project directory and execute:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
PYTHONPATH=src python -m unittest discover -s tests
|
|
210
|
+
```
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from typing import TypedDict
|
|
2
|
+
|
|
3
|
+
from snanosm.mealy import Machine, TransitionInputEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Context(TypedDict):
|
|
7
|
+
count: int
|
|
8
|
+
position: int
|
|
9
|
+
matches: int
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def dinc(context: Context):
|
|
13
|
+
context["count"] += 1
|
|
14
|
+
|
|
15
|
+
def dset(context: Context):
|
|
16
|
+
context["position"] = context["count"]
|
|
17
|
+
context["count"] += 1
|
|
18
|
+
|
|
19
|
+
def dprn(context: Context):
|
|
20
|
+
context["count"] += 1
|
|
21
|
+
print(f"SUBSTRING FOUND AT POSITION: {context['position']}")
|
|
22
|
+
context["matches"] += 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Detects the sequence AB in the string, printing the position and returning the amount of matches
|
|
26
|
+
def detector(input_string: str) -> int:
|
|
27
|
+
initial_context: Context = {
|
|
28
|
+
"count": 0,
|
|
29
|
+
"position": 0,
|
|
30
|
+
"matches": 0,
|
|
31
|
+
}
|
|
32
|
+
m = Machine(initial_context)
|
|
33
|
+
m.add_state("Q0", True, False)
|
|
34
|
+
m.add_state("Q1", False, False)
|
|
35
|
+
m.add_transition("A", "Q0", "Q1", lambda context: dset(context))
|
|
36
|
+
m.add_transition(TransitionInputEnum.MATCH_REST, "Q0", "Q0", lambda context: dinc(context))
|
|
37
|
+
m.add_transition("B", "Q1", "Q0", lambda context: dprn(context))
|
|
38
|
+
m.add_transition(TransitionInputEnum.MATCH_REST, "Q1", "Q0", lambda context: dinc(context))
|
|
39
|
+
for c in input_string:
|
|
40
|
+
m.process_input(c)
|
|
41
|
+
return initial_context["matches"]
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
print("== 1 ==")
|
|
45
|
+
n = detector("AAAAAAABAAAAAB")
|
|
46
|
+
print(f"Matches: {n}")
|
|
47
|
+
print("== 2 ==")
|
|
48
|
+
n = detector("AAAA")
|
|
49
|
+
print(f"Matches: {n}")
|
|
50
|
+
print("== 3 ==")
|
|
51
|
+
n = detector("ABCCBBACAB")
|
|
52
|
+
print(f"Matches: {n}")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from typing import Tuple, TypedDict
|
|
2
|
+
|
|
3
|
+
from snanosm.mealy import Machine
|
|
4
|
+
|
|
5
|
+
class Context(TypedDict):
|
|
6
|
+
result: str
|
|
7
|
+
n_chars: int
|
|
8
|
+
|
|
9
|
+
def add_to_result(context: Context, c: str) -> None:
|
|
10
|
+
context["result"] += c
|
|
11
|
+
context["n_chars"] += 1
|
|
12
|
+
|
|
13
|
+
def inverter(input_string: str) -> Tuple[str, int]:
|
|
14
|
+
context: Context = {
|
|
15
|
+
"result": "",
|
|
16
|
+
"n_chars": 0
|
|
17
|
+
}
|
|
18
|
+
m = Machine(context)
|
|
19
|
+
m.add_state("S", True, False)
|
|
20
|
+
m.add_transition("0", "S", "S", lambda ctx: add_to_result(ctx, "1"))
|
|
21
|
+
m.add_transition("1", "S", "S", lambda ctx: add_to_result(ctx, "0"))
|
|
22
|
+
for c in input_string:
|
|
23
|
+
m.process_input(c)
|
|
24
|
+
return context["result"], context["n_chars"]
|
|
25
|
+
|
|
26
|
+
if __name__ == '__main__':
|
|
27
|
+
print(inverter("000011110010"))
|
snanosm-1.0.0/publish.sh
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling >= 1.26"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "snanosm"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="ITStudiosi", email="david@studiosi.es" },
|
|
10
|
+
]
|
|
11
|
+
description = "A pure python small Mealy state machine library"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Topic :: Software Development :: Libraries",
|
|
20
|
+
"Typing :: Typed"
|
|
21
|
+
]
|
|
22
|
+
license = "MIT"
|
|
23
|
+
license-files = ["LICEN[CS]E*"]
|
|
File without changes
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Optional, Callable, Protocol, Self, TypeVar, Generic, List, Union
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
# Anything hashable and equatable can be used as an input
|
|
6
|
+
class InputProtocol(Protocol):
|
|
7
|
+
def __eq__(self, __o: Self) -> bool:
|
|
8
|
+
...
|
|
9
|
+
|
|
10
|
+
def __hash__(self) -> int:
|
|
11
|
+
...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TransitionInputEnum(Enum):
|
|
15
|
+
MATCH_REST = "#TRANSITION_MATCH_REST"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
Input = TypeVar("Input", bound=InputProtocol)
|
|
19
|
+
TransitionInput = TypeVar("TransitionInput", bound=Union[InputProtocol, TransitionInputEnum])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class State:
|
|
23
|
+
def __init__(self, name: str) -> None:
|
|
24
|
+
self.__name = name
|
|
25
|
+
|
|
26
|
+
def get_name(self) -> str:
|
|
27
|
+
return self.__name
|
|
28
|
+
|
|
29
|
+
def __str__(self) -> str:
|
|
30
|
+
return f"[State {self.__name}]"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Transition(Generic[TransitionInput]):
|
|
34
|
+
def __init__(self, transition_input: TransitionInput, origin_name: str, destination_name: str,
|
|
35
|
+
output_function: Optional[Callable[[Optional[object]], None]]) -> None:
|
|
36
|
+
self.__transition_input = transition_input
|
|
37
|
+
self.__origin_name = origin_name
|
|
38
|
+
self.__destination_name = destination_name
|
|
39
|
+
self.__destination_name_hash = hash(destination_name)
|
|
40
|
+
self.__output_function = output_function
|
|
41
|
+
|
|
42
|
+
def execute_output(self, context):
|
|
43
|
+
if self.__output_function is not None:
|
|
44
|
+
self.__output_function(context)
|
|
45
|
+
|
|
46
|
+
def get_destination_name_hash(self) -> int:
|
|
47
|
+
return self.__destination_name_hash
|
|
48
|
+
|
|
49
|
+
def __str__(self) -> str:
|
|
50
|
+
return f"[Transition ({self.__origin_name}, {self.__destination_name}, {self.__transition_input})]"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Machine(Generic[Input]):
|
|
54
|
+
def __init__(self, initial_context: object = None) -> None:
|
|
55
|
+
if initial_context is None:
|
|
56
|
+
initial_context = {}
|
|
57
|
+
self.__start_state_hash: Optional[int] = None
|
|
58
|
+
self.__end_states: List[int] = []
|
|
59
|
+
self.__current_state_hash: Optional[int] = None
|
|
60
|
+
self.__states: dict[int, State] = {}
|
|
61
|
+
# dict[hash_origin, dict[hash_transition_input, Transition]]
|
|
62
|
+
self.__transitions: dict[int, dict[int, Transition]] = {}
|
|
63
|
+
self.__initial_context = initial_context
|
|
64
|
+
self.__context = initial_context
|
|
65
|
+
|
|
66
|
+
def add_transition(self, transition_input: TransitionInput, origin_name: str, destination_name: str,
|
|
67
|
+
output_function: Optional[Callable[[Optional[object]], None]]) -> None:
|
|
68
|
+
ho = hash(origin_name)
|
|
69
|
+
if ho not in self.__states.keys():
|
|
70
|
+
raise ValueError(f"Origin state {origin_name} does not exist.")
|
|
71
|
+
hd = hash(destination_name)
|
|
72
|
+
if hd not in self.__states.keys():
|
|
73
|
+
raise ValueError(f"Destination state {destination_name} does not exist.")
|
|
74
|
+
if isinstance(transition_input, TransitionInputEnum):
|
|
75
|
+
hi = hash(transition_input.name)
|
|
76
|
+
else:
|
|
77
|
+
hi = hash(transition_input)
|
|
78
|
+
t = Transition(transition_input, origin_name, destination_name, output_function)
|
|
79
|
+
if ho not in self.__transitions.keys():
|
|
80
|
+
self.__transitions[ho] = {hi: t}
|
|
81
|
+
elif hi not in self.__transitions[ho]:
|
|
82
|
+
self.__transitions[ho][hi] = t
|
|
83
|
+
elif hi in self.__transitions[ho]:
|
|
84
|
+
raise ValueError(f"Transition already exists for origin state {origin_name} and transition input {transition_input}.")
|
|
85
|
+
|
|
86
|
+
def add_state(self, state_name: str, is_start_state: bool = False, is_end_state: bool = False) -> None:
|
|
87
|
+
if state_name.startswith("#"):
|
|
88
|
+
raise ValueError("State names cannot start with # (reserved for special states)")
|
|
89
|
+
h: int = hash(state_name)
|
|
90
|
+
if is_start_state:
|
|
91
|
+
if self.__start_state_hash is None:
|
|
92
|
+
self.__start_state_hash = h
|
|
93
|
+
else:
|
|
94
|
+
raise ValueError("Adding a start state when one is already set.")
|
|
95
|
+
if is_end_state:
|
|
96
|
+
# Multiple end states possible
|
|
97
|
+
self.__end_states.append(h)
|
|
98
|
+
if h in self.__states.keys():
|
|
99
|
+
raise ValueError(f"State {state_name} already exists.")
|
|
100
|
+
state = State(state_name)
|
|
101
|
+
self.__states[h] = state
|
|
102
|
+
|
|
103
|
+
def process_input(self, i: Input) -> None:
|
|
104
|
+
if self.__start_state_hash is None:
|
|
105
|
+
raise ValueError("Processing input without a start state.")
|
|
106
|
+
if self.__current_state_hash is None:
|
|
107
|
+
self.__current_state_hash = self.__start_state_hash
|
|
108
|
+
# dict[hash_origin, dict[hash_transition_input, Transition]]
|
|
109
|
+
if self.__current_state_hash not in self.__transitions.keys():
|
|
110
|
+
raise ValueError("Processing input for a state with no transitions.")
|
|
111
|
+
assert self.__current_state_hash is not None
|
|
112
|
+
transitions = self.__transitions[self.__current_state_hash]
|
|
113
|
+
hi = hash(i)
|
|
114
|
+
transition: Optional[Transition] = None
|
|
115
|
+
if hi in transitions.keys():
|
|
116
|
+
# Transition found: execute and move to the destination state
|
|
117
|
+
transition = transitions[hi]
|
|
118
|
+
# No transition found, check if there's a special transition
|
|
119
|
+
if transition is None:
|
|
120
|
+
hc = hash(TransitionInputEnum.MATCH_REST)
|
|
121
|
+
if hc in transitions.keys():
|
|
122
|
+
transition = transitions[hc]
|
|
123
|
+
else:
|
|
124
|
+
raise ValueError(f"Found invalid transition while processing input {i} [{transition}].]")
|
|
125
|
+
assert transition is not None
|
|
126
|
+
transition.execute_output(self.__context)
|
|
127
|
+
self.__current_state_hash = transition.get_destination_name_hash()
|
|
128
|
+
|
|
129
|
+
def reset(self) -> None:
|
|
130
|
+
if self.__start_state_hash is None:
|
|
131
|
+
raise ValueError("Resetting a machine without starting state.")
|
|
132
|
+
self.__current_state_hash = self.__start_state_hash
|
|
133
|
+
self.__context = self.__initial_context
|
|
134
|
+
|
|
135
|
+
def get_current_state(self) -> Optional[State]:
|
|
136
|
+
if self.__current_state_hash is None:
|
|
137
|
+
return None # The machine has not started yet
|
|
138
|
+
return self.__states[self.__current_state_hash]
|
|
139
|
+
|
|
140
|
+
def is_in_final_state(self) -> bool:
|
|
141
|
+
if self.__current_state_hash is None:
|
|
142
|
+
return False
|
|
143
|
+
assert self.__current_state_hash is not None
|
|
144
|
+
return self.__current_state_hash in self.__end_states
|
|
145
|
+
|
|
146
|
+
def __str__(self) -> str:
|
|
147
|
+
r = [f"=MACHINE=", "\tSTATES"]
|
|
148
|
+
for state in self.__states.values():
|
|
149
|
+
r.append(f"\t\t{str(state)}")
|
|
150
|
+
r.append("\tTRANSITIONS")
|
|
151
|
+
for transition_origin in self.__transitions.keys():
|
|
152
|
+
for transition_input in self.__transitions[transition_origin]:
|
|
153
|
+
r.append(f"\t\t{str(self.__transitions[transition_origin][transition_input])}")
|
|
154
|
+
return "\n".join(r)
|
|
File without changes
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import unittest
|
|
3
|
+
|
|
4
|
+
from snanosm.mealy import Machine, TransitionInputEnum, State, Transition
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Any hashable and equatable object should suffice
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class TestTransitionObject:
|
|
10
|
+
attribute: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestMachine(unittest.TestCase):
|
|
14
|
+
|
|
15
|
+
def test_init(self):
|
|
16
|
+
m = Machine()
|
|
17
|
+
self.assertIsNotNone(m)
|
|
18
|
+
|
|
19
|
+
# ADD STATE
|
|
20
|
+
|
|
21
|
+
def test_add_state_two_start_states(self):
|
|
22
|
+
m = Machine()
|
|
23
|
+
m.add_state("A", True, False)
|
|
24
|
+
with self.assertRaises(ValueError):
|
|
25
|
+
m.add_state("B", True, False)
|
|
26
|
+
|
|
27
|
+
def test_add_two_end_states(self):
|
|
28
|
+
m = Machine()
|
|
29
|
+
m.add_state("A", True, False)
|
|
30
|
+
m.add_state("B", False, True)
|
|
31
|
+
m.add_state("C", False, True)
|
|
32
|
+
|
|
33
|
+
def test_add_state_with_reserved_name(self):
|
|
34
|
+
m = Machine()
|
|
35
|
+
with self.assertRaises(ValueError):
|
|
36
|
+
m.add_state("#INVALID_STATE_NAME", True, False)
|
|
37
|
+
|
|
38
|
+
def test_add_state_with_duplicate_state_name(self):
|
|
39
|
+
m = Machine()
|
|
40
|
+
m.add_state("A", True, False)
|
|
41
|
+
with self.assertRaises(ValueError):
|
|
42
|
+
m.add_state("A", False, True)
|
|
43
|
+
|
|
44
|
+
# ADD TRANSITION
|
|
45
|
+
|
|
46
|
+
def test_add_transition(self):
|
|
47
|
+
m = Machine()
|
|
48
|
+
m.add_state("A", True, False)
|
|
49
|
+
m.add_state("B", False, True)
|
|
50
|
+
m.add_transition("X", "A", "B", lambda context: print(context))
|
|
51
|
+
|
|
52
|
+
def test_add_transition_non_existent_origin_state(self):
|
|
53
|
+
m = Machine()
|
|
54
|
+
m.add_state("A", True, False)
|
|
55
|
+
m.add_state("B", False, True)
|
|
56
|
+
with self.assertRaises(ValueError):
|
|
57
|
+
m.add_transition("X", "C", "B", lambda context: print(context))
|
|
58
|
+
|
|
59
|
+
def test_add_transition_non_existent_destination_state(self):
|
|
60
|
+
m = Machine()
|
|
61
|
+
m.add_state("A", True, False)
|
|
62
|
+
m.add_state("B", False, True)
|
|
63
|
+
with self.assertRaises(ValueError):
|
|
64
|
+
m.add_transition("X", "A", "C", lambda context: print(context))
|
|
65
|
+
|
|
66
|
+
def test_add_special_transition(self):
|
|
67
|
+
m = Machine()
|
|
68
|
+
m.add_state("A", True, False)
|
|
69
|
+
m.add_state("B", False, True)
|
|
70
|
+
m.add_transition(TransitionInputEnum.MATCH_REST, "A", "B", lambda context: print(context))
|
|
71
|
+
|
|
72
|
+
def test_add_several_transitions_with_same_origin(self):
|
|
73
|
+
m = Machine()
|
|
74
|
+
m.add_state("A", True, False)
|
|
75
|
+
m.add_state("B", False, True)
|
|
76
|
+
m.add_transition("X", "A", "B", lambda context: print("1"))
|
|
77
|
+
m.add_transition("Y", "A", "B", lambda context: print("2"))
|
|
78
|
+
|
|
79
|
+
def test_add_duplicate_transition(self):
|
|
80
|
+
m = Machine()
|
|
81
|
+
m.add_state("A", True, False)
|
|
82
|
+
m.add_state("B", False, True)
|
|
83
|
+
m.add_transition("X", "A", "B", lambda context: print("1"))
|
|
84
|
+
with self.assertRaises(ValueError):
|
|
85
|
+
m.add_transition("X", "A", "B", lambda context: print("2"))
|
|
86
|
+
|
|
87
|
+
# PROCESS INPUT
|
|
88
|
+
|
|
89
|
+
def test_process_input(self):
|
|
90
|
+
m = Machine()
|
|
91
|
+
m.add_state("A", True, False)
|
|
92
|
+
m.add_state("B", False, False)
|
|
93
|
+
m.add_state("C", False, True)
|
|
94
|
+
m.add_transition("X", "A", "B", lambda context: print("TEST_PROCESS_INPUT: Transition 1"))
|
|
95
|
+
m.add_transition("X", "B", "C", lambda context: print("TEST_PROCESS_INPUT: Transition 2"))
|
|
96
|
+
m.process_input("X")
|
|
97
|
+
m.process_input("X")
|
|
98
|
+
current_state = m.get_current_state()
|
|
99
|
+
self.assertIsNotNone(current_state)
|
|
100
|
+
assert current_state is not None
|
|
101
|
+
self.assertEqual(current_state.get_name(), "C")
|
|
102
|
+
|
|
103
|
+
def test_process_input_no_start_state(self):
|
|
104
|
+
m = Machine()
|
|
105
|
+
m.add_state("A", False, False)
|
|
106
|
+
m.add_state("B", False, False)
|
|
107
|
+
m.add_transition("X", "A", "B", lambda context: print("TEST_PROCESS_INPUT_NO_START_DATE: Transition"))
|
|
108
|
+
with self.assertRaises(ValueError):
|
|
109
|
+
m.process_input("X")
|
|
110
|
+
|
|
111
|
+
def test_process_input_state_without_transitions(self):
|
|
112
|
+
m = Machine()
|
|
113
|
+
m.add_state("A", True, False)
|
|
114
|
+
m.add_state("B", False, True)
|
|
115
|
+
m.add_transition("X", "A", "B", lambda context: print("TEST_PROCESS_INPUT_WITHOUT_TRANSITIONS: Transition"))
|
|
116
|
+
m.process_input("X")
|
|
117
|
+
current_state = m.get_current_state()
|
|
118
|
+
self.assertIsNotNone(current_state)
|
|
119
|
+
assert current_state is not None
|
|
120
|
+
self.assertEqual(current_state.get_name(), "B")
|
|
121
|
+
with self.assertRaises(ValueError):
|
|
122
|
+
m.process_input("X")
|
|
123
|
+
|
|
124
|
+
def test_process_input_catch_all_transition(self):
|
|
125
|
+
m = Machine()
|
|
126
|
+
m.add_state("A", True, False)
|
|
127
|
+
m.add_state("B", False, True)
|
|
128
|
+
m.add_transition(TransitionInputEnum.MATCH_REST, "A", "B", lambda context: print("A -> B"))
|
|
129
|
+
m.add_transition(TransitionInputEnum.MATCH_REST, "B", "A", lambda context: print("B -> A"))
|
|
130
|
+
m.process_input("X")
|
|
131
|
+
current_state = m.get_current_state()
|
|
132
|
+
self.assertIsNotNone(current_state)
|
|
133
|
+
assert current_state is not None
|
|
134
|
+
self.assertEqual(current_state.get_name(), "B")
|
|
135
|
+
m.process_input(1)
|
|
136
|
+
current_state = m.get_current_state()
|
|
137
|
+
self.assertIsNotNone(current_state)
|
|
138
|
+
assert current_state is not None
|
|
139
|
+
self.assertEqual(current_state.get_name(), "A")
|
|
140
|
+
m.process_input(TestTransitionObject("X"))
|
|
141
|
+
current_state = m.get_current_state()
|
|
142
|
+
self.assertIsNotNone(current_state)
|
|
143
|
+
assert current_state is not None
|
|
144
|
+
self.assertEqual(current_state.get_name(), "B")
|
|
145
|
+
|
|
146
|
+
def test_process_input_with_objects(self):
|
|
147
|
+
m = Machine()
|
|
148
|
+
m.add_state("A", True, False)
|
|
149
|
+
m.add_state("B", False, False)
|
|
150
|
+
m.add_state("C", False, True)
|
|
151
|
+
m.add_transition(TestTransitionObject("TEST_1"), "A", "B", lambda context: print("A -> B"))
|
|
152
|
+
m.add_transition(TestTransitionObject("TEST_2"), "B", "C", lambda context: print("B -> C"))
|
|
153
|
+
m.process_input(TestTransitionObject("TEST_1"))
|
|
154
|
+
current_state = m.get_current_state()
|
|
155
|
+
self.assertIsNotNone(current_state)
|
|
156
|
+
assert current_state is not None
|
|
157
|
+
self.assertEqual(current_state.get_name(), "B")
|
|
158
|
+
m.process_input(TestTransitionObject("TEST_2"))
|
|
159
|
+
current_state = m.get_current_state()
|
|
160
|
+
self.assertIsNotNone(current_state)
|
|
161
|
+
assert current_state is not None
|
|
162
|
+
self.assertEqual(current_state.get_name(), "C")
|
|
163
|
+
|
|
164
|
+
def test_process_input_invalid_transition(self):
|
|
165
|
+
m = Machine()
|
|
166
|
+
m.add_state("A", True, False)
|
|
167
|
+
m.add_state("B", False, True)
|
|
168
|
+
m.add_transition("X", "A", "B", lambda context: print("A -> B"))
|
|
169
|
+
with self.assertRaises(ValueError):
|
|
170
|
+
m.process_input("Y")
|
|
171
|
+
|
|
172
|
+
# GET CURRENT STATE
|
|
173
|
+
def test_get_current_state_machine_not_started(self):
|
|
174
|
+
m = Machine()
|
|
175
|
+
state = m.get_current_state()
|
|
176
|
+
self.assertIsNone(state)
|
|
177
|
+
|
|
178
|
+
def test_get_current_state_machine_started(self):
|
|
179
|
+
m = Machine()
|
|
180
|
+
m.add_state("A", True, False)
|
|
181
|
+
m.add_state("B", False, True)
|
|
182
|
+
m.add_transition("X", "A", "B", lambda context: print("A -> B"))
|
|
183
|
+
m.process_input("X")
|
|
184
|
+
current_state = m.get_current_state()
|
|
185
|
+
self.assertIsNotNone(current_state)
|
|
186
|
+
assert current_state is not None
|
|
187
|
+
self.assertEqual(current_state.get_name(), "B")
|
|
188
|
+
|
|
189
|
+
# RESET
|
|
190
|
+
|
|
191
|
+
def test_reset(self):
|
|
192
|
+
m = Machine()
|
|
193
|
+
m.add_state("C", True, False)
|
|
194
|
+
m.add_state("D", False, True)
|
|
195
|
+
m.add_transition("X", "C", "D", lambda context: print("A -> B"))
|
|
196
|
+
m.process_input("X")
|
|
197
|
+
current_state = m.get_current_state()
|
|
198
|
+
self.assertIsNotNone(current_state)
|
|
199
|
+
assert current_state is not None
|
|
200
|
+
self.assertEqual(current_state.get_name(), "D")
|
|
201
|
+
m.reset()
|
|
202
|
+
current_state = m.get_current_state()
|
|
203
|
+
self.assertIsNotNone(current_state)
|
|
204
|
+
assert current_state is not None
|
|
205
|
+
self.assertEqual(current_state.get_name(), "C")
|
|
206
|
+
|
|
207
|
+
def test_reset_no_start_state(self):
|
|
208
|
+
m = Machine()
|
|
209
|
+
m.add_state("A", False, False)
|
|
210
|
+
with self.assertRaises(ValueError):
|
|
211
|
+
m.reset()
|
|
212
|
+
|
|
213
|
+
# IS_IN_FINAL_STATE
|
|
214
|
+
|
|
215
|
+
def test_is_in_final_state(self):
|
|
216
|
+
m = Machine()
|
|
217
|
+
m.add_state("A", True, False)
|
|
218
|
+
m.add_state("B", False, True)
|
|
219
|
+
m.add_transition("X", "A", "B", lambda context: print("A -> B"))
|
|
220
|
+
m.process_input("X")
|
|
221
|
+
self.assertTrue(m.is_in_final_state())
|
|
222
|
+
m.reset()
|
|
223
|
+
self.assertFalse(m.is_in_final_state())
|
|
224
|
+
|
|
225
|
+
def test_is_in_final_state_not_started(self):
|
|
226
|
+
m = Machine()
|
|
227
|
+
self.assertFalse(m.is_in_final_state())
|
|
228
|
+
|
|
229
|
+
# STATE
|
|
230
|
+
|
|
231
|
+
def test_state_str(self):
|
|
232
|
+
s = State("A")
|
|
233
|
+
t = str(s)
|
|
234
|
+
self.assertEqual(t, "[State A]")
|
|
235
|
+
|
|
236
|
+
# TRANSITION
|
|
237
|
+
|
|
238
|
+
def test_transition_str(self):
|
|
239
|
+
t = Transition("TEST_1", "A", "B", lambda context: print("A -> B"))
|
|
240
|
+
self.assertEqual(str(t), "[Transition (A, B, TEST_1)]")
|
|
241
|
+
|
|
242
|
+
# MACHINE
|
|
243
|
+
|
|
244
|
+
def test_machine_str(self):
|
|
245
|
+
m = Machine()
|
|
246
|
+
m.add_state("A", True, False)
|
|
247
|
+
m.add_state("B", False, False)
|
|
248
|
+
m.add_state("C", False, True)
|
|
249
|
+
m.add_transition("TEST_1", "A", "B", lambda context: print("A -> B"))
|
|
250
|
+
m.add_transition("TEST_2", "B", "C", lambda context: print("B -> C"))
|
|
251
|
+
result = "=MACHINE=\n\tSTATES\n\t\t[State A]\n\t\t[State B]\n\t\t[State C]\n\tTRANSITIONS\n\t\t[Transition (A, B, TEST_1)]\n\t\t[Transition (B, C, TEST_2)]"
|
|
252
|
+
self.assertEqual(str(m), result)
|
|
253
|
+
|
|
254
|
+
|