qstate 1.0.0__py3-none-any.whl

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.
qsm/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Queued state machine package."""
2
+
3
+ from .qsm import QSM
4
+ from .states import State, StateContext
5
+
6
+ __all__ = [
7
+ "QSM",
8
+ "State",
9
+ "StateContext",
10
+ ]
qsm/deque.py ADDED
@@ -0,0 +1,82 @@
1
+ from queue import Full, Queue
2
+ from time import monotonic
3
+ from typing import Optional
4
+
5
+
6
+ class StringDequeQueue(Queue[str]):
7
+ """Thread-safe string queue that supports appending to either end.
8
+
9
+ This queue follows the synchronization behavior of ``queue.Queue`` while
10
+ adding prepend operations backed by the underlying deque.
11
+ """
12
+
13
+ def put(self, *items: str, block: bool = True, timeout: Optional[float] = None) -> None:
14
+ """Append one or more strings using the standard ``Queue.put`` name."""
15
+ self.append(*items, block=block, timeout=timeout)
16
+
17
+ def append(self, *items: str, block: bool = True, timeout: Optional[float] = None) -> None:
18
+ """Add one or more strings to the back of the queue."""
19
+ for item in items:
20
+ self._put_string(item, left=False, block=block, timeout=timeout)
21
+
22
+ def prepend(self, *items: str, block: bool = True, timeout: Optional[float] = None) -> None:
23
+ """Add one or more strings to the front of the queue."""
24
+ for item in items:
25
+ self._put_string(item, left=True, block=block, timeout=timeout)
26
+
27
+ def append_nowait(self, *items: str) -> None:
28
+ """Add one or more strings to the back without blocking."""
29
+ self.append(*items, block=False)
30
+
31
+ def prepend_nowait(self, *items: str) -> None:
32
+ """Add one or more strings to the front without blocking."""
33
+ self.prepend(*items, block=False)
34
+
35
+ def flush(self) -> int:
36
+ """Remove all queued strings and count them as completed.
37
+
38
+ Returns:
39
+ The number of queued items removed.
40
+ """
41
+ with self.mutex:
42
+ removed = self._qsize()
43
+ if removed == 0:
44
+ return 0
45
+
46
+ self.queue.clear()
47
+ self.unfinished_tasks -= removed
48
+ if self.unfinished_tasks == 0:
49
+ self.all_tasks_done.notify_all()
50
+ self.not_full.notify_all()
51
+ return removed
52
+
53
+ def _put_string(self, item: str, left: bool, block: bool, timeout: Optional[float]) -> None:
54
+ """Add a single string while holding the queue's producer lock."""
55
+ if not isinstance(item, str):
56
+ raise TypeError("StringDequeQueue only accepts strings")
57
+ if timeout is not None and timeout < 0:
58
+ raise ValueError("'timeout' must be either 0 or non-negative")
59
+
60
+ with self.not_full:
61
+ if self.maxsize > 0:
62
+ if not block:
63
+ if self._qsize() >= self.maxsize:
64
+ raise Full
65
+ elif timeout is None:
66
+ while self._qsize() >= self.maxsize:
67
+ self.not_full.wait()
68
+ else:
69
+ endtime = monotonic() + timeout
70
+ while self._qsize() >= self.maxsize:
71
+ remaining = endtime - monotonic()
72
+ if remaining <= 0.0:
73
+ raise Full
74
+ self.not_full.wait(remaining)
75
+
76
+ if left:
77
+ self.queue.appendleft(item)
78
+ else:
79
+ self.queue.append(item)
80
+
81
+ self.unfinished_tasks += 1
82
+ self.not_empty.notify()
qsm/exceptions.py ADDED
@@ -0,0 +1,4 @@
1
+ class NoSuchStateException(Exception):
2
+ """Raised when a state name is not registered with a QSM."""
3
+
4
+ pass
qsm/qsm.py ADDED
@@ -0,0 +1,72 @@
1
+ from queue import Empty
2
+ from typing import Any, Dict, Optional
3
+
4
+ from .deque import StringDequeQueue
5
+ from .exceptions import NoSuchStateException
6
+ from .states import State, StateContext
7
+
8
+
9
+ class QSM:
10
+ """Queued state machine.
11
+
12
+ A ``QSM`` stores pending state names in a queue, resolves each name through
13
+ ``state_map``, and executes states until no pending states remain.
14
+ """
15
+
16
+ def __init__(self, initial_context: Any = None, initial_state: str = "initial_state", max_queue_size: Optional[int] = None):
17
+ """Create a queued state machine.
18
+
19
+ Args:
20
+ initial_context: Shared workflow data passed to every state.
21
+ initial_state: State name used when ``loop`` is called without an
22
+ explicit initial state.
23
+ max_queue_size: Optional maximum number of queued states.
24
+ """
25
+ self.queue = StringDequeQueue(maxsize=max_queue_size) if max_queue_size is not None else StringDequeQueue()
26
+ self.state_map: Dict[str, State] = {}
27
+ self.initial_state = initial_state
28
+ self.current_state: str = initial_state
29
+ self.context = initial_context
30
+
31
+ def get_next_state(self) -> Optional[str]:
32
+ """Dequeues the next state name and makes it current.
33
+
34
+ Returns ``None`` when no state is queued.
35
+ """
36
+ try:
37
+ self.current_state = self.queue.get_nowait()
38
+ return self.current_state
39
+ except Empty:
40
+ return None
41
+
42
+ def execute_state(self, name: str):
43
+ """Execute a registered state by name.
44
+
45
+ Raises:
46
+ NoSuchStateException: If ``name`` is not registered in
47
+ ``state_map``.
48
+ """
49
+ if name not in self.state_map:
50
+ raise NoSuchStateException(name)
51
+ ctx = StateContext(
52
+ queue=self.queue,
53
+ context=self.context,
54
+ )
55
+ self.state_map[name].execute(ctx)
56
+
57
+ def execute_current_state(self):
58
+ """Execute the state named by ``current_state``."""
59
+ self.execute_state(self.current_state)
60
+
61
+ def loop(self, flush: bool = True):
62
+ """Run queued states until the queue is empty.
63
+
64
+ If ``flush`` is True, the loop will flush the current queue and enqueue self.initial_state
65
+ """
66
+ if flush:
67
+ self.queue.flush()
68
+ self.queue.append(self.initial_state)
69
+ while not self.queue.empty():
70
+ state = self.get_next_state()
71
+ if state is not None:
72
+ self.execute_current_state()
qsm/states.py ADDED
@@ -0,0 +1,30 @@
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass
3
+ import logging
4
+ from typing import Any
5
+
6
+ from .deque import StringDequeQueue
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ @dataclass
12
+ class StateContext:
13
+ """Execution context passed into each state.
14
+
15
+ Attributes:
16
+ queue: Queue used to schedule additional state names.
17
+ context: Shared workflow data owned by the ``QSM`` instance.
18
+ """
19
+
20
+ queue: StringDequeQueue
21
+ context: Any
22
+
23
+
24
+ class State(ABC):
25
+ """Base class for queued state machine states."""
26
+
27
+ @abstractmethod
28
+ def execute(self, ctx: StateContext):
29
+ """Run this state with the provided context."""
30
+ pass