Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ on:
branches:
- main
- master
pull_request:
branches:
- main
- master

jobs:
build:
Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Apparser is a Python library for automating desktop applications and interacting

# Installation
```bash
# Base Apparser package
# Base Apparser package with base ocr model
pip install apparser

# Apparser with text recognition support
Expand All @@ -31,13 +31,17 @@ pip install "apparser[all]"
```

# Examples
> Disclaimer: This example is provided for educational purposes only to demonstrate UI automation concepts.
> Do not use it with Counter-Strike 2, Steam, multiplayer games, VAC-protected games, ranking/progression systems,
> or any application/service where automation is prohibited by its terms of service.
> This project is not affiliated with, endorsed by, or sponsored by Valve, Steam, or Counter-Strike 2.

1) Open CS2 and start a game
#### Code
```python
from apparser import App
from apparser.instructions import OCRAlgorithm
from apparser.instructions.ocr import WaitText, ClickOnText
from apparser.text_readers import ScreensController, RapidOcrReader

# Text labels that the OCR algorithm will look for on the screen.
play_button = "play"
Expand All @@ -56,8 +60,8 @@ algorithm = OCRAlgorithm([
# Select the hostage group and start the match.
WaitText(group_button),
ClickOnText(group_button),
ClickOnText(start_button, min_similarity=0.5),
], text_reader=ScreensController(RapidOcrReader()))
ClickOnText(start_button, min_similarity=0.5)
])

# Launch CS2
app = App(['cmd', '/c', 'start', 'steam://rungameid/730'], timeout=20)
Expand All @@ -67,7 +71,7 @@ algorithm.perform(app.ui)
```
#### Video

<img src="./example.gif" alt="" width="100%"/>
<img src="https://github.com/apparser-development/apparser/blob/master/example.gif?raw=true" alt="" width="100%"/>

# Docs
Full documentation is available <a href="https://apparser-development.github.io/apparser/">here</a> <br>
Expand Down
4 changes: 2 additions & 2 deletions apparser/core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ def __init__(self, start_command: str | list[str],
"""Initialize an application controller.

:param start_command: App start command.
:type start_command: str
:type start_command: str | list[str]
:param window_title: Title of the window to attach to.
:type window_title: str
:type window_title: str | None
:param timeout: Delay before the window lookup starts.
:type timeout: float
:raises TypeError: If any argument has an invalid type.
Expand Down
4 changes: 2 additions & 2 deletions apparser/core/ui/coordinates.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ def __init__(
:type from_ui: BaseUi
:param point_one: First point of the nested region.
:type point_one: Point | RelativelyPoint
:param point_two: Second point of the nested region or region size.
:type point_two: Point | RelativelyPoint | Size
:param point_two: Second point of the nested region.
:type point_two: Point | RelativelyPoint
:raises TypeError: If any argument has an invalid type.
"""
if not isinstance(from_ui, BaseUi):
Expand Down
4 changes: 2 additions & 2 deletions apparser/cv/events/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from apparser.cv.events.moved import Moved
from apparser.cv.events.detected import Detected
from apparser.cv.events.resized import Resized
from apparser.cv.events.undetected import UnDetected
from apparser.cv.events.undetected import Undetected

__all__ = ["CvEvent", "Moved",
"Detected", "Resized", "UnDetected"]
"Detected", "Resized", "Undetected"]
2 changes: 1 addition & 1 deletion apparser/cv/events/undetected.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from apparser.cv.events.base import CvEvent


class UnDetected(CvEvent):
class Undetected(CvEvent):
"""Represent a previously tracked object that disappeared."""

def __str__(self) -> str:
Expand Down
26 changes: 24 additions & 2 deletions apparser/cv/handlers/default.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Callable, Type, Optional, Any
from types import UnionType
from typing import Callable, Type, Optional, Any, Union, get_args, get_origin
import inspect

from apparser.core import BaseUi
Expand All @@ -7,6 +8,27 @@
from apparser.cv.models import CvAllData, CvHandler, CvChangeData


def _is_annotation_matches(annotation: Any, value: Any) -> bool:
if annotation is inspect.Parameter.empty:
return False

if annotation is Any:
return True

annotation_origin = get_origin(annotation)
annotation_args = get_args(annotation)
if annotation_origin in (Union, UnionType):
return any(_is_annotation_matches(i, value) for i in annotation_args)

if isinstance(annotation_origin, type):
return isinstance(value, annotation_origin)

if isinstance(annotation, type):
return isinstance(value, annotation)

