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.
@@ -0,0 +1,3 @@
1
+ [run]
2
+ omit =
3
+ tests/*
@@ -0,0 +1,4 @@
1
+ .idea
2
+ .venv
3
+ **/__pycache__
4
+ dist
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
+ ```
@@ -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"))
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+
3
+ python3 -m build
4
+ python3 -m twine upload --repository pypi dist/*
@@ -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
+