pyautoscene 0.2.1__tar.gz → 0.2.3__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.
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/PKG-INFO +1 -1
- pyautoscene-0.2.3/examples/saucedemo/main.py +75 -0
- pyautoscene-0.2.3/experimental/README.md +2 -0
- pyautoscene-0.2.3/experimental/__init__.py +3 -0
- pyautoscene-0.2.3/experimental/algorithms/ilish.py +88 -0
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/pyproject.toml +8 -1
- pyautoscene-0.2.3/src/pyautoscene/__init__.py +6 -0
- pyautoscene-0.2.3/src/pyautoscene/_types.py +6 -0
- pyautoscene-0.2.3/src/pyautoscene/constants.py +6 -0
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/src/pyautoscene/ocr.py +5 -2
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/src/pyautoscene/references.py +36 -14
- pyautoscene-0.2.3/src/pyautoscene/region.py +70 -0
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/src/pyautoscene/scene.py +2 -2
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/src/pyautoscene/session.py +28 -5
- pyautoscene-0.2.3/src/pyautoscene/utils.py +148 -0
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/uv.lock +1 -1
- pyautoscene-0.2.1/examples/saucedemo/main.py +0 -68
- pyautoscene-0.2.1/src/pyautoscene/__init__.py +0 -5
- pyautoscene-0.2.1/src/pyautoscene/screen.py +0 -79
- pyautoscene-0.2.1/src/pyautoscene/utils.py +0 -25
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/.github/workflows/release.yml +0 -0
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/.gitignore +0 -0
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/.pre-commit-config.yaml +0 -0
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/.python-version +0 -0
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/LICENSE +0 -0
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/README.md +0 -0
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/examples/saucedemo/manual_flow.py +0 -0
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/examples/saucedemo/references/add_to_cart_button.png +0 -0
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/examples/saucedemo/references/backpack.png +0 -0
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/examples/saucedemo/references/cart_icon.png +0 -0
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/examples/saucedemo/references/checkout_button.png +0 -0
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/examples/saucedemo/references/login_button.png +0 -0
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/examples/saucedemo/references/username.png +0 -0
- {pyautoscene-0.2.1 → pyautoscene-0.2.3}/src/pyautoscene/ocr_config.yaml +0 -0
@@ -0,0 +1,75 @@
|
|
1
|
+
import pyautogui as gui
|
2
|
+
|
3
|
+
from pyautoscene import ImageElement, Scene, Session, TextElement
|
4
|
+
|
5
|
+
# Define all elements separately
|
6
|
+
add_to_cart = ImageElement("examples/saucedemo/references/add_to_cart_button.png")
|
7
|
+
username = ImageElement("examples/saucedemo/references/username.png")
|
8
|
+
backpack = TextElement(
|
9
|
+
"Sauce Labs Bike Light", region="x:2/2 y:(2-4)/5", case_sensitive=False
|
10
|
+
)
|
11
|
+
cart_icon = ImageElement("examples/saucedemo/references/cart_icon.png")
|
12
|
+
checkout_button = ImageElement("examples/saucedemo/references/checkout_button.png")
|
13
|
+
|
14
|
+
login = Scene(
|
15
|
+
"Login",
|
16
|
+
elements=[
|
17
|
+
TextElement("Username", region="x:2/3 y:(1-2)/3"),
|
18
|
+
TextElement("Password", region="x:2/3 y:(1-2)/3"),
|
19
|
+
# ReferenceImage("examples/saucedemo/references/login_button.png"),
|
20
|
+
],
|
21
|
+
initial=True,
|
22
|
+
)
|
23
|
+
|
24
|
+
dashboard = Scene(
|
25
|
+
"Dashboard",
|
26
|
+
elements=[
|
27
|
+
TextElement("Swag Labs", region="x:2/3 y:1/3"),
|
28
|
+
TextElement("Products", region="x:1/3 y:1/3"),
|
29
|
+
],
|
30
|
+
)
|
31
|
+
|
32
|
+
cart = Scene(
|
33
|
+
"Cart", elements=[TextElement("Your Cart", region="x:1/3 y:1/3"), cart_icon]
|
34
|
+
)
|
35
|
+
|
36
|
+
|
37
|
+
@login.action(transitions_to=dashboard)
|
38
|
+
def perform_login(username_inp: str, password_inp: str):
|
39
|
+
"""Performs the login action to transition from Login to Dashboard."""
|
40
|
+
username.locate_and_click()
|
41
|
+
gui.write(username_inp, interval=0.1)
|
42
|
+
gui.press("tab")
|
43
|
+
gui.write(password_inp, interval=0.1)
|
44
|
+
gui.press("enter")
|
45
|
+
|
46
|
+
|
47
|
+
@dashboard.action()
|
48
|
+
def add_products_to_cart(target: str):
|
49
|
+
"""Adds products to the cart."""
|
50
|
+
if target == "backpack":
|
51
|
+
backpack.locate_and_click()
|
52
|
+
else:
|
53
|
+
backpack.locate_and_click()
|
54
|
+
add_to_cart.locate_and_click(region="x:2/3 y:(2-3)/3", clicks=1)
|
55
|
+
|
56
|
+
|
57
|
+
@dashboard.action(transitions_to=cart)
|
58
|
+
def view_cart():
|
59
|
+
"""Views the cart."""
|
60
|
+
cart_icon.locate_and_click()
|
61
|
+
|
62
|
+
|
63
|
+
@cart.action()
|
64
|
+
def checkout():
|
65
|
+
"""Checks out the items in the cart."""
|
66
|
+
checkout_button.locate_and_click()
|
67
|
+
|
68
|
+
|
69
|
+
session = Session(scenes=[login, dashboard, cart])
|
70
|
+
|
71
|
+
gui.hotkey("alt", "tab")
|
72
|
+
session.expect(dashboard, username_inp="standard_user", password_inp="secret_sauce")
|
73
|
+
session.invoke("add_products_to_cart", target="backpack")
|
74
|
+
session.invoke("view_cart")
|
75
|
+
session.invoke("checkout")
|
@@ -0,0 +1,88 @@
|
|
1
|
+
from typing import Literal
|
2
|
+
|
3
|
+
import cv2
|
4
|
+
import numpy as np
|
5
|
+
from PIL import Image
|
6
|
+
|
7
|
+
from pyautoscene.region import Region
|
8
|
+
|
9
|
+
type OpenCvTransformKernel = Literal["CROSS", "RECT", "ELLIPSE"]
|
10
|
+
type OpenCvTransformMorphology = Literal[
|
11
|
+
"ERODE", "DILATE", "OPEN", "CLOSE", "GRADIENT", "TOPHAT", "BLACKHAT", "HITMISS"
|
12
|
+
]
|
13
|
+
type OpenCvThresholdAlgo = Literal[
|
14
|
+
"BINARY",
|
15
|
+
"BINARY_INV",
|
16
|
+
"TRUNC",
|
17
|
+
"TOZERO",
|
18
|
+
"TOZERO_INV",
|
19
|
+
"MASK",
|
20
|
+
"OTSU",
|
21
|
+
"TRIANGLE",
|
22
|
+
"DRYRUN",
|
23
|
+
]
|
24
|
+
type OpenCvMatchAlgo = Literal[
|
25
|
+
"SQDIFF", "SQDIFF_NORMED", "CCORR", "CCORR_NORMED", "CCOEFF", "CCOEFF_NORMED"
|
26
|
+
]
|
27
|
+
|
28
|
+
|
29
|
+
def ilish(
|
30
|
+
needle: Image.Image,
|
31
|
+
haystack: Image.Image,
|
32
|
+
*,
|
33
|
+
transform_kernel: OpenCvTransformKernel = "ELLIPSE",
|
34
|
+
transform_shape: tuple[int, int] = (53, 53),
|
35
|
+
transform_morphology: OpenCvTransformMorphology = "BLACKHAT",
|
36
|
+
threshold: int = 127,
|
37
|
+
threshold_max_value: int = 255,
|
38
|
+
threshold_algo: OpenCvThresholdAlgo = "BINARY_INV",
|
39
|
+
match_algo: OpenCvMatchAlgo = "SQDIFF",
|
40
|
+
match_mask_value: int | None = 255,
|
41
|
+
confidence: float = 0.9,
|
42
|
+
):
|
43
|
+
def transform(inp: Image.Image) -> np.ndarray:
|
44
|
+
gray = cv2.cvtColor(np.array(inp.convert("RGB")), cv2.COLOR_RGB2GRAY)
|
45
|
+
kernel = cv2.getStructuringElement(
|
46
|
+
getattr(cv2, f"MORPH_{transform_kernel}"), transform_shape
|
47
|
+
)
|
48
|
+
blackhat = cv2.morphologyEx(
|
49
|
+
gray, getattr(cv2, f"MORPH_{transform_morphology}"), kernel
|
50
|
+
)
|
51
|
+
thresh = cv2.threshold(
|
52
|
+
blackhat,
|
53
|
+
threshold,
|
54
|
+
threshold_max_value,
|
55
|
+
getattr(cv2, f"THRESH_{threshold_algo}"),
|
56
|
+
)[1]
|
57
|
+
return thresh
|
58
|
+
|
59
|
+
if haystack.size[0] < needle.size[0] or haystack.size[1] < needle.size[1]:
|
60
|
+
raise ValueError(
|
61
|
+
"needle dimension(s) exceed the haystack image or region dimensions"
|
62
|
+
)
|
63
|
+
|
64
|
+
needle_t = transform(needle)
|
65
|
+
haystack_t = transform(haystack)
|
66
|
+
result = cv2.matchTemplate(
|
67
|
+
haystack_t,
|
68
|
+
needle_t,
|
69
|
+
getattr(cv2, f"TM_{match_algo}"),
|
70
|
+
None,
|
71
|
+
(needle_t != match_mask_value).astype(np.uint8)
|
72
|
+
if match_mask_value is not None
|
73
|
+
else None,
|
74
|
+
)
|
75
|
+
|
76
|
+
if match_algo.startswith("SQDIFF"):
|
77
|
+
match_filter = result < confidence
|
78
|
+
else:
|
79
|
+
match_filter = result > confidence
|
80
|
+
|
81
|
+
match_indices = np.arange(result.size)[match_filter.flatten()]
|
82
|
+
matches = np.unravel_index(match_indices, result.shape)
|
83
|
+
|
84
|
+
match_regions = [
|
85
|
+
Region(x, y, needle.width, needle.height)
|
86
|
+
for x, y in zip(matches[1], matches[0])
|
87
|
+
]
|
88
|
+
return (match_regions, result, needle_t, haystack_t)
|
@@ -13,7 +13,7 @@ description = "Advance GUI automation"
|
|
13
13
|
name = "pyautoscene"
|
14
14
|
readme = "README.md"
|
15
15
|
requires-python = ">=3.13"
|
16
|
-
version = "0.2.
|
16
|
+
version = "0.2.3"
|
17
17
|
|
18
18
|
[project.scripts]
|
19
19
|
pyautoscene = "pyautoscene:main"
|
@@ -39,3 +39,10 @@ ocr = [
|
|
39
39
|
|
40
40
|
[tool.poe.tasks]
|
41
41
|
precmt = "pre-commit run --all-files"
|
42
|
+
|
43
|
+
[tool.ruff.format]
|
44
|
+
skip-magic-trailing-comma = true
|
45
|
+
|
46
|
+
[tool.ruff]
|
47
|
+
ignore = ["E731"]
|
48
|
+
line-length = 88
|
@@ -1,11 +1,12 @@
|
|
1
1
|
import logging
|
2
|
+
import os
|
2
3
|
from hashlib import sha256
|
3
4
|
from pathlib import Path
|
4
5
|
|
5
6
|
import numpy as np
|
6
7
|
from PIL import Image
|
7
8
|
|
8
|
-
from .
|
9
|
+
from .region import Region
|
9
10
|
|
10
11
|
logging.basicConfig(level=logging.INFO)
|
11
12
|
logger = logging.getLogger(__name__)
|
@@ -18,7 +19,9 @@ except ImportError:
|
|
18
19
|
"RapidOCR is not installed. Please install it using 'pip install pyautoscene[ocr]'."
|
19
20
|
)
|
20
21
|
|
21
|
-
|
22
|
+
default_ocr_config_path = Path(__file__).parent / "ocr_config.yaml"
|
23
|
+
ocr_config_path = Path(os.getenv("PYAUTOSCENE_OCR_CONFIG", default_ocr_config_path))
|
24
|
+
logger.info(f"OCR config path: {ocr_config_path}")
|
22
25
|
|
23
26
|
|
24
27
|
def hash_image(img: Image.Image) -> str:
|
@@ -1,19 +1,41 @@
|
|
1
1
|
from abc import ABC, abstractmethod
|
2
|
-
from typing import override
|
2
|
+
from typing import Callable, override
|
3
3
|
|
4
4
|
import pyautogui as gui
|
5
|
+
from PIL import Image
|
5
6
|
|
6
|
-
from .
|
7
|
+
from ._types import MouseButton, TowardsDirection
|
8
|
+
from .region import Region, RegionSpec
|
9
|
+
from .utils import locate_on_screen, move_and_click
|
7
10
|
|
8
11
|
|
9
12
|
class ReferenceElement(ABC):
|
10
13
|
"""Base class for reference elements used to identify scenes."""
|
11
14
|
|
12
15
|
@abstractmethod
|
13
|
-
def
|
16
|
+
def locate(self, region: RegionSpec | None = None) -> Region | None:
|
14
17
|
"""Detect the presence of the reference element."""
|
15
18
|
raise NotImplementedError("Subclasses must implement this method")
|
16
19
|
|
20
|
+
def locate_and_click(
|
21
|
+
self,
|
22
|
+
offset: tuple[int, int] = (0, 0),
|
23
|
+
region: RegionSpec | None = None,
|
24
|
+
clicks: int = 1,
|
25
|
+
button: MouseButton = "left",
|
26
|
+
towards: TowardsDirection = None,
|
27
|
+
):
|
28
|
+
"""Locate the reference element and click on it."""
|
29
|
+
region = self.locate(region=region)
|
30
|
+
assert region is not None, f"Element {self} not found on screen"
|
31
|
+
move_and_click(
|
32
|
+
target_region=region,
|
33
|
+
clicks=clicks,
|
34
|
+
button=button,
|
35
|
+
offset=offset,
|
36
|
+
towards=towards,
|
37
|
+
)
|
38
|
+
|
17
39
|
|
18
40
|
class ImageElement(ReferenceElement):
|
19
41
|
"""Reference element that identifies a scene by an image."""
|
@@ -23,13 +45,15 @@ class ImageElement(ReferenceElement):
|
|
23
45
|
path: str | list[str],
|
24
46
|
confidence: float = 0.999,
|
25
47
|
region: RegionSpec | None = None,
|
48
|
+
locator: Callable[[Image.Image, Image.Image], list[Region]] | None = None,
|
26
49
|
):
|
27
50
|
self.path = path
|
28
51
|
self.confidence = confidence
|
29
52
|
self.region = region
|
53
|
+
self.locator = locator
|
30
54
|
|
31
55
|
@override
|
32
|
-
def
|
56
|
+
def locate(self, region: RegionSpec | None = None) -> Region | None:
|
33
57
|
"""Method to detect the presence of the image in the current screen."""
|
34
58
|
if isinstance(self.path, str):
|
35
59
|
path = [self.path] # Ensure path is a list for consistency
|
@@ -38,7 +62,10 @@ class ImageElement(ReferenceElement):
|
|
38
62
|
for image_path in path:
|
39
63
|
try:
|
40
64
|
location = locate_on_screen(
|
41
|
-
image_path,
|
65
|
+
image_path,
|
66
|
+
region=region if region else self.region,
|
67
|
+
confidence=self.confidence,
|
68
|
+
locator=self.locator,
|
42
69
|
)
|
43
70
|
return location
|
44
71
|
except gui.ImageNotFoundException:
|
@@ -49,10 +76,7 @@ class TextElement(ReferenceElement):
|
|
49
76
|
"""Reference element that identifies a scene by text."""
|
50
77
|
|
51
78
|
def __init__(
|
52
|
-
self,
|
53
|
-
text: str,
|
54
|
-
region: RegionSpec | None = None,
|
55
|
-
case_sensitive: bool = False,
|
79
|
+
self, text: str, region: RegionSpec | None = None, case_sensitive: bool = False
|
56
80
|
):
|
57
81
|
self.text = text
|
58
82
|
self.region = region
|
@@ -60,19 +84,17 @@ class TextElement(ReferenceElement):
|
|
60
84
|
if not case_sensitive:
|
61
85
|
self.text = self.text.lower()
|
62
86
|
|
63
|
-
def
|
87
|
+
def locate(self, region: RegionSpec | None = None):
|
64
88
|
"""Method to detect the presence of the text in the current screen."""
|
65
89
|
from .ocr import OCR
|
66
90
|
|
67
91
|
ocr = OCR()
|
68
92
|
region = region or self.region
|
69
93
|
for text, detected_region in ocr.recognize_text(
|
70
|
-
gui.screenshot(
|
71
|
-
region=generate_region_from_spec(region).to_box() if region else None
|
72
|
-
)
|
94
|
+
gui.screenshot(region=Region.from_spec(region).to_box() if region else None)
|
73
95
|
):
|
74
96
|
if not self.case_sensitive:
|
75
97
|
text = text.lower()
|
76
98
|
if text.strip() == self.text.strip():
|
77
|
-
return detected_region
|
99
|
+
return detected_region.resolve(base=region)
|
78
100
|
return None
|
@@ -0,0 +1,70 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import re
|
4
|
+
from dataclasses import dataclass
|
5
|
+
|
6
|
+
import numpy as np
|
7
|
+
import pyautogui as gui
|
8
|
+
from pyscreeze import Box
|
9
|
+
|
10
|
+
type RegionSpec = Region | str
|
11
|
+
|
12
|
+
axis_pattern = re.compile(r"(?P<d>[xy]):\(?(?P<i>\d+)(?:-(?P<j>\d+))?\)?/(?P<n>\d+)")
|
13
|
+
|
14
|
+
|
15
|
+
@dataclass(frozen=True, slots=True)
|
16
|
+
class Region:
|
17
|
+
left: int
|
18
|
+
top: int
|
19
|
+
width: int
|
20
|
+
height: int
|
21
|
+
|
22
|
+
def to_box(self) -> Box:
|
23
|
+
"""Convert to a pyscreeze Box."""
|
24
|
+
return Box(self.left, self.top, self.width, self.height)
|
25
|
+
|
26
|
+
@classmethod
|
27
|
+
def from_box(cls, box: Box) -> Region:
|
28
|
+
"""Create a Region from a pyscreeze Box."""
|
29
|
+
return cls(left=box.left, top=box.top, width=box.width, height=box.height)
|
30
|
+
|
31
|
+
@property
|
32
|
+
def center(self) -> tuple[int, int]:
|
33
|
+
"""Get the center coordinates of the region."""
|
34
|
+
return (self.left + self.width // 2, self.top + self.height // 2)
|
35
|
+
|
36
|
+
@classmethod
|
37
|
+
def from_spec(
|
38
|
+
cls, spec: RegionSpec, shape: tuple[int, int] | None = None
|
39
|
+
) -> Region:
|
40
|
+
if isinstance(spec, Region):
|
41
|
+
return spec
|
42
|
+
if shape is None:
|
43
|
+
img = np.array(gui.screenshot())
|
44
|
+
shape = (img.shape[0]), (img.shape[1])
|
45
|
+
|
46
|
+
default_region = {"left": 0, "top": 0, "width": shape[1], "height": shape[0]}
|
47
|
+
|
48
|
+
axis_mapping = {"x": ("left", "width", 1), "y": ("top", "height", 0)}
|
49
|
+
for axis, i, j, n in axis_pattern.findall(spec):
|
50
|
+
alignment, size_attr, dim_index = axis_mapping[axis]
|
51
|
+
size = shape[dim_index] // int(n)
|
52
|
+
i, j = int(i), int(j) if j else int(i)
|
53
|
+
default_region.update({
|
54
|
+
alignment: (i - 1) * size,
|
55
|
+
size_attr: (j - i + 1) * size,
|
56
|
+
})
|
57
|
+
|
58
|
+
return cls(**default_region)
|
59
|
+
|
60
|
+
def resolve(self, base: RegionSpec | None) -> Region:
|
61
|
+
if base is None:
|
62
|
+
return self
|
63
|
+
if isinstance(base, str):
|
64
|
+
base = Region.from_spec(base)
|
65
|
+
return Region(
|
66
|
+
left=self.left + base.left,
|
67
|
+
top=self.top + base.top,
|
68
|
+
width=self.width,
|
69
|
+
height=self.height,
|
70
|
+
)
|
@@ -7,7 +7,7 @@ from statemachine import State
|
|
7
7
|
from pyautoscene.utils import is_valid_variable_name
|
8
8
|
|
9
9
|
from .references import ReferenceElement
|
10
|
-
from .
|
10
|
+
from .region import Region
|
11
11
|
|
12
12
|
|
13
13
|
class ActionInfo(TypedDict):
|
@@ -55,7 +55,7 @@ class Scene(State):
|
|
55
55
|
"""Check if any reference element is currently on screen."""
|
56
56
|
# TODO: Refactor after text recognition is implemented
|
57
57
|
# elements = (elem for elem in self.elements if isinstance(elem, ReferenceImage))
|
58
|
-
return all(elem.
|
58
|
+
return all(elem.locate(region) for elem in self.elements)
|
59
59
|
|
60
60
|
def __repr__(self):
|
61
61
|
return f"Scene({self.name!r}, elements={len(self.elements)})"
|
@@ -1,15 +1,18 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
import time
|
3
4
|
from typing import Callable
|
4
5
|
|
5
6
|
import networkx as nx
|
7
|
+
from PIL import Image
|
6
8
|
from statemachine import State, StateMachine
|
7
9
|
from statemachine.factory import StateMachineMetaclass
|
8
10
|
from statemachine.states import States
|
9
11
|
from statemachine.transition_list import TransitionList
|
10
12
|
|
13
|
+
from .references import ImageElement, ReferenceElement
|
14
|
+
from .region import Region
|
11
15
|
from .scene import Scene
|
12
|
-
from .screen import Region
|
13
16
|
|
14
17
|
|
15
18
|
class SceneRecognitionError(Exception):
|
@@ -61,9 +64,18 @@ def get_current_scene(scenes: list[Scene], region: Region | None = None) -> Scen
|
|
61
64
|
class Session:
|
62
65
|
"""A session manages the state machine for GUI automation scenes."""
|
63
66
|
|
64
|
-
def __init__(
|
67
|
+
def __init__(
|
68
|
+
self,
|
69
|
+
scenes: list[Scene],
|
70
|
+
image_locator: Callable[[Image.Image, Image.Image], list[Region]] | None = None,
|
71
|
+
):
|
65
72
|
self._scenes_list = scenes
|
66
73
|
self._scenes_dict = {scene.name: scene for scene in scenes}
|
74
|
+
self.image_locator = image_locator
|
75
|
+
for scene in self._scenes_list:
|
76
|
+
for elem in scene.elements:
|
77
|
+
if isinstance(elem, ImageElement):
|
78
|
+
elem.locator = image_locator
|
67
79
|
|
68
80
|
# Create dynamic StateMachine class and instantiate it
|
69
81
|
self._sm, self.transitions, self.leaf_actions = build_dynamic_state_machine(
|
@@ -84,9 +96,7 @@ class Session:
|
|
84
96
|
present_scene = get_current_scene(self._scenes_list)
|
85
97
|
all_paths = list(
|
86
98
|
nx.all_simple_paths(
|
87
|
-
self.graph,
|
88
|
-
source=present_scene.name,
|
89
|
-
target=target_scene.name,
|
99
|
+
self.graph, source=present_scene.name, target=target_scene.name
|
90
100
|
)
|
91
101
|
)
|
92
102
|
if len(all_paths) == 0:
|
@@ -132,6 +142,19 @@ class Session:
|
|
132
142
|
f"Action '{action_name}' not found in current scene '{self.current_scene.name}'"
|
133
143
|
)
|
134
144
|
|
145
|
+
def wait_until(self, target: Scene | ReferenceElement, interval: float = 1):
|
146
|
+
"""Wait until the target scene or reference element is on screen."""
|
147
|
+
found = False
|
148
|
+
while not found:
|
149
|
+
if isinstance(target, Scene):
|
150
|
+
found = target.is_on_screen()
|
151
|
+
elif isinstance(target, ReferenceElement):
|
152
|
+
found = target.locate() is not None
|
153
|
+
else:
|
154
|
+
raise TypeError("Target must be a Scene or ReferenceElement.")
|
155
|
+
if not found:
|
156
|
+
time.sleep(interval)
|
157
|
+
|
135
158
|
def __repr__(self):
|
136
159
|
current = self.current_scene
|
137
160
|
current_name = current.name if current else "None"
|
@@ -0,0 +1,148 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import logging
|
4
|
+
import os
|
5
|
+
import time
|
6
|
+
from keyword import iskeyword
|
7
|
+
from typing import Callable, overload
|
8
|
+
|
9
|
+
import numpy as np
|
10
|
+
import pyautogui as gui
|
11
|
+
from PIL import Image
|
12
|
+
|
13
|
+
from ._types import MouseButton, TowardsDirection
|
14
|
+
from .constants import LOCATE_AND_CLICK_DELAY, POINTER_SPEED
|
15
|
+
from .region import Region, RegionSpec
|
16
|
+
|
17
|
+
logging.basicConfig(level=logging.INFO)
|
18
|
+
logger = logging.getLogger(__name__)
|
19
|
+
|
20
|
+
|
21
|
+
def locate_and_click(
|
22
|
+
reference: Image.Image | str,
|
23
|
+
clicks: int = 1,
|
24
|
+
button: MouseButton = "left",
|
25
|
+
region: RegionSpec | None = None,
|
26
|
+
confidence: float = 0.999,
|
27
|
+
grayscale: bool = True,
|
28
|
+
limit: int = 1,
|
29
|
+
offset: tuple[int, int] = (0, 0),
|
30
|
+
towards: TowardsDirection | None = None,
|
31
|
+
):
|
32
|
+
time.sleep(LOCATE_AND_CLICK_DELAY)
|
33
|
+
found_region = locate_on_screen(
|
34
|
+
reference,
|
35
|
+
region=region,
|
36
|
+
confidence=confidence,
|
37
|
+
grayscale=grayscale,
|
38
|
+
limit=limit,
|
39
|
+
)
|
40
|
+
assert found_region is not None, f"Could not locate {reference} on screen."
|
41
|
+
move_and_click(
|
42
|
+
found_region, clicks=clicks, button=button, offset=offset, towards=towards
|
43
|
+
)
|
44
|
+
time.sleep(LOCATE_AND_CLICK_DELAY)
|
45
|
+
|
46
|
+
|
47
|
+
def move_and_click(
|
48
|
+
target_region: RegionSpec,
|
49
|
+
clicks: int = 1,
|
50
|
+
button: MouseButton = "left",
|
51
|
+
offset: tuple[int, int] = (0, 0),
|
52
|
+
towards: TowardsDirection | None = None,
|
53
|
+
):
|
54
|
+
"""Move to the center or edge of the region and click.
|
55
|
+
|
56
|
+
The offset is always added to the calculated target point.
|
57
|
+
For example, for 'bottom', offset=(0, 5) means 5 pixels below the bottom edge.
|
58
|
+
"""
|
59
|
+
_target_region = Region.from_spec(target_region)
|
60
|
+
base_points = {
|
61
|
+
"top": (_target_region.center[0], _target_region.top),
|
62
|
+
"left": (_target_region.left, _target_region.center[1]),
|
63
|
+
"bottom": (
|
64
|
+
_target_region.center[0],
|
65
|
+
_target_region.top + _target_region.height - 1,
|
66
|
+
),
|
67
|
+
"right": (
|
68
|
+
_target_region.left + _target_region.width - 1,
|
69
|
+
_target_region.center[1],
|
70
|
+
),
|
71
|
+
None: _target_region.center,
|
72
|
+
}
|
73
|
+
if towards not in base_points:
|
74
|
+
raise ValueError(f"Invalid direction: {towards}")
|
75
|
+
base = base_points[towards]
|
76
|
+
target = (base[0] + offset[0], base[1] + offset[1])
|
77
|
+
|
78
|
+
current = gui.position()
|
79
|
+
duration = np.linalg.norm(np.array(target) - np.array(current)) / POINTER_SPEED
|
80
|
+
gui.moveTo(*target, float(duration), gui.easeInOutQuad) # type: ignore
|
81
|
+
gui.click(clicks=clicks, button=button)
|
82
|
+
|
83
|
+
|
84
|
+
def is_valid_variable_name(name):
|
85
|
+
return name.isidentifier() and not iskeyword(name)
|
86
|
+
|
87
|
+
|
88
|
+
@overload
|
89
|
+
def locate_on_screen(
|
90
|
+
reference: Image.Image | str,
|
91
|
+
region: RegionSpec | None = None,
|
92
|
+
confidence: float = 0.999,
|
93
|
+
grayscale: bool = True,
|
94
|
+
limit: int = 1,
|
95
|
+
locator: Callable[[Image.Image, Image.Image], list[Region]] | None = None,
|
96
|
+
) -> Region | None: ...
|
97
|
+
@overload
|
98
|
+
def locate_on_screen(
|
99
|
+
reference: Image.Image | str,
|
100
|
+
region: RegionSpec | None = None,
|
101
|
+
confidence: float = 0.999,
|
102
|
+
grayscale: bool = True,
|
103
|
+
limit: int = 1,
|
104
|
+
locator: Callable[[Image.Image, Image.Image], list[Region]] | None = None,
|
105
|
+
) -> list[Region] | None: ...
|
106
|
+
def locate_on_screen(
|
107
|
+
reference: Image.Image | str,
|
108
|
+
region: RegionSpec | None = None,
|
109
|
+
confidence: float = 0.999,
|
110
|
+
grayscale: bool = True,
|
111
|
+
limit: int = 1,
|
112
|
+
locator: Callable[[Image.Image, Image.Image], list[Region]] | None = None,
|
113
|
+
):
|
114
|
+
"""Locate a region on the screen."""
|
115
|
+
if isinstance(reference, str):
|
116
|
+
if not os.path.exists(reference):
|
117
|
+
raise FileNotFoundError(f"Image file {reference} does not exist.")
|
118
|
+
reference = Image.open(reference)
|
119
|
+
if locator is None:
|
120
|
+
try:
|
121
|
+
location = gui.locateOnScreen(
|
122
|
+
reference,
|
123
|
+
region=Region.from_spec(region).to_box() if region else None,
|
124
|
+
grayscale=grayscale,
|
125
|
+
confidence=confidence,
|
126
|
+
limit=limit,
|
127
|
+
)
|
128
|
+
if location:
|
129
|
+
return Region.from_box(location)
|
130
|
+
except gui.ImageNotFoundException:
|
131
|
+
return None
|
132
|
+
except FileNotFoundError:
|
133
|
+
return None
|
134
|
+
else:
|
135
|
+
screenshot = gui.screenshot(
|
136
|
+
region=Region.from_spec(region).to_box() if region else None
|
137
|
+
)
|
138
|
+
logger.info(
|
139
|
+
f"Searching in region: {Region.from_spec(region).to_box() if region else None}.\nGiven region: {region}"
|
140
|
+
)
|
141
|
+
detections = locator(reference, screenshot)
|
142
|
+
logger.info("total detections: %d", len(detections))
|
143
|
+
if len(detections) == 0:
|
144
|
+
return None
|
145
|
+
elif limit > 1:
|
146
|
+
return [det.resolve(region) for det in detections[:limit]]
|
147
|
+
else:
|
148
|
+
return detections[0].resolve(region)
|
@@ -1,68 +0,0 @@
|
|
1
|
-
import pyautogui as gui
|
2
|
-
|
3
|
-
from pyautoscene import ImageElement, Scene, Session, TextElement
|
4
|
-
from pyautoscene.utils import locate_and_click
|
5
|
-
|
6
|
-
login = Scene(
|
7
|
-
"Login",
|
8
|
-
elements=[
|
9
|
-
TextElement("Username", region="x-1/3 y-(1-2)/3"),
|
10
|
-
TextElement("Password", region="x-1/3 y-(2-3)/3"),
|
11
|
-
# ReferenceImage("examples/saucedemo/references/login_button.png"),
|
12
|
-
],
|
13
|
-
initial=True,
|
14
|
-
)
|
15
|
-
|
16
|
-
dashboard = Scene(
|
17
|
-
"Dashboard",
|
18
|
-
elements=[
|
19
|
-
TextElement("Swag Labs", region="x-2/3 y-1/3"),
|
20
|
-
TextElement("Products", region="x-1/3 y-1/3"),
|
21
|
-
],
|
22
|
-
)
|
23
|
-
|
24
|
-
cart = Scene(
|
25
|
-
"Cart",
|
26
|
-
elements=[
|
27
|
-
TextElement("Your Cart", region="x-1/3 y-1/3"),
|
28
|
-
ImageElement("examples/saucedemo/references/cart_icon.png"),
|
29
|
-
],
|
30
|
-
)
|
31
|
-
|
32
|
-
|
33
|
-
@login.action(transitions_to=dashboard)
|
34
|
-
def perform_login(username: str, password: str):
|
35
|
-
"""Performs the login action to transition from Login to Dashboard."""
|
36
|
-
locate_and_click("examples/saucedemo/references/username.png")
|
37
|
-
gui.write(username, interval=0.1)
|
38
|
-
gui.press("tab")
|
39
|
-
gui.write(password, interval=0.1)
|
40
|
-
gui.press("enter")
|
41
|
-
|
42
|
-
|
43
|
-
@dashboard.action()
|
44
|
-
def add_products_to_cart(target: str):
|
45
|
-
"""Adds products to the cart."""
|
46
|
-
locate_and_click(f"examples/saucedemo/references/{target}.png")
|
47
|
-
locate_and_click("examples/saucedemo/references/add_to_cart_button.png")
|
48
|
-
|
49
|
-
|
50
|
-
@dashboard.action(transitions_to=cart)
|
51
|
-
def view_cart():
|
52
|
-
"""Views the cart."""
|
53
|
-
locate_and_click("examples/saucedemo/references/cart_icon.png")
|
54
|
-
|
55
|
-
|
56
|
-
@cart.action()
|
57
|
-
def checkout():
|
58
|
-
"""Checks out the items in the cart."""
|
59
|
-
locate_and_click("examples/saucedemo/references/checkout_button.png")
|
60
|
-
|
61
|
-
|
62
|
-
session = Session(scenes=[login, dashboard, cart])
|
63
|
-
|
64
|
-
gui.hotkey("alt", "tab")
|
65
|
-
session.expect(dashboard, username="standard_user", password="secret_sauce")
|
66
|
-
session.invoke("add_products_to_cart", target="backpack")
|
67
|
-
session.invoke("view_cart")
|
68
|
-
session.invoke("checkout")
|
@@ -1,79 +0,0 @@
|
|
1
|
-
from __future__ import annotations
|
2
|
-
|
3
|
-
import re
|
4
|
-
from dataclasses import dataclass
|
5
|
-
|
6
|
-
import numpy as np
|
7
|
-
import pyautogui as gui
|
8
|
-
from PIL import Image
|
9
|
-
from pyscreeze import Box
|
10
|
-
|
11
|
-
axis_pattern = re.compile(r"(?P<d>[xy]):\(?(?P<i>\d+)(?:-(?P<j>\d+))?\)?/(?P<n>\d+)")
|
12
|
-
|
13
|
-
|
14
|
-
@dataclass(frozen=True, slots=True)
|
15
|
-
class Region:
|
16
|
-
left: int
|
17
|
-
top: int
|
18
|
-
width: int
|
19
|
-
height: int
|
20
|
-
|
21
|
-
def to_box(self) -> Box:
|
22
|
-
"""Convert to a pyscreeze Box."""
|
23
|
-
return Box(self.left, self.top, self.width, self.height)
|
24
|
-
|
25
|
-
@classmethod
|
26
|
-
def from_box(cls, box: Box) -> Region:
|
27
|
-
"""Create a Region from a pyscreeze Box."""
|
28
|
-
return cls(left=box.left, top=box.top, width=box.width, height=box.height)
|
29
|
-
|
30
|
-
|
31
|
-
RegionSpec = Region | str
|
32
|
-
|
33
|
-
|
34
|
-
def generate_region_from_spec(
|
35
|
-
spec: RegionSpec, shape: tuple[int, int] | None = None
|
36
|
-
) -> Region:
|
37
|
-
if isinstance(spec, Region):
|
38
|
-
return spec
|
39
|
-
if shape is None:
|
40
|
-
img = np.array(gui.screenshot())
|
41
|
-
shape = (img.shape[0]), (img.shape[1])
|
42
|
-
|
43
|
-
default_region = {"left": 0, "top": 0, "width": shape[1], "height": shape[0]}
|
44
|
-
|
45
|
-
axis_mapping = {"x": ("left", "width", 1), "y": ("top", "height", 0)}
|
46
|
-
for axis, i, j, n in axis_pattern.findall(spec):
|
47
|
-
alignment, size_attr, dim_index = axis_mapping[axis]
|
48
|
-
size = shape[dim_index] // int(n)
|
49
|
-
i, j = int(i), int(j) if j else int(i)
|
50
|
-
default_region.update({
|
51
|
-
alignment: (i - 1) * size,
|
52
|
-
size_attr: (j - i + 1) * size,
|
53
|
-
})
|
54
|
-
|
55
|
-
return Region(**default_region)
|
56
|
-
|
57
|
-
|
58
|
-
def locate_on_screen(
|
59
|
-
reference: Image.Image | str,
|
60
|
-
region: RegionSpec | None = None,
|
61
|
-
confidence: float = 0.999,
|
62
|
-
grayscale: bool = True,
|
63
|
-
limit: int = 1,
|
64
|
-
) -> Region | None:
|
65
|
-
"""Locate a region on the screen."""
|
66
|
-
try:
|
67
|
-
location = gui.locateOnScreen(
|
68
|
-
reference,
|
69
|
-
region=generate_region_from_spec(region).to_box() if region else None,
|
70
|
-
grayscale=grayscale,
|
71
|
-
confidence=confidence,
|
72
|
-
limit=limit,
|
73
|
-
)
|
74
|
-
if location:
|
75
|
-
return Region.from_box(location)
|
76
|
-
except gui.ImageNotFoundException:
|
77
|
-
return None
|
78
|
-
except FileNotFoundError:
|
79
|
-
return None
|
@@ -1,25 +0,0 @@
|
|
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)
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{pyautoscene-0.2.1 → pyautoscene-0.2.3}/examples/saucedemo/references/add_to_cart_button.png
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|