return annotation is type(value)


def _form_args(function: Callable, *args) -> dict[str, Any]:
"""Build keyword arguments matching the annotated handler signature.

Expand All @@ -20,7 +42,7 @@ def _form_args(function: Callable, *args) -> dict[str, Any]:
function_signature = inspect.signature(function)
for arg in function_signature.parameters.values():
for a in args:
if arg.annotation is type(a):
if _is_annotation_matches(arg.annotation, a):
result[arg.name] = a
return result

Expand Down
12 changes: 6 additions & 6 deletions apparser/cv/readers/yolo.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,12 @@ def read(self, ui: BaseUi) -> CvAllData:
track_id = int(track_id.item())
cls_name = names[class_index]
x1, y1, x2, y2 = box.xyxy[0].tolist()
x = int(x1)
y = int(y1)
x2 = int(x2)
y2 = int(y2)
width = x2 - x1
height = y2 - y1
x = round(x1)
y = round(y1)
x2 = round(x2)
y2 = round(y2)
width = x2 - x
height = y2 - y
box_ui = CoordinatesUi(ui, Point(x, y), Point(x2, y2))
boxes.append(
CvBox(
Expand Down
22 changes: 2 additions & 20 deletions apparser/cv/utils/changes_checker.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,12 @@
from apparser.cv.models import CvAllData, CvChangeData, CvBox
from apparser.cv.events import Detected, UnDetected, Moved, Resized
from apparser.cv.events import Detected, Undetected, Moved, Resized


def _is_moved(box: CvBox, old_box: CvBox) -> bool:
"""Check whether a box position changed.

:param box: Current box state.
:type box: CvBox
:param old_box: Previous box state.
:type old_box: CvBox
:return: True if the box coordinates changed.
:rtype: bool
"""
return abs(box.x - old_box.x) > 0 or abs(box.y - old_box.y) > 0


def _is_resized(box: CvBox, old_box: CvBox) -> bool:
"""Check whether both box dimensions changed.

:param box: Current box state.
:type box: CvBox
:param old_box: Previous box state.
:type old_box: CvBox
:return: True if width and height both changed.
:rtype: bool
"""
return abs(box.width - old_box.width) > 0 or abs(box.height - old_box.height) > 0


Expand Down Expand Up @@ -59,7 +41,7 @@ def __get_undetected(self, current_data: CvAllData) -> list[CvChangeData]:
:rtype: list[CvChangeData]
"""
new_ids = [i.track_id for i in current_data.boxes if i.track_id is not None]
return [CvChangeData(UnDetected, i, i) for i in self.__old_data.boxes if
return [CvChangeData(Undetected, i, i) for i in self.__old_data.boxes if
i.track_id not in new_ids and i.track_id is not None]

def check(self, data: CvAllData) -> list[CvChangeData]:
Expand Down
2 changes: 1 addition & 1 deletion apparser/instructions/default/click.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def __init__(self, click_type: BaseKeyCode = LeftClick()):

:param click_type: Mouse button to click.
:type click_type: BaseKeyCode
:raises TypeError: If ``click_type`` is neither :class:`BaseKeyCode`.
:raises TypeError: If ``click_type`` is not a :class:`BaseKeyCode`.
"""
if not isinstance(click_type, BaseKeyCode):
raise TypeError('click_type must be BaseKeyCode')
Expand Down
5 changes: 3 additions & 2 deletions apparser/instructions/default/press.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,17 @@ def perform(self, *args, **kwargs):
class PressKeysCombination(BaseInstruction):
"""Send a keyboard shortcut as a pressed combination."""

def __init__(self, keys: list[BaseKeyCode | str]):
def __init__(self, keys: list[BaseKeyCode | str] | str):
"""Initialize a key combination instruction.

:param keys: Keys to press together.
:type keys: list[BaseKeyCode | str]
:type keys: list[BaseKeyCode | str] | str
"""
self.__keys = keys
self.__validate()

def __validate(self):

for key in self.__keys:
if not (isinstance(key, BaseKeyCode) or isinstance(key, str)):
raise TypeError('key_code must be BaseKeyCode or str')
Expand Down
4 changes: 4 additions & 0 deletions apparser/instructions/default/sleep.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ def __init__(self, sleep_time: float):
:param sleep_time: Delay duration in seconds.
:type sleep_time: float
:raises ValueError: If ``sleep_time`` is not greater than zero.
:raises TypeError: If ``sleep_time`` is not a number.
"""
if not isinstance(sleep_time, float) and not isinstance(sleep_time, int):
raise TypeError("sleep_time must be a number.")

if sleep_time <= 0:
raise ValueError("sleep_time must be > 0")

Expand Down
8 changes: 4 additions & 4 deletions apparser/instructions/ocr/click_on_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def __init__(self, text: str,
min_similarity: float = 0.8,
offset: Point | RelativelyPoint = Point(0, 0),
text_getter: GetText | None = None,
sleep_time_before_move: float = 0.1):
sleep_time_after_move: float = 0.1):
"""Initialize a text click instruction.

:param text: Text to locate before clicking.
Expand All @@ -31,16 +31,16 @@ def __init__(self, text: str,
:type offset: Point | RelativelyPoint
:param text_getter: Instruction used to extract text from the screen. If None use GetText()
:type text_getter: GetText | None
:param sleep_time_before_move: Delay before the click is performed.
:type sleep_time_before_move: float
:param sleep_time_after_move: Delay before the click is performed.
:type sleep_time_after_move: float
"""

