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.
Files changed (34) hide show
  1. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/PKG-INFO +1 -1
  2. pyautoscene-0.2.3/examples/saucedemo/main.py +75 -0
  3. pyautoscene-0.2.3/experimental/README.md +2 -0
  4. pyautoscene-0.2.3/experimental/__init__.py +3 -0
  5. pyautoscene-0.2.3/experimental/algorithms/ilish.py +88 -0
  6. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/pyproject.toml +8 -1
  7. pyautoscene-0.2.3/src/pyautoscene/__init__.py +6 -0
  8. pyautoscene-0.2.3/src/pyautoscene/_types.py +6 -0
  9. pyautoscene-0.2.3/src/pyautoscene/constants.py +6 -0
  10. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/src/pyautoscene/ocr.py +5 -2
  11. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/src/pyautoscene/references.py +36 -14
  12. pyautoscene-0.2.3/src/pyautoscene/region.py +70 -0
  13. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/src/pyautoscene/scene.py +2 -2
  14. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/src/pyautoscene/session.py +28 -5
  15. pyautoscene-0.2.3/src/pyautoscene/utils.py +148 -0
  16. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/uv.lock +1 -1
  17. pyautoscene-0.2.1/examples/saucedemo/main.py +0 -68
  18. pyautoscene-0.2.1/src/pyautoscene/__init__.py +0 -5
  19. pyautoscene-0.2.1/src/pyautoscene/screen.py +0 -79
  20. pyautoscene-0.2.1/src/pyautoscene/utils.py +0 -25
  21. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/.github/workflows/release.yml +0 -0
  22. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/.gitignore +0 -0
  23. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/.pre-commit-config.yaml +0 -0
  24. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/.python-version +0 -0
  25. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/LICENSE +0 -0
  26. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/README.md +0 -0
  27. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/examples/saucedemo/manual_flow.py +0 -0
  28. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/examples/saucedemo/references/add_to_cart_button.png +0 -0
  29. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/examples/saucedemo/references/backpack.png +0 -0
  30. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/examples/saucedemo/references/cart_icon.png +0 -0
  31. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/examples/saucedemo/references/checkout_button.png +0 -0
  32. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/examples/saucedemo/references/login_button.png +0 -0
  33. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/examples/saucedemo/references/username.png +0 -0
  34. {pyautoscene-0.2.1 → pyautoscene-0.2.3}/src/pyautoscene/ocr_config.yaml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyautoscene
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Advance GUI automation
5
5
  Author-email: pritam-dey3 <pritam.pritamdey.984@gmail.com>
6
6
  License-File: LICENSE
@@ -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,2 @@
1
+ # Experimental Algorithms
2
+ This directory contains experimental algorithms and features that are not yet part of the main library.
@@ -0,0 +1,3 @@
1
+ from .algorithms.ilish import ilish
2
+
3
+ __all__ = ["ilish"]
@@ -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.1"
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
@@ -0,0 +1,6 @@
1
+ from .references import ImageElement, TextElement
2
+ from .region import Region, RegionSpec
3
+ from .scene import Scene
4
+ from .session import Session
5
+
6
+ __all__ = ["Scene", "Session", "ImageElement", "TextElement", "RegionSpec", "Region"]
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ type MouseButton = Literal["left", "right"]
6
+ type TowardsDirection = Literal["top", "left", "bottom", "right", None]
@@ -0,0 +1,6 @@
1
+ import os
2
+
3
+ LOCATE_AND_CLICK_DELAY = float(os.getenv("PYAUTOSCENE_LOCATE_AND_CLICK_DELAY", 0.3))
4
+
5
+ # pixels per second, used for calculating move duration
6
+ POINTER_SPEED = int(os.getenv("PYAUTOSCENE_POINTER_SPEED", 1000))
@@ -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 .screen import Region
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
- ocr_config_path = Path(__file__).parent / "ocr_config.yaml"
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 .screen import RegionSpec, generate_region_from_spec, locate_on_screen
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 is_visible(self, region: RegionSpec | None = None) -> RegionSpec | None:
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 is_visible(self, region: RegionSpec | None = None):
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, region=region or self.region, confidence=self.confidence
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 is_visible(self, region: RegionSpec | None = None):
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 .screen import Region
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.is_visible(region) for elem in self.elements)
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__(self, scenes: list[Scene]):
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)
@@ -398,7 +398,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/65/ff/cdae0a8c2118a0de7
398
398
 
399
399
  [[package]]
400
400
  name = "pyautoscene"
401
- version = "0.2.1"
401
+ version = "0.2.3"
402
402
  source = { editable = "." }
403
403
  dependencies = [
404
404
  { name = "networkx" },
@@ -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,5 +0,0 @@
1
- from .references import ImageElement, TextElement
2
- from .scene import Scene
3
- from .session import Session
4
-
5
- __all__ = ["Scene", "Session", "ImageElement", "TextElement"]
@@ -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