pyautoscene 0.1.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.
- pyautoscene/__init__.py +5 -0
- pyautoscene/references.py +38 -0
- pyautoscene/scene.py +61 -0
- pyautoscene/session.py +140 -0
- pyautoscene/utils.py +25 -0
- pyautoscene-0.1.0.dist-info/METADATA +200 -0
- pyautoscene-0.1.0.dist-info/RECORD +9 -0
- pyautoscene-0.1.0.dist-info/WHEEL +4 -0
- pyautoscene-0.1.0.dist-info/entry_points.txt +2 -0
pyautoscene/__init__.py
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
from abc import ABC, abstractmethod
|
2
|
+
|
3
|
+
import pyautogui as gui
|
4
|
+
from pyscreeze import Box
|
5
|
+
|
6
|
+
|
7
|
+
class ReferenceElement(ABC):
|
8
|
+
"""Base class for reference elements used to identify scenes."""
|
9
|
+
|
10
|
+
@abstractmethod
|
11
|
+
def is_visible(self):
|
12
|
+
"""Detect the presence of the reference element."""
|
13
|
+
raise NotImplementedError("Subclasses must implement this method")
|
14
|
+
|
15
|
+
|
16
|
+
class ReferenceImage(ReferenceElement):
|
17
|
+
"""Reference element that identifies a scene by an image."""
|
18
|
+
|
19
|
+
def __init__(self, image_path: str):
|
20
|
+
self.image_path = image_path
|
21
|
+
|
22
|
+
def is_visible(self, region: Box | None = None):
|
23
|
+
"""Method to detect the presence of the image in the current screen."""
|
24
|
+
try:
|
25
|
+
return gui.locateOnScreen(self.image_path, region=region)
|
26
|
+
except gui.ImageNotFoundException:
|
27
|
+
return None
|
28
|
+
|
29
|
+
|
30
|
+
class ReferenceText(ReferenceElement):
|
31
|
+
"""Reference element that identifies a scene by text."""
|
32
|
+
|
33
|
+
def __init__(self, text: str):
|
34
|
+
self.text = text
|
35
|
+
|
36
|
+
def is_visible(self):
|
37
|
+
"""Method to detect the presence of the text in the current screen."""
|
38
|
+
raise NotImplementedError("Text recognition is not implemented yet.")
|
pyautoscene/scene.py
ADDED
@@ -0,0 +1,61 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import Callable, TypedDict
|
4
|
+
|
5
|
+
from pyscreeze import Box
|
6
|
+
from statemachine import State
|
7
|
+
|
8
|
+
from pyautoscene.utils import is_valid_variable_name
|
9
|
+
|
10
|
+
from .references import ReferenceElement, ReferenceImage
|
11
|
+
|
12
|
+
|
13
|
+
class ActionInfo(TypedDict):
|
14
|
+
"""Type definition for action information in a scene."""
|
15
|
+
|
16
|
+
action: Callable[..., None]
|
17
|
+
transitions_to: Scene | None
|
18
|
+
|
19
|
+
|
20
|
+
class Scene(State):
|
21
|
+
"""A scene represents a state in the GUI automation state machine."""
|
22
|
+
|
23
|
+
def __init__(
|
24
|
+
self,
|
25
|
+
name: str,
|
26
|
+
elements: list[ReferenceElement] | None = None,
|
27
|
+
initial: bool = False,
|
28
|
+
):
|
29
|
+
assert is_valid_variable_name(name), (
|
30
|
+
f"Invalid scene name: {name}, must be a valid Python identifier."
|
31
|
+
)
|
32
|
+
super().__init__(name, initial=initial)
|
33
|
+
self.elements = elements or []
|
34
|
+
self.actions: dict[str, ActionInfo] = {}
|
35
|
+
|
36
|
+
def action(self, transitions_to: Scene | None = None):
|
37
|
+
"""Decorator to register an action for this scene."""
|
38
|
+
|
39
|
+
def decorator(func: Callable[..., None]) -> Callable[..., None]:
|
40
|
+
if func.__name__ not in self.actions:
|
41
|
+
action_name = func.__name__
|
42
|
+
self.actions[action_name] = {
|
43
|
+
"action": func,
|
44
|
+
"transitions_to": transitions_to,
|
45
|
+
}
|
46
|
+
return func
|
47
|
+
|
48
|
+
return decorator
|
49
|
+
|
50
|
+
def get_action(self, action_name: str) -> ActionInfo | None:
|
51
|
+
"""Get an action by name."""
|
52
|
+
return self.actions.get(action_name)
|
53
|
+
|
54
|
+
def is_on_screen(self, region: Box | None = None) -> bool:
|
55
|
+
"""Check if any reference element is currently on screen."""
|
56
|
+
# TODO: Refactor after text recognition is implemented
|
57
|
+
elements = (elem for elem in self.elements if isinstance(elem, ReferenceImage))
|
58
|
+
return all(elem.is_visible(region) for elem in elements)
|
59
|
+
|
60
|
+
def __repr__(self):
|
61
|
+
return f"Scene({self.name!r}, elements={len(self.elements)})"
|
pyautoscene/session.py
ADDED
@@ -0,0 +1,140 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import Callable
|
4
|
+
|
5
|
+
import networkx as nx
|
6
|
+
from pyscreeze import Box
|
7
|
+
from statemachine import State, StateMachine
|
8
|
+
from statemachine.factory import StateMachineMetaclass
|
9
|
+
from statemachine.states import States
|
10
|
+
from statemachine.transition_list import TransitionList
|
11
|
+
|
12
|
+
from .scene import Scene
|
13
|
+
|
14
|
+
|
15
|
+
class SceneRecognitionError(Exception):
|
16
|
+
pass
|
17
|
+
|
18
|
+
|
19
|
+
def build_dynamic_state_machine(
|
20
|
+
scenes: list[Scene],
|
21
|
+
) -> tuple[StateMachine, dict[str, TransitionList], dict[str, Callable]]:
|
22
|
+
"""Create a dynamic StateMachine class from scenes using StateMachineMetaclass."""
|
23
|
+
|
24
|
+
states = {scene.name: scene for scene in scenes}
|
25
|
+
transitions = {}
|
26
|
+
leaf_actions = {}
|
27
|
+
for scene in scenes:
|
28
|
+
for action_name, action_info in scene.actions.items():
|
29
|
+
target_scene = action_info["transitions_to"]
|
30
|
+
if target_scene is not None:
|
31
|
+
event_name = f"event_{action_name}"
|
32
|
+
new_transition = scene.to(target_scene, event=event_name)
|
33
|
+
new_transition.on(action_info["action"])
|
34
|
+
transitions[event_name] = new_transition
|
35
|
+
else:
|
36
|
+
leaf_actions[action_name] = action_info["action"]
|
37
|
+
|
38
|
+
SessionSM = StateMachineMetaclass(
|
39
|
+
"SessionSM",
|
40
|
+
(StateMachine,),
|
41
|
+
{"states": States(states), **transitions}, # type: ignore[call-arg]
|
42
|
+
)
|
43
|
+
session_sm: StateMachine = SessionSM() # type: ignore[no-redef]
|
44
|
+
|
45
|
+
return session_sm, transitions, leaf_actions
|
46
|
+
|
47
|
+
|
48
|
+
def get_current_scene(scenes: list[Scene], region: Box | None = None) -> Scene:
|
49
|
+
"""Get the current scene from the list of scenes."""
|
50
|
+
current_scenes = [scene for scene in scenes if scene.is_on_screen(region)]
|
51
|
+
if len(current_scenes) == 1:
|
52
|
+
return current_scenes[0]
|
53
|
+
elif len(current_scenes) > 1:
|
54
|
+
raise SceneRecognitionError(
|
55
|
+
f"Multiple scenes are currently on screen.\n{' '.join(str(scene) for scene in current_scenes)}"
|
56
|
+
)
|
57
|
+
else:
|
58
|
+
raise SceneRecognitionError("No scene is currently on screen.")
|
59
|
+
|
60
|
+
|
61
|
+
class Session:
|
62
|
+
"""A session manages the state machine for GUI automation scenes."""
|
63
|
+
|
64
|
+
def __init__(self, scenes: list[Scene]):
|
65
|
+
self._scenes_list = scenes
|
66
|
+
self._scenes_dict = {scene.name: scene for scene in scenes}
|
67
|
+
|
68
|
+
# Create dynamic StateMachine class and instantiate it
|
69
|
+
self._sm, self.transitions, self.leaf_actions = build_dynamic_state_machine(
|
70
|
+
scenes
|
71
|
+
)
|
72
|
+
self.graph: nx.MultiDiGraph = nx.nx_pydot.from_pydot(self._sm._graph())
|
73
|
+
|
74
|
+
@property
|
75
|
+
def current_scene(self) -> State:
|
76
|
+
"""Get the current state."""
|
77
|
+
return self._sm.current_state
|
78
|
+
|
79
|
+
def expect(self, target_scene: Scene, **kwargs):
|
80
|
+
"""Navigate to a specific scene."""
|
81
|
+
if target_scene.is_on_screen():
|
82
|
+
return
|
83
|
+
|
84
|
+
present_scene = get_current_scene(self._scenes_list)
|
85
|
+
all_paths = list(
|
86
|
+
nx.all_simple_paths(
|
87
|
+
self.graph,
|
88
|
+
source=present_scene.name,
|
89
|
+
target=target_scene.name,
|
90
|
+
)
|
91
|
+
)
|
92
|
+
if len(all_paths) == 0:
|
93
|
+
raise SceneRecognitionError(
|
94
|
+
f"No path found from {present_scene.name} to {target_scene.name}"
|
95
|
+
)
|
96
|
+
elif len(all_paths) > 1:
|
97
|
+
raise SceneRecognitionError(
|
98
|
+
f"Multiple paths found from {present_scene.name} to {target_scene.name}"
|
99
|
+
)
|
100
|
+
|
101
|
+
path = all_paths[0]
|
102
|
+
events: list[str] = [
|
103
|
+
self.graph.get_edge_data(path[i], path[i + 1])[0]["label"] # type: ignore
|
104
|
+
for i in range(len(path) - 1)
|
105
|
+
]
|
106
|
+
|
107
|
+
for event in events:
|
108
|
+
self._sm.send(event, **kwargs)
|
109
|
+
|
110
|
+
def invoke(self, action_name: str, **kwargs):
|
111
|
+
"""Invoke an action in the current scene."""
|
112
|
+
event_name = f"event_{action_name}"
|
113
|
+
transition = next(
|
114
|
+
(tr for tr_name, tr in self.transitions.items() if tr_name == event_name),
|
115
|
+
None,
|
116
|
+
)
|
117
|
+
if transition:
|
118
|
+
return self._sm.send(event_name, **kwargs)
|
119
|
+
|
120
|
+
leaf_action = next(
|
121
|
+
(
|
122
|
+
action
|
123
|
+
for name, action in self.leaf_actions.items()
|
124
|
+
if name == action_name
|
125
|
+
),
|
126
|
+
None,
|
127
|
+
)
|
128
|
+
if leaf_action:
|
129
|
+
return leaf_action(**kwargs)
|
130
|
+
|
131
|
+
raise ValueError(
|
132
|
+
f"Action '{action_name}' not found in current scene '{self.current_scene.name}'"
|
133
|
+
)
|
134
|
+
|
135
|
+
def __repr__(self):
|
136
|
+
current = self.current_scene
|
137
|
+
current_name = current.name if current else "None"
|
138
|
+
return (
|
139
|
+
f"Session(scenes={list(self._scenes_dict.keys())}, current={current_name})"
|
140
|
+
)
|
pyautoscene/utils.py
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import time
|
4
|
+
from keyword import iskeyword
|
5
|
+
from typing import Literal
|
6
|
+
|
7
|
+
import pyautogui as gui
|
8
|
+
|
9
|
+
LOCATE_AND_CLICK_DELAY = 0.2
|
10
|
+
|
11
|
+
|
12
|
+
def locate_and_click(
|
13
|
+
filename: str, clicks: int = 1, button: Literal["left", "right"] = "left"
|
14
|
+
):
|
15
|
+
time.sleep(LOCATE_AND_CLICK_DELAY)
|
16
|
+
locate = gui.locateOnScreen(filename, grayscale=True)
|
17
|
+
assert locate is not None, f"Could not locate {filename} on screen."
|
18
|
+
locate_center = (locate.left + locate.width // 2), (locate.top + locate.height // 2)
|
19
|
+
gui.moveTo(*locate_center, 0.6, gui.easeInOutQuad) # type: ignore
|
20
|
+
gui.click(clicks=clicks, button=button)
|
21
|
+
time.sleep(LOCATE_AND_CLICK_DELAY)
|
22
|
+
|
23
|
+
|
24
|
+
def is_valid_variable_name(name):
|
25
|
+
return name.isidentifier() and not iskeyword(name)
|
@@ -0,0 +1,200 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: pyautoscene
|
3
|
+
Version: 0.1.0
|
4
|
+
Summary: Advance GUI automation
|
5
|
+
Author-email: pritam-dey3 <pritam.pritamdey.984@gmail.com>
|
6
|
+
Requires-Python: >=3.13
|
7
|
+
Requires-Dist: networkx>=3.5
|
8
|
+
Requires-Dist: onnxruntime>=1.22.0
|
9
|
+
Requires-Dist: pillow>=11.3.0
|
10
|
+
Requires-Dist: pyautogui>=0.9.54
|
11
|
+
Requires-Dist: python-statemachine[diagrams]
|
12
|
+
Requires-Dist: rapidocr>=3.2.0
|
13
|
+
Description-Content-Type: text/markdown
|
14
|
+
|
15
|
+
# PyAutoScene
|
16
|
+
|
17
|
+
**Advanced GUI Automation with Scene-Based State Management**
|
18
|
+
|
19
|
+
PyAutoScene is a Python library that provides a declarative approach to GUI automation by modeling application interfaces as scenes and transitions. It combines element detection with state machine patterns to create robust, maintainable automation scripts.
|
20
|
+
|
21
|
+
## 🌟 Features
|
22
|
+
|
23
|
+
- **Scene-Based Architecture**: Model your application as a collection of scenes with defined elements and transitions
|
24
|
+
- **Visual Element Detection**: Supports both image-based element recognition. (Text recognition support coming soon!)
|
25
|
+
- **Automatic Navigation**: Intelligent pathfinding between scenes using graph algorithms
|
26
|
+
- **Action Decorators**: Clean, declarative syntax for defining scene actions and transitions
|
27
|
+
|
28
|
+
## 🚀 Quick Start
|
29
|
+
|
30
|
+
### Installation
|
31
|
+
|
32
|
+
```bash
|
33
|
+
pip install pyautoscene
|
34
|
+
```
|
35
|
+
|
36
|
+
### Basic Example
|
37
|
+
|
38
|
+
Here's how to automate a simple login flow:
|
39
|
+
|
40
|
+
```python
|
41
|
+
import pyautogui as gui
|
42
|
+
from pyautoscene import ReferenceImage, ReferenceText, Scene, Session
|
43
|
+
from pyautoscene.utils import locate_and_click
|
44
|
+
|
45
|
+
# Define scenes
|
46
|
+
login = Scene(
|
47
|
+
"Login",
|
48
|
+
elements=[
|
49
|
+
ReferenceText("Welcome to Login"),
|
50
|
+
ReferenceImage("references/login_button.png"),
|
51
|
+
],
|
52
|
+
initial=True,
|
53
|
+
)
|
54
|
+
|
55
|
+
dashboard = Scene(
|
56
|
+
"Dashboard",
|
57
|
+
elements=[
|
58
|
+
ReferenceText("Dashboard"),
|
59
|
+
ReferenceImage("references/user_menu.png"),
|
60
|
+
],
|
61
|
+
)
|
62
|
+
|
63
|
+
# Define actions with transitions
|
64
|
+
@login.action(transitions_to=dashboard)
|
65
|
+
def perform_login(username: str, password: str):
|
66
|
+
"""Performs login and transitions to dashboard."""
|
67
|
+
locate_and_click("references/username_field.png")
|
68
|
+
gui.write(username, interval=0.1)
|
69
|
+
gui.press("tab")
|
70
|
+
gui.write(password, interval=0.1)
|
71
|
+
gui.press("enter")
|
72
|
+
|
73
|
+
# Create session and navigate
|
74
|
+
session = Session(scenes=[login, dashboard])
|
75
|
+
session.expect(dashboard, username="user", password="pass")
|
76
|
+
```
|
77
|
+
|
78
|
+
## 📖 Core Concepts
|
79
|
+
|
80
|
+
### Scenes
|
81
|
+
|
82
|
+
A **Scene** represents a distinct state in your application's UI. Each scene contains:
|
83
|
+
|
84
|
+
- **Elements**: Visual markers that identify when the scene is active
|
85
|
+
- **Actions**: Functions that can be performed in this scene
|
86
|
+
- **Transitions**: Connections to other scenes
|
87
|
+
|
88
|
+
```python
|
89
|
+
scene = Scene(
|
90
|
+
"SceneName",
|
91
|
+
elements=[
|
92
|
+
ReferenceImage("path/to/image.png"),
|
93
|
+
ReferenceText("Expected Text"),
|
94
|
+
],
|
95
|
+
initial=False # Set to True for starting scene
|
96
|
+
)
|
97
|
+
```
|
98
|
+
|
99
|
+
### Reference Elements
|
100
|
+
|
101
|
+
PyAutoScene supports two types of reference elements:
|
102
|
+
|
103
|
+
#### ReferenceImage
|
104
|
+
|
105
|
+
Detects scenes using image matching:
|
106
|
+
|
107
|
+
```python
|
108
|
+
ReferenceImage("path/to/reference/image.png")
|
109
|
+
```
|
110
|
+
|
111
|
+
#### ReferenceText
|
112
|
+
(Coming soon)
|
113
|
+
Detects scenes using text recognition:
|
114
|
+
|
115
|
+
```python
|
116
|
+
ReferenceText("Expected text on screen")
|
117
|
+
```
|
118
|
+
|
119
|
+
### Actions and Transitions
|
120
|
+
|
121
|
+
Actions are decorated functions that define what can be done in a scene:
|
122
|
+
|
123
|
+
```python
|
124
|
+
@scene.action(transitions_to=target_scene) # Action that changes scenes
|
125
|
+
def action_with_transition():
|
126
|
+
# Perform GUI operations
|
127
|
+
pass
|
128
|
+
|
129
|
+
@scene.action() # Action that stays in current scene
|
130
|
+
def action_without_transition():
|
131
|
+
# Perform GUI operations
|
132
|
+
pass
|
133
|
+
```
|
134
|
+
|
135
|
+
### Session Management
|
136
|
+
|
137
|
+
The **Session** class manages the state machine and provides navigation:
|
138
|
+
|
139
|
+
```python
|
140
|
+
session = Session(scenes=[scene1, scene2, scene3])
|
141
|
+
|
142
|
+
# Navigate to a specific scene (finds optimal path)
|
143
|
+
session.expect(target_scene, **action_params)
|
144
|
+
|
145
|
+
# Invoke an action in the current scene
|
146
|
+
session.invoke("action_name", **action_params)
|
147
|
+
|
148
|
+
# Get current scene
|
149
|
+
current = session.current_scene
|
150
|
+
```
|
151
|
+
|
152
|
+
### Automatic Scene Detection
|
153
|
+
|
154
|
+
PyAutoScene automatically detects which scene is currently active:
|
155
|
+
|
156
|
+
```python
|
157
|
+
from pyautoscene.session import get_current_scene
|
158
|
+
|
159
|
+
current_scene = get_current_scene(scenes)
|
160
|
+
print(f"Currently on: {current_scene.name}")
|
161
|
+
```
|
162
|
+
|
163
|
+
### Path Finding
|
164
|
+
|
165
|
+
The library uses NetworkX to find optimal paths between scenes:
|
166
|
+
|
167
|
+
```python
|
168
|
+
# This will automatically navigate: Login → Dashboard → Cart
|
169
|
+
session.expect(cart_scene, username="user", password="pass")
|
170
|
+
```
|
171
|
+
|
172
|
+
### Error Handling
|
173
|
+
|
174
|
+
```python
|
175
|
+
from pyautoscene.session import SceneRecognitionError
|
176
|
+
|
177
|
+
try:
|
178
|
+
session.expect(target_scene)
|
179
|
+
except SceneRecognitionError as e:
|
180
|
+
print(f"Navigation failed: {e}")
|
181
|
+
```
|
182
|
+
|
183
|
+
## 🤝 Contributing
|
184
|
+
|
185
|
+
1. Fork the repository
|
186
|
+
2. Create a feature branch: `git checkout -b feature-name`
|
187
|
+
3. Make your changes and add tests
|
188
|
+
4. Run pre-commit hooks: `pre-commit run --all-files`
|
189
|
+
5. Submit a pull request
|
190
|
+
|
191
|
+
## 📝 License
|
192
|
+
|
193
|
+
This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
|
194
|
+
|
195
|
+
## 🔮 Roadmap
|
196
|
+
|
197
|
+
- [ ] Text recognition implementation
|
198
|
+
- [ ] Enhanced image matching algorithms
|
199
|
+
- [ ] Multiple session support
|
200
|
+
|
@@ -0,0 +1,9 @@
|
|
1
|
+
pyautoscene/__init__.py,sha256=4ppGaF-TV-r874c4Q679n6gkSflveKTFu_u-eCpG5yU,175
|
2
|
+
pyautoscene/references.py,sha256=wAKZIIrZX2gXnuHsXb-eliF6xk-taVG_ZOvWUj8QLV0,1224
|
3
|
+
pyautoscene/scene.py,sha256=Koc2hTQQvNOVRfoYvPwtEYNosb1aRuhpo-3KKOuoWfw,2063
|
4
|
+
pyautoscene/session.py,sha256=D-YKH8HJNjpqxqV9HdxZA-cbsbYf8_iMR59Tl3IRph4,4862
|
5
|
+
pyautoscene/utils.py,sha256=yc6jkaz-X_sUaOpRS9UG42UnFumhMN7648BunRp2PXM,794
|
6
|
+
pyautoscene-0.1.0.dist-info/METADATA,sha256=gHbgYywzt8pw1_uSU5YFiKK9EhRL6b73k7bH4uZnCnk,5080
|
7
|
+
pyautoscene-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
8
|
+
pyautoscene-0.1.0.dist-info/entry_points.txt,sha256=6aKjylfDivCRMrJasIIi7ICU4fZR-8HjcOHhRmHpYpQ,49
|
9
|
+
pyautoscene-0.1.0.dist-info/RECORD,,
|