if text_getter is None:
text_getter = GetText()

self.__mouse_mover = MoveToText(text, min_similarity, offset, text_getter)
self.__click_type = click_type
self.__sleep = Sleep(sleep_time_before_move)
self.__sleep = Sleep(sleep_time_after_move)

@property
def id(self) -> int:
Expand Down
4 changes: 2 additions & 2 deletions apparser/instructions/ocr/plot_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from apparser.core import BaseUi
from apparser.geometry import Point

from apparser.text_readers import BaseTextReader, TextData\
from apparser.text_readers import BaseTextReader, TextData

from apparser.instructions.ocr.base import OCRInstruction
from apparser.instructions.ocr.text_getter import GetText
Expand Down Expand Up @@ -41,7 +41,7 @@ def __paint_cords(self, data: TextData):
if y < 0:
y = data.coordinates.right_bottom.y - self.__text_move.y
x = data.coordinates.left_top.x + self.__text_move.x
if y < 0:
if x < 0:
x = data.coordinates.right_bottom.x - self.__text_move.x
self.__draw.text((x, y), data.text, fill=self.__color)

Expand Down
30 changes: 26 additions & 4 deletions apparser/instructions/ui/algorithms/ids.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import inspect
from typing import Any
from types import UnionType
from typing import Any, Union, get_args, get_origin

from apparser.core import BaseUi
from apparser.instructions import BaseInstruction
Expand All @@ -22,6 +23,27 @@ def _check_instruction(instruction: tuple[int, list[Any]]) -> tuple[int, list[An
return instruction_id, instruction_args


def _is_annotation_matches(annotation: Any, value: Any) -> bool:
if annotation is inspect.Parameter.empty:
return False

if annotation is Any:
return True

annotation_origin = get_origin(annotation)
annotation_args = get_args(annotation)
if annotation_origin in (Union, UnionType):
return any(_is_annotation_matches(i, value) for i in annotation_args)

if isinstance(annotation_origin, type):
return isinstance(value, annotation_origin)

if isinstance(annotation, type):
return isinstance(value, annotation)

return annotation is type(value)


class IdsAlgorithm(BaseAlgorithm):
"""Resolve and execute instructions by their numeric identifiers."""

Expand All @@ -44,7 +66,7 @@ def __init__(self,
raise TypeError("attributes must be list")

if not isinstance(instructions, list):
raise TypeError("attributes must be list")
raise TypeError("instructions must be list")

if not isinstance(debugger, BaseDebugger) and not isinstance(debugger, bool):
raise TypeError(f"debugger must be a bool or BaseDebugger")
Expand All @@ -68,7 +90,7 @@ def __form_args(self, instruction: BaseInstruction, *additional_args) -> dict[st
function_signature = inspect.signature(instruction.perform)
for arg in function_signature.parameters.values():
for a in self.__attributes + list(additional_args):
if arg.annotation is type(a):
if _is_annotation_matches(arg.annotation, a):
result[arg.name] = a
return result

Expand All @@ -91,7 +113,7 @@ def perform(self, ui: BaseUi, *args, **kwargs):
if self.__debugger is not None:
self.__debugger.try_perform(instruction, **perform_kwargs)
else:
instruction.perform(ui, **perform_kwargs)
instruction.perform(**perform_kwargs)

def add_instruction(self, instruction: tuple[int, list[Any]]):
_check_instruction(instruction)
Expand Down
Loading
Loading