From bcbc8a2ad71d8c081532bb25bf2a593a074fb717 Mon Sep 17 00:00:00 2001 From: Kimberly Lara Date: Mon, 17 Jun 2024 17:22:28 +0800 Subject: [PATCH 1/2] Add type hint annotations for the complete project These are based on mypy validation for a standard set of accepted checks (but not all). The corresponding mypy config file is also included in the changes. --- guibot/calibrator.py | 96 ++++------ guibot/config.py | 139 ++++++++------ guibot/controller.py | 228 +++++++++++----------- guibot/errors.py | 6 +- guibot/fileresolver.py | 32 ++-- guibot/finder.py | 360 ++++++++++++++++++----------------- guibot/guibot.py | 14 +- guibot/guibot_proxy.py | 85 +++++---- guibot/guibot_simple.py | 77 ++++---- guibot/imagelogger.py | 31 ++- guibot/inputmap.py | 214 ++++++++++----------- guibot/location.py | 14 +- guibot/match.py | 52 +++-- guibot/region.py | 339 ++++++++++++++------------------- guibot/target.py | 152 +++++++-------- mypy.ini | 19 ++ tests/qt5_application.py | 36 ++-- tests/qt5_image.py | 2 +- tests/test_calibrator.py | 38 ++-- tests/test_config.py | 2 +- tests/test_controller.py | 35 ++-- tests/test_fileresolver.py | 30 +-- tests/test_finder.py | 94 ++++----- tests/test_imagelogger.py | 18 +- tests/test_interfaces.py | 12 +- tests/test_region_calc.py | 20 +- tests/test_region_control.py | 65 +++---- tests/test_region_expect.py | 51 ++--- tests/test_target.py | 62 +++--- 29 files changed, 1142 insertions(+), 1181 deletions(-) create mode 100644 mypy.ini diff --git a/guibot/calibrator.py b/guibot/calibrator.py index 67ff5f81..60181153 100644 --- a/guibot/calibrator.py +++ b/guibot/calibrator.py @@ -28,11 +28,13 @@ import time import math import copy +from typing import Generator from .finder import * -from .target import Target +from .target import Target, Image from .imagelogger import ImageLogger from .errors import * +from .location import Location import logging log = logging.getLogger('guibot.calibrator') @@ -56,14 +58,14 @@ class Calibrator(object): multiple random starts from a uniform or normal probability distribution. """ - def __init__(self, needle=None, haystack=None, config=None): + def __init__(self, needle: Target = None, haystack: Image = None, + config: str = None) -> None: """ Build a calibrator object for a given match case. - :param haystack: image to look in - :type haystack: :py:class:`target.Image` or None :param needle: target to look for - :type needle: :py:class:`target.Target` or None + :param haystack: image to look in + :param config: config file for calibration """ self.cases = [] if needle is not None and haystack is not None: @@ -86,21 +88,20 @@ def __init__(self, needle=None, haystack=None, config=None): # this attribute can be changed to use different run function self.run = self.run_default - def benchmark(self, finder, random_starts=0, uniform=False, - calibration=False, max_attempts=3, **kwargs): + def benchmark(self, finder: Finder, random_starts: int = 0, uniform: bool = False, + calibration: bool = False, max_attempts: int = 3, + **kwargs: dict[str, type]) -> list[tuple[str, float, float]]: """ Perform benchmarking on all available algorithms of a finder for a given needle and haystack. :param finder: CV backend whose backend algorithms will be benchmarked - :type finder: :py:class:`finder.Finder` - :param int random_starts: number of random starts to try with (0 for nonrandom) - :param bool uniform: whether to use uniform or normal distribution - :param bool calibration: whether to use calibration - :param int max_attempts: maximal number of refinements to reach - the parameter delta below the tolerance - :returns: list of (method, similarity, location, time) tuples sorted according to similarity - :rtype: [(str, float, :py:class:`location.Location`, float)] + :param random_starts: number of random starts to try with (0 for nonrandom) + :param uniform: whether to use uniform or normal distribution + :param calibration: whether to use calibration + :param max_attempts: maximal number of refinements to reach + the parameter delta below the tolerance + :returns: list of (method, similarity, time) tuples sorted according to similarity .. note:: Methods that are supported by OpenCV and others but currently don't work are excluded from the dictionary. The dictionary can thus also be used to @@ -120,7 +121,7 @@ def benchmark(self, finder, random_starts=0, uniform=False, ordered_categories.remove("find") # test all matching methods of the current finder - def backend_tuples(category_list, finder): + def backend_tuples(category_list: list[str], finder: Finder) -> Generator[tuple[str, ...], None, None]: if len(category_list) == 0: yield () else: @@ -159,22 +160,20 @@ def backend_tuples(category_list, finder): ImageLogger.accumulate_logging = False return sorted(results, key=lambda x: x[1], reverse=True) - def search(self, finder, random_starts=1, uniform=False, - calibration=True, max_attempts=3, **kwargs): + def search(self, finder: Finder, random_starts: int = 1, uniform: bool = False, + calibration: bool = True, max_attempts: int = 3, **kwargs: dict[str, type]) -> float: """ Search for the best match configuration for a given needle and haystack using calibration from random initial conditions. :param finder: CV backend to use in order to determine deltas, fixed, and free parameters and ultimately tweak to minimize error - :type finder: :py:class:`finder.Finder` - :param int random_starts: number of random starts to try with - :param bool uniform: whether to use uniform or normal distribution - :param bool calibration: whether to use calibration - :param int max_attempts: maximal number of refinements to reach - the parameter delta below the tolerance + :param random_starts: number of random starts to try with + :param uniform: whether to use uniform or normal distribution + :param calibration: whether to use calibration + :param max_attempts: maximal number of refinements to reach + the parameter delta below the tolerance :returns: maximized similarity - :rtype: float If normal distribution is used, the mean will be the current value of the respective CV parameter and the standard variation will be determined from @@ -225,17 +224,15 @@ def search(self, finder, random_starts=1, uniform=False, category, key, param.value, param.delta) return 1.0 - best_error - def calibrate(self, finder, max_attempts=3, **kwargs): + def calibrate(self, finder: Finder, max_attempts: int = 3, **kwargs: dict[str, type]) -> float: """ Calibrate the available match configuration for a given needle and haystack minimizing the matchign error. :param finder: configuration for the CV backend to calibrate - :type finder: :py:class:`finder.Finder` - :param int max_attempts: maximal number of refinements to reach - the parameter delta below the tolerance + :param max_attempts: maximal number of refinements to reach + the parameter delta below the tolerance :returns: maximized similarity - :rtype: float This method calibrates only parameters that are not protected from calibration, i.e. that have `fixed` attribute set to false. @@ -291,17 +288,17 @@ def calibrate(self, finder, max_attempts=3, **kwargs): # add the delta to the current parameter if isinstance(param.value, float): if param.range[1] is not None: - param.value = min(start_value + param.delta, + param.value = min(float(start_value) + param.delta, param.range[1]) else: - param.value = start_value + param.delta + param.value = float(start_value) + param.delta elif isinstance(param.value, int) and not param.enumerated: intdelta = int(math.ceil(param.delta)) if param.range[1] is not None: - param.value = min(start_value + intdelta, + param.value = min(int(start_value) + intdelta, param.range[1]) else: - param.value = start_value + intdelta + param.value = int(start_value) + intdelta # remaining types require special handling elif isinstance(param.value, int) and param.enumerated: delta_coeff = 0.9 @@ -339,17 +336,17 @@ def calibrate(self, finder, max_attempts=3, **kwargs): if isinstance(param.value, float): if param.range[0] is not None: - param.value = max(start_value - param.delta, + param.value = max(float(start_value) - param.delta, param.range[0]) else: - param.value = start_value - param.delta + param.value = float(start_value) - param.delta elif isinstance(param.value, int): intdelta = int(math.floor(param.delta)) if param.range[0] is not None: - param.value = max(start_value - intdelta, + param.value = max(int(start_value) - intdelta, param.range[0]) else: - param.value = start_value - intdelta + param.value = int(start_value) - intdelta elif isinstance(param.value, bool): # the default boolean value was already checked param.value = start_value @@ -388,14 +385,12 @@ def calibrate(self, finder, max_attempts=3, **kwargs): category, key, param.value, param.delta) return 1.0 - best_error - def run_default(self, finder, **_kwargs): + def run_default(self, finder: Finder, **_kwargs: dict[str, type]) -> float: """ Run a match case and return error from the match as dissimilarity. :param finder: finder with match configuration to use for the run - :type finder: :py:class:`finder.Finder` :returns: error obtained as unity minus similarity - :rtype: float """ self._handle_restricted_values(finder) @@ -414,20 +409,16 @@ def run_default(self, finder, **_kwargs): error = 1.0 - total_similarity / len(self.cases) return error - def run_performance(self, finder, **kwargs): + def run_performance(self, finder: Finder, **kwargs: dict[str, type]) -> float: """ Run a match case and return error from the match as dissimilarity and linear performance penalty. :param finder: finder with match configuration to use for the run - :type finder: :py:class:`finder.Finder` - :param float max_exec_time: maximum execution time before penalizing - the run by increasing the error linearly :returns: error obtained as unity minus similarity - :rtype: float """ self._handle_restricted_values(finder) - max_exec_time = kwargs.get("max_exec_time", 1.0) + max_exec_time: float = kwargs.get("max_exec_time", 1.0) total_similarity = 0.0 for needle, haystack, maximize in self.cases: @@ -449,18 +440,13 @@ def run_performance(self, finder, **kwargs): error += max(total_time - max_exec_time, 0) return error - def run_peak(self, finder, **kwargs): + def run_peak(self, finder: Finder, **kwargs: dict[str, type]) -> float: """ Run a match case and return error from the match as failure to obtain high similarity of one match and low similarity of all others. :param finder: finder with match configuration to use for the run - :type finder: :py:class:`finder.Finder` - :param peak_location: (x, y) of the match whose similarity should be - maximized while all the rest minimized - :type peak_location: (int, int) :returns: error obtained as unity minus similarity - :rtype: float This run function doesn't just obtain the optimum similarity for the best match in each case of needle and haystack but it minimizes the similarity @@ -495,7 +481,7 @@ def run_peak(self, finder, **kwargs): error = 1.0 - total_similarity / len(self.cases) return error - def _handle_restricted_values(self, finder): + def _handle_restricted_values(self, finder: Finder) -> None: if "threshold" in finder.params: params = finder.params["threshold"] if params["blurKernelSize"].value % 2 == 0: @@ -524,7 +510,7 @@ def _handle_restricted_values(self, finder): diffs = {m: abs(m - params["dt_mask_size"].value) for m in [0, 3, 5]} params["dt_mask_size"].value = min(diffs, key=diffs.get) - def _prepare_params(self, finder): + def _prepare_params(self, finder: Finder) -> None: # any similarity parameters will be reset to 0.0 to search optimally finder.params["find"]["similarity"].value = 0.0 finder.params["find"]["similarity"].fixed = True diff --git a/guibot/config.py b/guibot/config.py index e1ad95d6..fb85002a 100644 --- a/guibot/config.py +++ b/guibot/config.py @@ -26,6 +26,7 @@ """ import logging +from typing import Any from .errors import * @@ -78,23 +79,22 @@ class GlobalConfig(type): _deep_learn_backend = "pytorch" _hybrid_match_backend = "template" - def toggle_delay(self, value=None): + def toggle_delay(self, value: float = None) -> float | None: """ Getter/setter for property attribute. :param value: time interval between mouse down and up in a click - :type value: float or None :returns: current value if no argument was passed otherwise only sets it - :rtype: float or None """ if value is None: return GlobalConfig._toggle_delay else: GlobalConfig._toggle_delay = value + return None #: time interval between mouse down and up in a click toggle_delay = property(fget=toggle_delay, fset=toggle_delay) - def click_delay(self, value=None): + def click_delay(self, value: float = None) -> float | None: """ Same as :py:func:`GlobalConfig.toggle_delay` but with @@ -104,10 +104,11 @@ def click_delay(self, value=None): return GlobalConfig._click_delay else: GlobalConfig._click_delay = value + return None #: time interval after a click (in a double or n-click) click_delay = property(fget=click_delay, fset=click_delay) - def delay_after_drag(self, value=None): + def delay_after_drag(self, value: float = None) -> float | None: """ Same as :py:func:`GlobalConfig.toggle_delay` but with @@ -117,10 +118,11 @@ def delay_after_drag(self, value=None): return GlobalConfig._drag_delay else: GlobalConfig._drag_delay = value + return None #: timeout before drag operation delay_after_drag = property(fget=delay_after_drag, fset=delay_after_drag) - def delay_before_drop(self, value=None): + def delay_before_drop(self, value: float = None) -> float | None: """ Same as :py:func:`GlobalConfig.toggle_delay` but with @@ -130,10 +132,11 @@ def delay_before_drop(self, value=None): return GlobalConfig._drop_delay else: GlobalConfig._drop_delay = value + return None #: timeout before drop operation delay_before_drop = property(fget=delay_before_drop, fset=delay_before_drop) - def delay_before_keys(self, value=None): + def delay_before_keys(self, value: float = None) -> float | None: """ Same as :py:func:`GlobalConfig.toggle_delay` but with @@ -143,10 +146,11 @@ def delay_before_keys(self, value=None): return GlobalConfig._keys_delay else: GlobalConfig._keys_delay = value + return None #: timeout before key press operation delay_before_keys = property(fget=delay_before_keys, fset=delay_before_keys) - def delay_between_keys(self, value=None): + def delay_between_keys(self, value: float = None) -> float | None: """ Same as :py:func:`GlobalConfig.toggle_delay` but with @@ -156,10 +160,11 @@ def delay_between_keys(self, value=None): return GlobalConfig._type_delay else: GlobalConfig._type_delay = value + return None #: time interval between two consecutively typed keys delay_between_keys = property(fget=delay_between_keys, fset=delay_between_keys) - def rescan_speed_on_find(self, value=None): + def rescan_speed_on_find(self, value: float = None) -> float | None: """ Same as :py:func:`GlobalConfig.toggle_delay` but with @@ -170,17 +175,16 @@ def rescan_speed_on_find(self, value=None): return GlobalConfig._rescan_speed_on_find else: GlobalConfig._rescan_speed_on_find = value + return None #: time interval between two image matching attempts (used to reduce overhead on the CPU) rescan_speed_on_find = property(fget=rescan_speed_on_find, fset=rescan_speed_on_find) - def wait_for_animations(self, value=None): + def wait_for_animations(self, value: bool = None) -> bool | None: """ Getter/setter for property attribute. :param value: whether to wait for animations to complete and match only static (not moving) targets - :type value: bool or None :returns: current value if no argument was passed otherwise only sets it - :rtype: bool or None :raises: :py:class:`ValueError` if value is not boolean or None This is useful to handle highly animated environments with lots of moving @@ -191,19 +195,18 @@ def wait_for_animations(self, value=None): return GlobalConfig._wait_for_animations elif value is True or value is False: GlobalConfig._wait_for_animations = value + return None else: raise ValueError #: whether to wait for animations to complete and match only static (not moving) targets wait_for_animations = property(fget=wait_for_animations, fset=wait_for_animations) - def smooth_mouse_drag(self, value=None): + def smooth_mouse_drag(self, value: bool = None) -> bool | None: """ Getter/setter for property attribute. :param value: whether to move the mouse cursor to a location instantly or smoothly - :type value: bool or None :returns: current value if no argument was passed otherwise only sets it - :rtype: bool or None :raises: :py:class:`ValueError` if value is not boolean or None This is useful if a routine task has to be executed faster without @@ -213,12 +216,13 @@ def smooth_mouse_drag(self, value=None): return GlobalConfig._smooth_mouse_drag elif value is True or value is False: GlobalConfig._smooth_mouse_drag = value + return None else: raise ValueError #: whether to move the mouse cursor to a location instantly or smoothly smooth_mouse_drag = property(fget=smooth_mouse_drag, fset=smooth_mouse_drag) - def preprocess_special_chars(self, value=None): + def preprocess_special_chars(self, value: bool = None) -> bool | None: """ Same as :py:func:`GlobalConfig.smooth_mouse_drag` but with @@ -232,12 +236,13 @@ def preprocess_special_chars(self, value=None): return GlobalConfig._preprocess_special_chars elif value is True or value is False: GlobalConfig._preprocess_special_chars = value + return None else: raise ValueError #: whether to preprocess capital and special characters and handle them internally preprocess_special_chars = property(fget=preprocess_special_chars, fset=preprocess_special_chars) - def save_needle_on_error(self, value=None): + def save_needle_on_error(self, value: bool = None) -> bool | None: """ Same as :py:func:`GlobalConfig.smooth_mouse_drag` but with @@ -247,19 +252,18 @@ def save_needle_on_error(self, value=None): return GlobalConfig._save_needle_on_error elif value is True or value is False: GlobalConfig._save_needle_on_error = value + return None else: raise ValueError #: whether to perform an extra needle dump on matching error save_needle_on_error = property(fget=save_needle_on_error, fset=save_needle_on_error) - def image_logging_level(self, value=None): + def image_logging_level(self, value: int = None) -> int | None: """ Getter/setter for property attribute. :param value: logging level similar to the python logging module - :type value: int or None :returns: current value if no argument was passed otherwise only sets it - :rtype: int or None .. seealso:: See the image logging documentation for more details. """ @@ -267,10 +271,11 @@ def image_logging_level(self, value=None): return GlobalConfig._image_logging_level else: GlobalConfig._image_logging_level = value + return None #: logging level similar to the python logging module image_logging_level = property(fget=image_logging_level, fset=image_logging_level) - def image_logging_step_width(self, value=None): + def image_logging_step_width(self, value: int = None) -> int | None: """ Same as :py:func:`GlobalConfig.image_logging_level` but with @@ -281,10 +286,11 @@ def image_logging_step_width(self, value=None): return GlobalConfig._image_logging_step_width else: GlobalConfig._image_logging_step_width = value + return None #: number of digits when enumerating the image logging steps, e.g. value=3 for 001, 002, etc. image_logging_step_width = property(fget=image_logging_step_width, fset=image_logging_step_width) - def image_quality(self, value=None): + def image_quality(self, value: int = None) -> int | None: """ Same as :py:func:`GlobalConfig.image_logging_level` but with @@ -296,27 +302,27 @@ def image_quality(self, value=None): return GlobalConfig._image_quality else: GlobalConfig._image_quality = value + return None #: quality of the image dumps ranging from 0 for no compression to 9 for maximum compression # (used to save space and reduce the disk space needed for image logging) image_quality = property(fget=image_quality, fset=image_quality) - def image_logging_destination(self, value=None): + def image_logging_destination(self, value: str = None) -> str | None: """ Getter/setter for property attribute. :param value: relative path of the image logging steps - :type value: str or None :returns: current value if no argument was passed otherwise only sets it - :rtype: str or None """ if value is None: return GlobalConfig._image_logging_destination else: GlobalConfig._image_logging_destination = value + return None #: relative path of the image logging steps image_logging_destination = property(fget=image_logging_destination, fset=image_logging_destination) - def display_control_backend(self, value=None): + def display_control_backend(self, value: str = None) -> str | None: """ Same as :py:func:`GlobalConfig.image_logging_destination` but with @@ -346,12 +352,13 @@ def display_control_backend(self, value=None): if value not in ["autopy", "xdotool", "vncdotool", "qemu", "pyautogui"]: raise ValueError("Unsupported backend for GUI actions '%s'" % value) GlobalConfig._display_control_backend = value + return None #: name of the display control backend display_control_backend = property(fget=display_control_backend, fset=display_control_backend) # these methods do not check for valid values since this # is already done during region and target initialization - def find_backend(self, value=None): + def find_backend(self, value: str = None) -> str | None: """ Same as :py:func:`GlobalConfig.image_logging_destination` but with @@ -368,7 +375,7 @@ def find_backend(self, value=None): * text - text matching using EAST, ERStat, or custom text detection, followed by Tesseract or Hidden Markov Model OCR * tempfeat - a mixture of template and feature matching where the - first is used as necessary and the second as sufficient stage + first is used as necessary and the second as sufficient stage * deep - deep learning matching using convolutional neural network but customizable to any type of deep neural network * hybrid - use a composite approach with any of the above methods @@ -381,10 +388,11 @@ def find_backend(self, value=None): return GlobalConfig._find_backend else: GlobalConfig._find_backend = value + return None #: name of the computer vision backend find_backend = property(fget=find_backend, fset=find_backend) - def contour_threshold_backend(self, value=None): + def contour_threshold_backend(self, value: str = None) -> str | None: """ Same as :py:func:`GlobalConfig.image_logging_destination` but with @@ -396,10 +404,11 @@ def contour_threshold_backend(self, value=None): return GlobalConfig._contour_threshold_backend else: GlobalConfig._contour_threshold_backend = value + return None #: name of the contour threshold backend contour_threshold_backend = property(fget=contour_threshold_backend, fset=contour_threshold_backend) - def template_match_backend(self, value=None): + def template_match_backend(self, value: str = None) -> str | None: """ Same as :py:func:`GlobalConfig.image_logging_destination` but with @@ -412,10 +421,11 @@ def template_match_backend(self, value=None): return GlobalConfig._template_match_backend else: GlobalConfig._template_match_backend = value + return None #: name of the template matching backend template_match_backend = property(fget=template_match_backend, fset=template_match_backend) - def feature_detect_backend(self, value=None): + def feature_detect_backend(self, value: str = None) -> str | None: """ Same as :py:func:`GlobalConfig.image_logging_destination` but with @@ -428,10 +438,11 @@ def feature_detect_backend(self, value=None): return GlobalConfig._feature_detect_backend else: GlobalConfig._feature_detect_backend = value + return None #: name of the feature detection backend feature_detect_backend = property(fget=feature_detect_backend, fset=feature_detect_backend) - def feature_extract_backend(self, value=None): + def feature_extract_backend(self, value: str = None) -> str | None: """ Same as :py:func:`GlobalConfig.image_logging_destination` but with @@ -443,10 +454,11 @@ def feature_extract_backend(self, value=None): return GlobalConfig._feature_extract_backend else: GlobalConfig._feature_extract_backend = value + return None #: name of the feature extraction backend feature_extract_backend = property(fget=feature_extract_backend, fset=feature_extract_backend) - def feature_match_backend(self, value=None): + def feature_match_backend(self, value: str = None) -> str | None: """ Same as :py:func:`GlobalConfig.image_logging_destination` but with @@ -458,10 +470,11 @@ def feature_match_backend(self, value=None): return GlobalConfig._feature_match_backend else: GlobalConfig._feature_match_backend = value + return None #: name of the feature matching backend feature_match_backend = property(fget=feature_match_backend, fset=feature_match_backend) - def text_detect_backend(self, value=None): + def text_detect_backend(self, value: str = None) -> str | None: """ Same as :py:func:`GlobalConfig.image_logging_destination` but with @@ -473,10 +486,11 @@ def text_detect_backend(self, value=None): return GlobalConfig._text_detect_backend else: GlobalConfig._text_detect_backend = value + return None #: name of the text detection backend text_detect_backend = property(fget=text_detect_backend, fset=text_detect_backend) - def text_ocr_backend(self, value=None): + def text_ocr_backend(self, value: str = None) -> str | None: """ Same as :py:func:`GlobalConfig.image_logging_destination` but with @@ -488,10 +502,11 @@ def text_ocr_backend(self, value=None): return GlobalConfig._text_ocr_backend else: GlobalConfig._text_ocr_backend = value + return None #: name of the optical character recognition backend text_ocr_backend = property(fget=text_ocr_backend, fset=text_ocr_backend) - def deep_learn_backend(self, value=None): + def deep_learn_backend(self, value: str = None) -> str | None: """ Same as :py:func:`GlobalConfig.image_logging_destination` but with @@ -503,10 +518,11 @@ def deep_learn_backend(self, value=None): return GlobalConfig._deep_learn_backend else: GlobalConfig._deep_learn_backend = value + return None #: name of the deep learning backend deep_learn_backend = property(fget=deep_learn_backend, fset=deep_learn_backend) - def hybrid_match_backend(self, value=None): + def hybrid_match_backend(self, value: str = None) -> str | None: """ Same as :py:func:`GlobalConfig.image_logging_destination` but with @@ -518,11 +534,12 @@ def hybrid_match_backend(self, value=None): return GlobalConfig._hybrid_match_backend else: GlobalConfig._hybrid_match_backend = value + return None #: name of the hybrid matching backend for unconfigured one-step targets hybrid_match_backend = property(fget=hybrid_match_backend, fset=hybrid_match_backend) -class GlobalConfig(object, metaclass=GlobalConfig): +class GlobalConfig(object, metaclass=GlobalConfig): # type: ignore """ Handler for default configuration present in all cases where no specific value is set. @@ -558,15 +575,15 @@ class TemporaryConfig(object): 0.5 """ - def __init__(self): + def __init__(self) -> None: """Build a temporary global config.""" object.__setattr__(self, "_original_values", {}) - def __getattribute__(self, name): + def __getattribute__(self, name: Any) -> Any: # fallback to GlobalConfig return getattr(GlobalConfig, name) - def __setattr__(self, name, value): + def __setattr__(self, name: Any, value: Any) -> None: original_values = object.__getattribute__(self, "_original_values") # store the original value only at the first set operation, # so further changes won't overwrite the history @@ -574,11 +591,11 @@ def __setattr__(self, name, value): original_values[name] = getattr(GlobalConfig, name) setattr(GlobalConfig, name, value) - def __enter__(self): + def __enter__(self) -> "TemporaryConfig": # our temporary config object return self - def __exit__(self, *_): + def __exit__(self, *_: tuple[type, ...]) -> None: original_values = object.__getattribute__(self, "_original_values") # restore original configuration values for name, value in original_values.items(): @@ -595,12 +612,12 @@ class LocalConfig(object): information about them and the current parameters. """ - def __init__(self, configure=True, synchronize=True): + def __init__(self, configure: bool = True, synchronize: bool = True) -> None: """ Build a container for the entire backend configuration. - :param bool configure: whether to also generate configuration - :param bool synchronize: whether to also apply configuration + :param configure: whether to also generate configuration + :param synchronize: whether to also apply configuration Available algorithms can be seen in the `algorithms` attribute whose keys are the algorithm types and values are the members of @@ -622,7 +639,8 @@ def __init__(self, configure=True, synchronize=True): if synchronize: self.__synchronize_backend() - def __configure_backend(self, backend=None, category="type", reset=False): + def __configure_backend(self, backend: str = None, category: str ="type", + reset: bool = False) -> None: if category != "type": raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -637,25 +655,25 @@ def __configure_backend(self, backend=None, category="type", reset=False): self.params[category] = {} self.params[category]["backend"] = backend - def configure_backend(self, backend=None, category="type", reset=False): + def configure_backend(self, backend: str = None, category: str = "type", + reset: bool = False) -> None: """ Generate configuration dictionary for a given backend. :param backend: name of a preselected backend, see `algorithms[category]` - :type backend: str or None - :param str category: category for the backend, see `algorithms.keys()` - :param bool reset: whether to (re)set all parent configurations as well + :param category: category for the backend, see `algorithms.keys()` + :param reset: whether to (re)set all parent configurations as well :raises: :py:class:`UnsupportedBackendError` if `backend` is not among the supported backends for the category (and is not `None`) or the category is not found """ self.__configure_backend(backend, category, reset) - def configure(self, reset=True, **kwargs): + def configure(self, reset: bool = True, **kwargs: dict[str, type]) -> None: """ Generate configuration dictionary for all backends. - :param bool reset: whether to (re)set all parent configurations as well + :param reset: whether to (re)set all parent configurations as well If multiple categories are available and just some of them are configured, the rest will be reset to defaults. To configure specific category without @@ -663,7 +681,8 @@ def configure(self, reset=True, **kwargs): """ self.configure_backend(reset=reset) - def __synchronize_backend(self, backend=None, category="type", reset=False): + def __synchronize_backend(self, backend: str = None, category: str = "type", + reset: bool = False) -> None: if category != "type": raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -674,24 +693,24 @@ def __synchronize_backend(self, backend=None, category="type", reset=False): if backend not in self.algorithms[self.categories[category]]: raise UninitializedBackendError("Backend '%s' has not been configured yet" % backend) - def synchronize_backend(self, backend=None, category="type", reset=False): + def synchronize_backend(self, backend: str = None, category: str = "type", + reset: bool = False) -> None: """ Synchronize a category backend with the equalizer configuration. :param backend: name of a preselected backend, see `algorithms[category]` - :type backend: str or None - :param str category: category for the backend, see `algorithms.keys()` - :param bool reset: whether to (re)sync all parent backends as well + :param category: category for the backend, see `algorithms.keys()` + :param reset: whether to (re)sync all parent backends as well :raises: :py:class:`UnsupportedBackendError` if the category is not found :raises: :py:class:`UninitializedBackendError` if there is no backend object that is configured with and with the required name """ self.__synchronize_backend(backend, category, reset) - def synchronize(self, *args, reset=True, **kwargs): + def synchronize(self, *args: tuple[type, ...], reset: bool = True, **kwargs: dict[str, type]) -> None: """ Synchronize all backends with the current configuration dictionary. - :param bool reset: whether to (re)sync all parent backends as well + :param reset: whether to (re)sync all parent backends as well """ self.synchronize_backend(reset=reset) diff --git a/guibot/controller.py b/guibot/controller.py index 6631c52e..e5a08bcc 100644 --- a/guibot/controller.py +++ b/guibot/controller.py @@ -51,7 +51,7 @@ class Controller(LocalConfig): like mouse clicking, key pressing, text typing, etc. """ - def __init__(self, configure=True, synchronize=True): + def __init__(self, configure: bool = True, synchronize: bool = True) -> None: """Build a screen controller backend.""" super(Controller, self).__init__(configure=False, synchronize=False) @@ -66,9 +66,9 @@ def __init__(self, configure=True, synchronize=True): self._height = 0 # NOTE: some backends require mouse pointer reinitialization so compensate for it self._pointer = Location(0, 0) - self._keymap = None - self._modmap = None - self._mousemap = None + self._keymap: inputmap.Key = None + self._modmap: inputmap.KeyModifier = None + self._mousemap: inputmap.MouseButton = None # additional preparation if configure: @@ -76,67 +76,62 @@ def __init__(self, configure=True, synchronize=True): if synchronize: self.__synchronize_backend(reset=False) - def get_width(self): + def get_width(self) -> int: """ Getter for readonly attribute. :returns: width of the connected screen - :rtype: int """ return self._width width = property(fget=get_width) - def get_height(self): + def get_height(self) -> int: """ Getter for readonly attribute. :returns: height of the connected screen - :rtype: int """ return self._height height = property(fget=get_height) - def get_keymap(self): + def get_keymap(self) -> inputmap.Key: """ Getter for readonly attribute. :returns: map of keys to be used for the connected screen - :rtype: :py:class:`inputmap.Key` """ return self._keymap keymap = property(fget=get_keymap) - def get_mousemap(self): + def get_mousemap(self) -> inputmap.MouseButton: """ Getter for readonly attribute. :returns: map of mouse buttons to be used for the connected screen - :rtype: :py:class:`inputmap.MouseButton` """ return self._mousemap mousemap = property(fget=get_mousemap) - def get_modmap(self): + def get_modmap(self) -> inputmap.KeyModifier: """ Getter for readonly attribute. :returns: map of modifier keys to be used for the connected screen - :rtype: :py:class:`inputmap.KeyModifier` """ return self._modmap modmap = property(fget=get_modmap) - def get_mouse_location(self): + def get_mouse_location(self) -> Location: """ Getter for readonly attribute. :returns: location of the mouse pointer - :rtype: :py:class:`location.Location` """ return self._pointer mouse_location = property(fget=get_mouse_location) - def __configure_backend(self, backend=None, category="control", reset=False): + def __configure_backend(self, backend: str = None, category: str = "control", + reset: bool = False) -> None: if category != "control": raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -152,7 +147,8 @@ def __configure_backend(self, backend=None, category="control", reset=False): self.params[category]["backend"] = backend log.log(9, "%s %s\n", category, self.params[category]) - def configure_backend(self, backend=None, category="control", reset=False): + def configure_backend(self, backend: str = None, category: str = "control", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -160,7 +156,8 @@ def configure_backend(self, backend=None, category="control", reset=False): """ self.__configure_backend(backend, category, reset) - def __synchronize_backend(self, backend=None, category="control", reset=False): + def __synchronize_backend(self, backend: str = None, category: str = "control", + reset: bool = False) -> None: if category != "control": raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -168,7 +165,8 @@ def __synchronize_backend(self, backend=None, category="control", reset=False): if backend is not None and self.params[category]["backend"] != backend: raise UninitializedBackendError("Backend '%s' has not been configured yet" % backend) - def synchronize_backend(self, backend=None, category="control", reset=False): + def synchronize_backend(self, backend: str = None, category: str = "control", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -180,7 +178,7 @@ def synchronize_backend(self, backend=None, category="control", reset=False): """ self.__synchronize_backend(backend, category, reset) - def _region_from_args(self, *args): + def _region_from_args(self, *args: "Region") -> tuple[int, int, int, int, str]: if len(args) == 4: xpos = args[0] ypos = args[1] @@ -215,108 +213,99 @@ def _region_from_args(self, *args): filename = f.name return xpos, ypos, width, height, filename - def capture_screen(self, *args): + def capture_screen(self, *args: "list[int] | Region | None") -> Image: """ Get the current screen as image. :param args: region's (x, y, width, height) or a region object or nothing to obtain an image of the full screen - :type args: [int] or :py:class:`region.Region` or None :returns: image of the current screen - :rtype: :py:class:`target.Image` :raises: :py:class:`NotImplementedError` if the base class method is called """ raise NotImplementedError("Method is not available for this controller implementation") - def mouse_move(self, location, smooth=True): + def mouse_move(self, location: Location, smooth: bool = True) -> None: """ Move the mouse to a desired location. :param location: location on the screen to move to - :type location: :py:class:`location.Location` - :param bool smooth: whether to sue smooth transition or just teleport the mouse + :param smooth: whether to sue smooth transition or just teleport the mouse :raises: :py:class:`NotImplementedError` if the base class method is called """ raise NotImplementedError("Method is not available for this controller implementation") - def mouse_click(self, button=None, count=1, modifiers=None): + def mouse_click(self, button: int = None, count: int = 1, modifiers: list[str] = None) -> None: """ Click the selected mouse button N times at the current mouse location. :param button: mouse button, e.g. self.mouse_map.LEFT_BUTTON - :type button: int or None - :param int count: number of times to click + :param count: number of times to click :param modifiers: special keys to hold during clicking (see :py:class:`inputmap.KeyModifier` for extensive list) - :type modifiers: [str] :raises: :py:class:`NotImplementedError` if the base class method is called """ raise NotImplementedError("Method is not available for this controller implementation") - def mouse_down(self, button): + def mouse_down(self, button: int) -> None: """ Hold down a mouse button. - :param int button: button index depending on backend - (see :py:class:`inputmap.MouseButton` for extensive list) + :param button: button index depending on backend + (see :py:class:`inputmap.MouseButton` for extensive list) :raises: :py:class:`NotImplementedError` if the base class method is called """ raise NotImplementedError("Method is not available for this controller implementation") - def mouse_up(self, button): + def mouse_up(self, button: int) -> None: """ Release a mouse button. - :param int button: button index depending on backend - (see :py:class:`inputmap.MouseButton` for extensive list) + :param button: button index depending on backend + (see :py:class:`inputmap.MouseButton` for extensive list) :raises: :py:class:`NotImplementedError` if the base class method is called """ raise NotImplementedError("Method is not available for this controller implementation") - def mouse_scroll(self, clicks=10, horizontal=False): + def mouse_scroll(self, clicks: int = 10, horizontal: bool = False) -> None: """ Scroll the mouse for a number of clicks. - :param int clicks: number of clicks to scroll up (positive) or down (negative) - :param bool horizontal: whether to perform a horizontal scroll instead - (only available on some platforms) + :param clicks: number of clicks to scroll up (positive) or down (negative) + :param horizontal: whether to perform a horizontal scroll instead + (only available on some platforms) :raises: :py:class:`NotImplementedError` if the base class method is called """ raise NotImplementedError("Method is not available for this controller implementation") - def keys_toggle(self, keys, up_down): + def keys_toggle(self, keys: list[str] | str, up_down: bool) -> None: """ Hold down or release together all provided keys. :param keys: characters or special keys depending on the backend (see :py:class:`inputmap.Key` for extensive list) - :type keys: [str] or str (no special keys in the second case) - :param bool up_down: hold down if true else release + :param up_down: hold down if true else release :raises: :py:class:`NotImplementedError` if the base class method is called """ raise NotImplementedError("Method is not available for this controller implementation") - def keys_press(self, keys): + def keys_press(self, keys: list[str] | str) -> None: """ Press (hold down and release) together all provided keys. :param keys: characters or special keys depending on the backend (see :py:class:`inputmap.Key` for extensive list) - :type keys: [str] or str (no special keys in the second case) """ # BUG: pressing multiple times the same key does not work? self.keys_toggle(keys, True) self.keys_toggle(keys, False) - def keys_type(self, text, modifiers=None): + def keys_type(self, text: list[str] | str, modifiers: list[str] = None) -> None: """ Type (press consecutively) all provided keys. :param text: characters only (no special keys allowed) - :type text: [str] or str (second case is preferred and first redundant) :param modifiers: special keys to hold during typing (see :py:class:`inputmap.KeyModifier` for extensive list) - :type modifiers: [str] :raises: :py:class:`NotImplementedError` if the base class method is called """ raise NotImplementedError("Method is not available for this controller implementation") @@ -328,7 +317,7 @@ class AutoPyController(Controller): python library portable to Windows and Linux operating systems. """ - def __init__(self, configure=True, synchronize=True): + def __init__(self, configure: bool = True, synchronize: bool = True) -> None: """Build a DC backend using AutoPy.""" super(AutoPyController, self).__init__(configure=False, synchronize=False) if configure: @@ -336,7 +325,7 @@ def __init__(self, configure=True, synchronize=True): if synchronize: self.__synchronize_backend(reset=False) - def get_mouse_location(self): + def get_mouse_location(self) -> Location: """ Custom implementation of the base method. @@ -350,7 +339,8 @@ def get_mouse_location(self): return Location(int(loc[0] / self._scale), int(loc[1] / self._scale)) mouse_location = property(fget=get_mouse_location) - def __configure_backend(self, backend=None, category="autopy", reset=False): + def __configure_backend(self, backend: str = None, category: str = "autopy", + reset: bool = False) -> None: if category != "autopy": raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -359,7 +349,8 @@ def __configure_backend(self, backend=None, category="autopy", reset=False): self.params[category] = {} self.params[category]["backend"] = "none" - def configure_backend(self, backend=None, category="autopy", reset=False): + def configure_backend(self, backend: str = None, category: str = "autopy", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -367,7 +358,8 @@ def configure_backend(self, backend=None, category="autopy", reset=False): """ self.__configure_backend(backend, category, reset) - def __synchronize_backend(self, backend=None, category="autopy", reset=False): + def __synchronize_backend(self, backend: str = None, category: str = "autopy", + reset: bool = False) -> None: if category != "autopy": raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -387,7 +379,8 @@ def __synchronize_backend(self, backend=None, category="autopy", reset=False): self._modmap = inputmap.AutoPyKeyModifier() self._mousemap = inputmap.AutoPyMouseButton() - def synchronize_backend(self, backend=None, category="autopy", reset=False): + def synchronize_backend(self, backend: str = None, category: str = "autopy", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -395,7 +388,7 @@ def synchronize_backend(self, backend=None, category="autopy", reset=False): """ self.__synchronize_backend(backend, category, reset) - def capture_screen(self, *args): + def capture_screen(self, *args: "list[int] | Region | None") -> Image: """ Custom implementation of the base method. @@ -405,20 +398,20 @@ def capture_screen(self, *args): # autopy works in points and requires a minimum of one point along a dimension xpos, ypos, width, height = xpos / self._scale, ypos / self._scale, width / self._scale, height / self._scale - xpos, ypos = xpos - (1.0 - width) if width < 1.0 else xpos, ypos - (1.0 - height) if height < 1.0 else ypos - height, width = 1.0 if height < 1.0 else height, 1.0 if width < 1.0 else width + xpos, ypos = float(xpos) - (1.0 - float(width)) if width < 1.0 else xpos, float(ypos) - (1.0 - float(height)) if height < 1.0 else ypos + height, width = 1.0 if float(height) < 1.0 else height, 1.0 if float(width) < 1.0 else width try: autopy_bmp = self._backend_obj.bitmap.capture_screen(((xpos, ypos), (width, height))) except ValueError: - return Image(None, PIL.Image.new('RGB', (1, 1))) + return Image("", PIL.Image.new('RGB', (1, 1))) autopy_bmp.save(filename) with PIL.Image.open(filename) as f: pil_image = f.convert('RGB') os.unlink(filename) - return Image(None, pil_image) + return Image("", pil_image) - def mouse_move(self, location, smooth=True): + def mouse_move(self, location: Location, smooth: bool = True) -> None: """ Custom implementation of the base method. @@ -431,7 +424,8 @@ def mouse_move(self, location, smooth=True): self._backend_obj.mouse.move(x, y) self._pointer = location - def mouse_click(self, button=None, count=1, modifiers=None): + def mouse_click(self, button: int = None, count: int = 1, + modifiers: list[str] = None) -> None: """ Custom implementation of the base method. @@ -451,7 +445,7 @@ def mouse_click(self, button=None, count=1, modifiers=None): if modifiers is not None: self.keys_toggle(modifiers, False) - def mouse_down(self, button): + def mouse_down(self, button: int) -> None: """ Custom implementation of the base method. @@ -459,7 +453,7 @@ def mouse_down(self, button): """ self._backend_obj.mouse.toggle(button, True) - def mouse_up(self, button): + def mouse_up(self, button: int) -> None: """ Custom implementation of the base method. @@ -467,7 +461,7 @@ def mouse_up(self, button): """ self._backend_obj.mouse.toggle(button, False) - def keys_toggle(self, keys, up_down): + def keys_toggle(self, keys: list[str] | str, up_down: bool) -> None: """ Custom implementation of the base method. @@ -476,7 +470,7 @@ def keys_toggle(self, keys, up_down): for key in keys: self._backend_obj.key.toggle(key, up_down, []) - def keys_type(self, text, modifiers=None): + def keys_type(self, text: list[str] | str, modifiers: list[str] = None) -> None: """ Custom implementation of the base method. @@ -502,7 +496,7 @@ class XDoToolController(Controller): thus portable to Linux operating systems. """ - def __init__(self, configure=True, synchronize=True): + def __init__(self, configure: bool = True, synchronize: bool = True) -> None: """Build a DC backend using XDoTool.""" super(XDoToolController, self).__init__(configure=False, synchronize=False) if configure: @@ -510,7 +504,7 @@ def __init__(self, configure=True, synchronize=True): if synchronize: self.__synchronize_backend(reset=False) - def get_mouse_location(self): + def get_mouse_location(self) -> Location: """ Custom implementation of the base method. @@ -522,7 +516,8 @@ def get_mouse_location(self): return Location(int(x), int(y)) mouse_location = property(fget=get_mouse_location) - def __configure_backend(self, backend=None, category="xdotool", reset=False): + def __configure_backend(self, backend: str = None, category: str = "xdotool", + reset: bool = False) -> None: if category != "xdotool": raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -532,7 +527,8 @@ def __configure_backend(self, backend=None, category="xdotool", reset=False): self.params[category]["backend"] = "none" self.params[category]["binary"] = "xdotool" - def configure_backend(self, backend=None, category="xdotool", reset=False): + def configure_backend(self, backend: str = None, category: str = "xdotool", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -540,7 +536,8 @@ def configure_backend(self, backend=None, category="xdotool", reset=False): """ self.__configure_backend(backend, category, reset) - def __synchronize_backend(self, backend=None, category="xdotool", reset=False): + def __synchronize_backend(self, backend: str = None, category: str = "xdotool", + reset: bool = False) -> None: if category != "xdotool": raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -550,9 +547,9 @@ def __synchronize_backend(self, backend=None, category="xdotool", reset=False): import subprocess class XDoTool(object): - def __init__(self, dc): + def __init__(self, dc: Controller) -> None: self.dc = dc - def run(self, command, *args): + def run(self, command: str, *args: list[str]) -> str: process = [self.dc.params[category]["binary"]] process += [command] process += args @@ -566,7 +563,8 @@ def run(self, command, *args): self._modmap = inputmap.XDoToolKeyModifier() self._mousemap = inputmap.XDoToolMouseButton() - def synchronize_backend(self, backend=None, category="xdotool", reset=False): + def synchronize_backend(self, backend: str = None, category: str = "xdotool", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -574,7 +572,7 @@ def synchronize_backend(self, backend=None, category="xdotool", reset=False): """ self.__synchronize_backend(backend, category, reset) - def capture_screen(self, *args): + def capture_screen(self, *args: "list[int] | Region | None") -> Image: """ Custom implementation of the base method. @@ -587,9 +585,9 @@ def capture_screen(self, *args): with PIL.Image.open(filename) as f: pil_image = f.convert('RGB') os.unlink(filename) - return Image(None, pil_image) + return Image("", pil_image) - def mouse_move(self, location, smooth=True): + def mouse_move(self, location: Location, smooth: bool = True) -> None: """ Custom implementation of the base method. @@ -605,7 +603,8 @@ def mouse_move(self, location, smooth=True): time.sleep(0.3) self._pointer = location - def mouse_click(self, button=None, count=1, modifiers=None): + def mouse_click(self, button: int = None, count: int = 1, + modifiers: list[str] = None) -> None: """ Custom implementation of the base method. @@ -626,7 +625,7 @@ def mouse_click(self, button=None, count=1, modifiers=None): if modifiers is not None: self.keys_toggle(modifiers, False) - def mouse_down(self, button): + def mouse_down(self, button: int) -> None: """ Custom implementation of the base method. @@ -634,7 +633,7 @@ def mouse_down(self, button): """ self._backend_obj.run("mousedown", str(button)) - def mouse_up(self, button): + def mouse_up(self, button: int) -> None: """ Custom implementation of the base method. @@ -642,7 +641,7 @@ def mouse_up(self, button): """ self._backend_obj.run("mouseup", str(button)) - def keys_toggle(self, keys, up_down): + def keys_toggle(self, keys: list[str] | str, up_down: bool) -> None: """ Custom implementation of the base method. @@ -654,7 +653,7 @@ def keys_toggle(self, keys, up_down): else: self._backend_obj.run('keyup', str(key)) - def keys_type(self, text, modifiers=None): + def keys_type(self, text: list[str] | str, modifiers: list[str] = None) -> None: """ Custom implementation of the base method. @@ -676,7 +675,7 @@ class VNCDoToolController(Controller): thus portable to any guest OS that is accessible through a VNC/RFB protocol. """ - def __init__(self, configure=True, synchronize=True): + def __init__(self, configure: bool = True, synchronize: bool = True) -> None: """Build a DC backend using VNCDoTool.""" super(VNCDoToolController, self).__init__(configure=False, synchronize=False) if configure: @@ -684,7 +683,7 @@ def __init__(self, configure=True, synchronize=True): if synchronize: self.__synchronize_backend(reset=False) - def __configure_backend(self, backend=None, category="vncdotool", reset=False): + def __configure_backend(self, backend: str = None, category: str = "vncdotool", reset: bool = False) -> None: if category != "vncdotool": raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -699,7 +698,8 @@ def __configure_backend(self, backend=None, category="vncdotool", reset=False): # password for the vnc server self.params[category]["vnc_password"] = None - def configure_backend(self, backend=None, category="vncdotool", reset=False): + def configure_backend(self, backend: str = None, category: str = "vncdotool", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -707,7 +707,8 @@ def configure_backend(self, backend=None, category="vncdotool", reset=False): """ self.__configure_backend(backend, category, reset) - def __synchronize_backend(self, backend=None, category="vncdotool", reset=False): + def __synchronize_backend(self, backend: str = None, category: str = "vncdotool", + reset: bool = False) -> None: if category != "vncdotool": raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -748,7 +749,8 @@ def __synchronize_backend(self, backend=None, category="vncdotool", reset=False) self._modmap = inputmap.VNCDoToolKeyModifier() self._mousemap = inputmap.VNCDoToolMouseButton() - def synchronize_backend(self, backend=None, category="vncdotool", reset=False): + def synchronize_backend(self, backend: str = None, category: str = "vncdotool", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -756,7 +758,7 @@ def synchronize_backend(self, backend=None, category="vncdotool", reset=False): """ self.__synchronize_backend(backend, category, reset) - def capture_screen(self, *args): + def capture_screen(self, *args: "list[int] | Region | None") -> Image: """ Custom implementation of the base method. @@ -766,9 +768,9 @@ def capture_screen(self, *args): self._backend_obj.refreshScreen() cropped = self._backend_obj.screen.crop((xpos, ypos, xpos + width, ypos + height)) pil_image = cropped.convert('RGB') - return Image(None, pil_image) + return Image("", pil_image) - def mouse_move(self, location, smooth=True): + def mouse_move(self, location: Location, smooth: bool = True) -> None: """ Custom implementation of the base method. @@ -780,7 +782,8 @@ def mouse_move(self, location, smooth=True): self._backend_obj.mouseMove(location.x, location.y) self._pointer = location - def mouse_click(self, button=None, count=1, modifiers=None): + def mouse_click(self, button: int = None, count: int = 1, + modifiers: list[str] = None) -> None: """ Custom implementation of the base method. @@ -802,7 +805,7 @@ def mouse_click(self, button=None, count=1, modifiers=None): if modifiers is not None: self.keys_toggle(modifiers, False) - def mouse_down(self, button): + def mouse_down(self, button: int) -> None: """ Custom implementation of the base method. @@ -810,7 +813,7 @@ def mouse_down(self, button): """ self._backend_obj.mouseDown(button) - def mouse_up(self, button): + def mouse_up(self, button: int) -> None: """ Custom implementation of the base method. @@ -818,7 +821,7 @@ def mouse_up(self, button): """ self._backend_obj.mouseUp(button) - def keys_toggle(self, keys, up_down): + def keys_toggle(self, keys: list[str] | str, up_down: bool) -> None: """ Custom implementation of the base method. @@ -836,7 +839,7 @@ def keys_toggle(self, keys, up_down): else: self._backend_obj.keyUp(key) - def keys_type(self, text, modifiers=None): + def keys_type(self, text: list[str] | str, modifiers: list [str] = None) -> None: """ Custom implementation of the base method. @@ -868,7 +871,7 @@ class PyAutoGUIController(Controller): library portable to MacOS, Windows, and Linux operating systems. """ - def __init__(self, configure=True, synchronize=True): + def __init__(self, configure: bool = True, synchronize: bool = True) -> None: """Build a DC backend using PyAutoGUI.""" super(PyAutoGUIController, self).__init__(configure=False, synchronize=False) if configure: @@ -876,7 +879,7 @@ def __init__(self, configure=True, synchronize=True): if synchronize: self.__synchronize_backend(reset=False) - def get_mouse_location(self): + def get_mouse_location(self) -> Location: """ Custom implementation of the base method. @@ -886,7 +889,8 @@ def get_mouse_location(self): return Location(x, y) mouse_location = property(fget=get_mouse_location) - def __configure_backend(self, backend=None, category="pyautogui", reset=False): + def __configure_backend(self, backend: str = None, category: str = "pyautogui", + reset: bool = False) -> None: if category != "pyautogui": raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -895,7 +899,8 @@ def __configure_backend(self, backend=None, category="pyautogui", reset=False): self.params[category] = {} self.params[category]["backend"] = "none" - def configure_backend(self, backend=None, category="pyautogui", reset=False): + def configure_backend(self, backend: str = None, category: str = "pyautogui", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -903,7 +908,8 @@ def configure_backend(self, backend=None, category="pyautogui", reset=False): """ self.__configure_backend(backend, category, reset) - def __synchronize_backend(self, backend=None, category="pyautogui", reset=False): + def __synchronize_backend(self, backend: str = None, category: str = "pyautogui", + reset: bool = False) -> None: if category != "pyautogui": raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -922,7 +928,8 @@ def __synchronize_backend(self, backend=None, category="pyautogui", reset=False) self._modmap = inputmap.PyAutoGUIKeyModifier() self._mousemap = inputmap.PyAutoGUIMouseButton() - def synchronize_backend(self, backend=None, category="pyautogui", reset=False): + def synchronize_backend(self, backend: str = None, category: str = "pyautogui", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -930,7 +937,7 @@ def synchronize_backend(self, backend=None, category="pyautogui", reset=False): """ self.__synchronize_backend(backend, category, reset) - def capture_screen(self, *args): + def capture_screen(self, *args: "list[int] | Region | None") -> Image: """ Custom implementation of the base method. @@ -939,9 +946,9 @@ def capture_screen(self, *args): xpos, ypos, width, height, _ = self._region_from_args(*args) pil_image = self._backend_obj.screenshot(region=(xpos, ypos, width, height)) - return Image(None, pil_image) + return Image("", pil_image) - def mouse_move(self, location, smooth=True): + def mouse_move(self, location: Location, smooth: bool = True) -> None: """ Custom implementation of the base method. @@ -953,7 +960,8 @@ def mouse_move(self, location, smooth=True): self._backend_obj.moveTo(location.x, location.y) self._pointer = location - def mouse_click(self, button=None, count=1, modifiers=None): + def mouse_click(self, button: int = None, count: int = 1, + modifiers: list[str] = None) -> None: """ Custom implementation of the base method. @@ -975,7 +983,7 @@ def mouse_click(self, button=None, count=1, modifiers=None): if modifiers is not None: self.keys_toggle(modifiers, False) - def mouse_down(self, button): + def mouse_down(self, button: int) -> None: """ Custom implementation of the base method. @@ -983,7 +991,7 @@ def mouse_down(self, button): """ self._backend_obj.mouseDown(button=button) - def mouse_up(self, button): + def mouse_up(self, button: int) -> None: """ Custom implementation of the base method. @@ -991,7 +999,7 @@ def mouse_up(self, button): """ self._backend_obj.mouseUp(button=button) - def mouse_scroll(self, clicks=10, horizontal=False): + def mouse_scroll(self, clicks: int = 10, horizontal: bool = False) -> None: """ Custom implementation of the base method. @@ -1002,7 +1010,7 @@ def mouse_scroll(self, clicks=10, horizontal=False): else: self._backend_obj.scroll(clicks) - def keys_toggle(self, keys, up_down): + def keys_toggle(self, keys: list[str] | str, up_down: bool) -> None: """ Custom implementation of the base method. @@ -1014,7 +1022,7 @@ def keys_toggle(self, keys, up_down): else: self._backend_obj.keyUp(key) - def keys_type(self, text, modifiers=None): + def keys_type(self, text: list[str] | str, modifiers: list[str] = None) -> None: """ Custom implementation of the base method. diff --git a/guibot/errors.py b/guibot/errors.py index 3f9d1b0c..bf7adb0c 100644 --- a/guibot/errors.py +++ b/guibot/errors.py @@ -52,12 +52,11 @@ class IncompatibleTargetFileError(GuiBotError): class FindError(GuiBotError): """Exception raised when an Image cannot be found on the screen""" - def __init__(self, failed_target=None): + def __init__(self, failed_target: "Target" = None) -> None: """ Build the exception possibly providing the failed target. :param failed_target: the target that wasn't found - :type failed_target: :py:class:`target.Target` or None """ if failed_target: message = "The target %s could not be found on the screen" % failed_target @@ -69,12 +68,11 @@ def __init__(self, failed_target=None): class NotFindError(GuiBotError): """Exception raised when an Image can be found on the screen but should not be""" - def __init__(self, failed_target=None): + def __init__(self, failed_target: "Target" = None) -> None: """ Build the exception possibly providing the failed target. :param failed_target: the target that was found - :type failed_target: :py:class:`target.Target` or None """ if failed_target: message = "The target %s was found on the screen while it was not expected" % failed_target diff --git a/guibot/fileresolver.py b/guibot/fileresolver.py index 5f2c3bc7..d54d2316 100644 --- a/guibot/fileresolver.py +++ b/guibot/fileresolver.py @@ -27,6 +27,7 @@ import os from .errors import * +from typing import Generator import logging @@ -46,24 +47,23 @@ class FileResolver(object): # Shared between all instances _target_paths = [] - def add_path(self, directory): + def add_path(self, directory: str) -> None: """ Add a path to the list of currently accessible paths if it wasn't already added. - :param str directory: path to add + :param directory: path to add """ if directory not in FileResolver._target_paths: log.info("Adding target path %s", directory) FileResolver._target_paths.append(directory) - def remove_path(self, directory): + def remove_path(self, directory: str) -> bool: """ Remove a path from the list of currently accessible paths. - :param str directory: path to add + :param directory: path to add :returns: whether the removal succeeded - :rtype: bool """ try: FileResolver._target_paths.remove(directory) @@ -73,20 +73,19 @@ def remove_path(self, directory): log.info("Removing target path %s", directory) return True - def clear(self): + def clear(self) -> None: """Clear all currently accessible paths.""" # empty list but keep reference del FileResolver._target_paths[:] - def search(self, filename, restriction="", silent=False): + def search(self, filename: str, restriction: str = "", silent: bool = False) -> str | None: """ Search for a filename in the currently accessible paths. - :param str filename: filename of the target to search for - :param str restriction: simple string to restrict the number of paths - :param bool silent: whether to return None instead of error out + :param filename: filename of the target to search for + :param restriction: simple string to restrict the number of paths + :param silent: whether to return None instead of error out :returns: the full name of the found target file or None if silent and no file was found - :rtype: str or None :raises: :py:class:`FileNotFoundError` if no such file was found and not silent """ for directory in FileResolver._target_paths: @@ -127,11 +126,11 @@ def search(self, filename, restriction="", silent=False): return None - def __iter__(self): + def __iter__(self) -> Generator[str, None, None]: for p in self._target_paths: yield p - def __len__(self): + def __len__(self) -> int: return len(self._target_paths) @@ -146,7 +145,7 @@ class CustomFileResolver(object): take only these paths into account. """ - def __init__(self, *paths): + def __init__(self, *paths: tuple[type, ...]) -> None: """ Create the class with the paths that the search will be restricted to. @@ -155,12 +154,11 @@ def __init__(self, *paths): """ self._paths = paths - def __enter__(self): + def __enter__(self) -> FileResolver: """ Start this context. :returns: instance of the file resolver that can be used to search files - :rtype: py:class:`FileResolver` The paths used by the py:class:`FileResolver` class will be replaced by the paths used to initialize this class during the duration of this context. @@ -172,7 +170,7 @@ def __enter__(self): file_resolver.add_path(p) return file_resolver - def __exit__(self, *args): + def __exit__(self, *args: tuple[type, ...],) -> None: """ Exit this context and restore the original paths. diff --git a/guibot/finder.py b/guibot/finder.py index 4e82dab9..26025e75 100644 --- a/guibot/finder.py +++ b/guibot/finder.py @@ -32,11 +32,14 @@ import random import configparser as config import PIL.Image +from typing import Callable +from typing import Any from .config import GlobalConfig, LocalConfig from .imagelogger import ImageLogger from .fileresolver import FileResolver from .errors import * +from .location import Location import logging log = logging.getLogger('guibot.finder') @@ -50,25 +53,23 @@ class CVParameter(object): """A class for a single parameter used for CV backend configuration.""" - def __init__(self, value, - min_val=None, max_val=None, - delta=10.0, tolerance=1.0, - fixed=True, enumerated=False): + def __init__(self, value: bool | int | float | str | None, + min_val: type["value"] = None, + max_val: type["value"] = None, + delta: float = 10.0, tolerance: float = 1.0, + fixed: bool = True, enumerated: bool = False) -> None: """ Build a computer vision parameter. :param value: value of the parameter - :type value: bool or int or float or str or None :param min_val: lower boundary for the parameter range - :type min_val: int or float or None :param max_val: upper boundary for the parameter range - :type max_val: int or float or None - :param float delta: delta for the calibration and random value - (no calibration if `delta` < `tolerance`) - :param float tolerance: tolerance of calibration - :param bool fixed: whether the parameter is prevented from calibration - :param bool enumerated: whether the parameter value belongs to an - enumeration or to a range (distance matters) + :param delta: delta for the calibration and random value + (no calibration if `delta` < `tolerance`) + :param tolerance: tolerance of calibration + :param fixed: whether the parameter is prevented from calibration + :param enumerated: whether the parameter value belongs to an + enumeration or to a range (distance matters) As a rule of thumb a good choice for the parameter delta is one fourth of the range since the delta will be used as standard deviation when @@ -106,35 +107,32 @@ def __init__(self, value, if self.enumerated and (self.min_val is None or self.max_val is None): raise ValueError("Enumerated parameters must have a finite (usually small) range") - def __repr__(self): + def __repr__(self) -> str: """ Provide a representation of the parameter for storing and reporting. :returns: special syntax representation of the parameter - :rtype: str """ return ("" % (self.value, self.min_val, self.max_val, self.delta, self.tolerance, self.fixed, self.enumerated)) - def __eq__(self, other): + def __eq__(self, other: "CVParameter") -> bool: """ Custom implementation for equality check. :returns: whether this instance is equal to another - :rtype: bool """ if not isinstance(other, CVParameter): return NotImplemented return repr(self) == repr(other) @staticmethod - def from_string(raw): + def from_string(raw: str) -> "CVParameter": """ Parse a CV parameter from string. - :param str raw: string representation for the parameter + :param raw: string representation for the parameter :returns: parameter parsed from the representation - :rtype: :py:class:`CVParameter` :raises: :py:class:`ValueError` if unsupported type is encountered """ args = [] @@ -162,17 +160,15 @@ def from_string(raw): log.log(9, "%s", args) return CVParameter(*args) - def random_value(self, mu=None, sigma=None): + def random_value(self, mu: bool | int | float | str = None, + sigma: bool | int | float | str = None) -> bool | int | float | str | None: """ Return a random value of the CV parameter given its range and type. :param mu: mean for a normal distribution, uniform distribution if None - :type mu: bool or int or float or str or None :param sigma: standard deviation for a normal distribution, quarter range if None (maximal range is equivalent to maximal data type values) - :type sigma: bool or int or float or str or None :returns: a random value comforming to the CV parameter range and type - :rtype: bool or int or float or str or None .. note:: Only uniform distribution is used for boolean values. """ @@ -211,13 +207,12 @@ class Finder(LocalConfig): """ @staticmethod - def from_match_file(filename): + def from_match_file(filename: str) -> "Finder": """ Read the configuration from a match file with the given filename. - :param str filename: match filename for the configuration + :param filename: match filename for the configuration :returns: target finder with the parsed (and generated) settings - :rtype: :py:class:`finder.Finder` :raises: :py:class:`IOError` if the respective match file couldn't be read The influence of the read configuration is that of an overwrite, i.e. @@ -284,13 +279,12 @@ def from_match_file(filename): return finder @staticmethod - def to_match_file(finder, filename): + def to_match_file(finder: "Finder", filename: str) -> None: """ Write the configuration to a match file with the given filename. :param finder: match configuration to save - :type finder: :py:class:`finder.Finder` - :param str filename: match filename for the configuration + :param filename: match filename for the configuration """ parser = config.RawConfigParser() # preserve case sensitivity @@ -311,7 +305,7 @@ def to_match_file(finder, filename): configfile.write("# IMAGE MATCH DATA\n") parser.write(configfile) - def __init__(self, configure=True, synchronize=True): + def __init__(self, configure: bool = True, synchronize: bool = True) -> None: """Build a finder and its CV backend settings.""" super(Finder, self).__init__(configure=False, synchronize=False) @@ -328,7 +322,8 @@ def __init__(self, configure=True, synchronize=True): if configure: self.__configure_backend(reset=True) - def __configure_backend(self, backend=None, category="find", reset=False): + def __configure_backend(self, backend: str = None, category: str = "find", + reset: bool = False) -> None: if category != "find": raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -345,7 +340,8 @@ def __configure_backend(self, backend=None, category="find", reset=False): self.params[category]["similarity"] = CVParameter(0.75, 0.0, 1.0) log.log(9, "%s %s\n", category, self.params[category]) - def configure_backend(self, backend=None, category="find", reset=False): + def configure_backend(self, backend: str = None, category: str = "find", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -353,7 +349,8 @@ def configure_backend(self, backend=None, category="find", reset=False): """ self.__configure_backend(backend, category, reset) - def __synchronize_backend(self, backend=None, category="find", reset=False): + def __synchronize_backend(self, backend: str = None, category: str = "find", + reset: bool = False) -> None: if category != "find": raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -362,7 +359,8 @@ def __synchronize_backend(self, backend=None, category="find", reset=False): raise UninitializedBackendError("Backend '%s' has not been configured yet" % backend) backend = self.params[category]["backend"] - def synchronize_backend(self, backend=None, category="find", reset=False): + def synchronize_backend(self, backend: str = None, category: str = "find", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -370,13 +368,13 @@ def synchronize_backend(self, backend=None, category="find", reset=False): """ self.__synchronize_backend(backend, category, reset) - def can_calibrate(self, category, mark): + def can_calibrate(self, category: str, mark: bool) -> None: """ Fix the parameters for a given category backend algorithm, i.e. disallow the calibrator to change them. - :param bool mark: whether to mark for calibration - :param str category: backend category whose parameters are marked + :param category: backend category whose parameters are marked + :param mark: whether to mark for calibration :raises: :py:class:`UnsupportedBackendError` if `category` is not among the supported backend categories """ @@ -398,12 +396,11 @@ def can_calibrate(self, category, mark): value.fixed = not mark log.debug("Setting %s/%s to fixed=%s for calibration", category, key, value.fixed) - def copy(self): + def copy(self) -> "Finder": """ Deep copy the current finder and its configuration. :returns: a copy of the current finder with identical configuration - :rtype: :py:class:`Finder` """ acopy = type(self)(synchronize=False) for category in self.params.keys(): @@ -426,25 +423,22 @@ def copy(self): return acopy - def find(self, needle, haystack): + def find(self, needle: "Target | list[Target]", haystack: "Image") -> "list[Match]": """ Find all needle targets in a haystack image. :param needle: image, text, pattern, or a list or chain of such to look for - :type needle: :py:class:`target.Target` or [:py:class:`target.Target`] :param haystack: image to look in - :type haystack: :py:class:`target.Image` :returns: all found matches (one in most use cases) - :rtype: [:py:class:`match.Match`] :raises: :py:class:`NotImplementedError` if the base class method is called """ raise NotImplementedError("Abstract method call - call implementation of this class") - def log(self, lvl): + def log(self, lvl: int) -> None: """ Log images with an arbitrary logging level. - :param int lvl: logging level for the message + :param lvl: logging level for the message """ # below selected logging level if lvl < self.imglog.logging_level: @@ -468,7 +462,7 @@ def log(self, lvl): class AutoPyFinder(Finder): """Simple matching backend provided by AutoPy.""" - def __init__(self, configure=True, synchronize=True): + def __init__(self, configure: bool = True, synchronize: bool = True) -> None: """Build a CV backend using AutoPy.""" super(AutoPyFinder, self).__init__(configure=False, synchronize=False) @@ -479,7 +473,8 @@ def __init__(self, configure=True, synchronize=True): if configure: self.__configure_backend(reset=True) - def __configure_backend(self, backend=None, category="autopy", reset=False): + def __configure_backend(self, backend: str = None, category: str = "autopy", + reset: bool = False) -> None: if category != "autopy": raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -488,7 +483,8 @@ def __configure_backend(self, backend=None, category="autopy", reset=False): self.params[category] = {} self.params[category]["backend"] = "none" - def configure_backend(self, backend=None, category="autopy", reset=False): + def configure_backend(self, backend: str = None, category: str = "autopy", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -496,12 +492,12 @@ def configure_backend(self, backend=None, category="autopy", reset=False): """ self.__configure_backend(backend, category, reset) - def find(self, needle, haystack): + def find(self, needle: "Image", haystack: "Image") -> "list[Match]": """ Custom implementation of the base method. :param needle: target iamge to search for - :type needle: :py:class:`Image` + :param haystack: image to look in See base method for details. @@ -572,7 +568,7 @@ class ContourFinder(Finder): the ones with area (size) similar to the searched image. """ - def __init__(self, configure=True, synchronize=True): + def __init__(self, configure: bool = True, synchronize: bool = True) -> None: """Build a CV backend using OpenCV's contour matching.""" super(ContourFinder, self).__init__(configure=False, synchronize=False) @@ -586,7 +582,8 @@ def __init__(self, configure=True, synchronize=True): if configure: self.__configure(reset=True) - def __configure_backend(self, backend=None, category="contour", reset=False): + def __configure_backend(self, backend: str = None, category: str = "contour", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -641,7 +638,8 @@ def __configure_backend(self, backend=None, category="contour", reset=False): self.params[category]["threshold1"] = CVParameter(100.0, 0.0, None, 50.0) self.params[category]["threshold2"] = CVParameter(1000.0, 0.0, None, 500.0) - def configure_backend(self, backend=None, category="contour", reset=False): + def configure_backend(self, backend: str = None, category: str = "contour", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -649,25 +647,25 @@ def configure_backend(self, backend=None, category="contour", reset=False): """ self.__configure_backend(backend, category, reset) - def __configure(self, threshold_filter=None, reset=True, **kwargs): + def __configure(self, threshold_filter: str = None, reset: bool = True, **kwargs: dict[str, type]) -> None: self.__configure_backend(category="contour", reset=reset) self.__configure_backend(threshold_filter, "threshold") - def configure(self, threshold_filter=None, reset=True, **kwargs): + def configure(self, threshold_filter: str = None, reset: bool = True, **kwargs: dict[str, type]) -> None: """ Custom implementation of the base method. :param threshold_filter: name of a preselected backend - :type threshold_filter: str or None + :paran reset: whether to (re)set all parent configurations as well """ self.__configure(threshold_filter, reset) - def find(self, needle, haystack): + def find(self, needle: "Image", haystack: "Image") -> "list[Match]": """ Custom implementation of the base method. :param needle: target iamge to search for - :type needle: :py:class:`Image` + :param haystack: image to look in See base method for details. @@ -754,7 +752,7 @@ def find(self, needle, haystack): self.imglog.log(30) return matches - def _binarize_image(self, image, log=False): + def _binarize_image(self, image: "Matlike", log: bool = False) -> "Matlike": import cv2 # blur first in order to avoid unwonted edges caused from noise blurSize = self.params["threshold"]["blurKernelSize"].value @@ -791,7 +789,7 @@ def _binarize_image(self, image, log=False): self.imglog.hotmaps.append(thresh_image) return thresh_image - def _extract_contours(self, countours_image, log=False): + def _extract_contours(self, countours_image: "Matlike", log: bool = False) -> "list[Matlike]": import cv2 rargs = cv2.findContours(countours_image, self.params["contour"]["retrievalMode"].value, @@ -806,7 +804,7 @@ def _extract_contours(self, countours_image, log=False): self.imglog.hotmaps.append(countours_image) return image_contours - def log(self, lvl): + def log(self, lvl: int) -> None: """ Custom implementation of the base method. @@ -839,7 +837,7 @@ def log(self, lvl): class TemplateFinder(Finder): """Template matching backend provided by OpenCV.""" - def __init__(self, configure=True, synchronize=True): + def __init__(self, configure: bool = True, synchronize: bool = True) -> None: """Build a CV backend using OpenCV's template matching.""" super(TemplateFinder, self).__init__(configure=False, synchronize=False) @@ -852,7 +850,8 @@ def __init__(self, configure=True, synchronize=True): if configure: self.__configure_backend(reset=True) - def __configure_backend(self, backend=None, category="template", reset=False): + def __configure_backend(self, backend: str = None, category: str = "template", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -874,7 +873,8 @@ def __configure_backend(self, backend=None, category="template", reset=False): self.params[category]["nocolor"] = CVParameter(False) log.log(9, "%s %s\n", category, self.params[category]) - def configure_backend(self, backend=None, category="template", reset=False): + def configure_backend(self, backend: str = None, category: str = "template", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -882,12 +882,12 @@ def configure_backend(self, backend=None, category="template", reset=False): """ self.__configure_backend(backend, category, reset) - def find(self, needle, haystack): + def find(self, needle: "Image", haystack: "Image") -> "list[Match]": """ Custom implementation of the base method. :param needle: target iamge to search for - :type needle: :py:class:`Image` + :param haystack: image to look in :raises: :py:class:`UnsupportedBackendError` if the choice of template matches is not among the supported ones @@ -985,7 +985,8 @@ def find(self, needle, haystack): return matches - def _match_template(self, needle, haystack, nocolor, method): + def _match_template(self, needle: "Image", haystack: "Image", nocolor: str, + method: str) -> "Matlike | None": """ EXTRA DOCSTRING: Template matching backend - wrapper. @@ -1017,7 +1018,7 @@ def _match_template(self, needle, haystack, nocolor, method): return match - def log(self, lvl): + def log(self, lvl: int) -> None: """ Custom implementation of the base method. @@ -1055,7 +1056,7 @@ class FeatureFinder(Finder): by default in newer OpenCV versions (>3.0). """ - def __init__(self, configure=True, synchronize=True): + def __init__(self, configure: bool = True, synchronize: bool = True) -> None: """Build a CV backend using OpenCV's feature matching.""" super(FeatureFinder, self).__init__(configure=False, synchronize=False) @@ -1085,7 +1086,8 @@ def __init__(self, configure=True, synchronize=True): if synchronize: self.__synchronize(reset=False) - def __configure_backend(self, backend=None, category="feature", reset=False): + def __configure_backend(self, backend: str = None, category: str = "feature", + reset: bool = False) -> None: if category not in ["feature", "fdetect", "fextract", "fmatch"]: raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -1185,7 +1187,8 @@ def __configure_backend(self, backend=None, category="feature", reset=False): self.params[category][param] = CVParameter(val) log.log(9, "%s=%s", param, val) - def configure_backend(self, backend=None, category="feature", reset=False): + def configure_backend(self, backend: str = None, category: str = "feature", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -1205,28 +1208,29 @@ def configure_backend(self, backend=None, category="feature", reset=False): """ self.__configure_backend(backend, category, reset) - def __configure(self, feature_detect=None, feature_extract=None, - feature_match=None, reset=True, **kwargs): + def __configure(self, feature_detect: str = None, feature_extract: str = None, + feature_match: str = None, reset: bool = True, + **kwargs: dict[str, type]) -> None: self.__configure_backend(category="feature", reset=reset) self.__configure_backend(feature_detect, "fdetect") self.__configure_backend(feature_extract, "fextract") self.__configure_backend(feature_match, "fmatch") - def configure(self, feature_detect=None, feature_extract=None, - feature_match=None, reset=True, **kwargs): + def configure(self, feature_detect: str = None, feature_extract: str = None, + feature_match: str = None, reset: bool = True, + **kwargs: dict[str, type]) -> None: """ Custom implementation of the base method. :param feature_detect: name of a preselected backend - :type feature_detect: str or None :param feature_extract: name of a preselected backend - :type feature_extract: str or None :param feature_match: name of a preselected backend - :type feature_match: str or None + :param reset: whether to (re)set all parent configurations as well """ self.__configure(feature_detect, feature_extract, feature_match, reset) - def __synchronize_backend(self, backend=None, category="feature", reset=False): + def __synchronize_backend(self, backend: str = None, category: str = "feature", + reset: bool = False) -> None: if category not in ["feature", "fdetect", "fextract", "fmatch"]: raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -1280,7 +1284,8 @@ def __synchronize_backend(self, backend=None, category="feature", reset=False): elif category == "fmatch": self.matcher = backend_obj - def synchronize_backend(self, backend=None, category="feature", reset=False): + def synchronize_backend(self, backend: str = None, category: str = "feature", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -1288,33 +1293,30 @@ def synchronize_backend(self, backend=None, category="feature", reset=False): """ self.__synchronize_backend(backend, category, reset) - def __synchronize(self, feature_detect=None, feature_extract=None, - feature_match=None, reset=True): + def __synchronize(self, feature_detect: str = None, feature_extract: str = None, + feature_match: str = None, reset: bool = True) -> None: self.__synchronize_backend(category="feature", reset=reset) self.__synchronize_backend(feature_detect, "fdetect") self.__synchronize_backend(feature_extract, "fextract") self.__synchronize_backend(feature_match, "fmatch") - def synchronize(self, feature_detect=None, feature_extract=None, - feature_match=None, reset=True): + def synchronize(self, feature_detect: str = None, feature_extract: str = None, + feature_match: str = None, reset: bool = True) -> None: """ Custom implementation of the base method. :param feature_detect: name of a preselected backend - :type feature_detect: str or None :param feature_extract: name of a preselected backend - :type feature_extract: str or None :param feature_match: name of a preselected backend - :type feature_match: str or None + : param reset: whether to (re)set all parent configurations as well """ self.__synchronize(feature_detect, feature_extract, feature_match, reset) - def find(self, needle, haystack): + def find(self, needle: "Image", haystack: "Image") -> "list[Match]": """ Custom implementation of the base method. :param needle: target iamge to search for - :type needle: :py:class:`Image` See base method for details. @@ -1358,7 +1360,8 @@ def find(self, needle, haystack): self.imglog.log(40) return [] - def _project_features(self, locations_in_needle, ngray, hgray, similarity): + def _project_features(self, locations_in_needle: list[tuple[int, int]], ngray: "Matlike", + hgray: "Matlike", similarity: float) -> list[tuple[int, int]] | None: """ EXTRA DOCSTRING: Feature matching backend - wrapper. @@ -1406,7 +1409,8 @@ def _project_features(self, locations_in_needle, ngray, hgray, similarity): self._log_features(30, self.imglog.locations, self.imglog.hotmaps[-1], 3, 0, 0, 255) return locations_in_haystack - def _detect_features(self, ngray, hgray, detect, extract): + def _detect_features(self, ngray: int, hgray: int, detect: str, + extract: str) -> tuple[list[Any], list[Any], list[Any], list[Any]]: """ EXTRA DOCSTRING: Feature matching backend - detection/extraction stage (1). @@ -1457,14 +1461,15 @@ def _detect_features(self, ngray, hgray, detect, extract): return (nkeypoints, ndescriptors, hkeypoints, hdescriptors) - def _match_features(self, nkeypoints, ndescriptors, - hkeypoints, hdescriptors, match): + def _match_features(self, nkeypoints: str, ndescriptors: str, + hkeypoints: str, hdescriptors: str, + match: str) -> tuple[list[Any], list[Any]]: """ EXTRA DOCSTRING: Feature matching backend - matching stage (2). Match two sets of keypoints based on their descriptors. """ - def ratio_test(matches): + def ratio_test(matches: list[Any]) -> list[Any]: """ The ratio test checks the first and second best match. If their ratio is close to 1.0, there are both good candidates for the @@ -1488,7 +1493,7 @@ def ratio_test(matches): log.log(9, "Ratio test result is %i/%i", len(matches2), len(matches)) return matches2 - def symmetry_test(nmatches, hmatches): + def symmetry_test(nmatches: list[Any], hmatches: list[Any]) -> list[Any]: """ Refines the matches with a symmetry test which extracts only the matches in agreement with both the haystack and needle @@ -1563,7 +1568,8 @@ def symmetry_test(nmatches, hmatches): return (match_nkeypoints, match_hkeypoints) - def _project_locations(self, locations_in_needle, mnkp, mhkp): + def _project_locations(self, locations_in_needle: list[tuple[int, int]], mnkp: list[Any], + mhkp: list[Any]) -> list[tuple[int, int]]: """ EXTRA DOCSTRING: Feature matching backend - projecting stage (3). @@ -1635,7 +1641,7 @@ def _project_locations(self, locations_in_needle, mnkp, mhkp): return projected - def log(self, lvl): + def log(self, lvl: int) -> None: """ Custom implementation of the base method. @@ -1669,7 +1675,9 @@ def log(self, lvl): self.imglog.clear() ImageLogger.step += 1 - def _log_features(self, lvl, locations, hotmap, radius=0, r=255, g=255, b=255): + def _log_features(self, lvl: int, locations: list[tuple[float, float]], hotmap: "Matlike", + radius: int = 0, r: int = 255, g: int = 255, + b: int = 255) -> None: if lvl < self.imglog.logging_level: return import cv2 @@ -1692,7 +1700,8 @@ class CascadeFinder(Finder): due to the cascade classifier API. """ - def __init__(self, classifier_datapath=".", configure=True, synchronize=True): + def __init__(self, classifier_datapath: str = ".", configure: bool = True, + synchronize: bool = True) -> None: """Build a CV backend using OpenCV's cascade matching options.""" super(CascadeFinder, self).__init__(configure=False, synchronize=False) @@ -1700,7 +1709,8 @@ def __init__(self, classifier_datapath=".", configure=True, synchronize=True): if configure: self.__configure_backend(reset=True) - def __configure_backend(self, backend=None, category="cascade", reset=False): + def __configure_backend(self, backend: str = None, category: str = "cascade", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -1720,7 +1730,8 @@ def __configure_backend(self, backend=None, category="cascade", reset=False): self.params[category]["minHeight"] = CVParameter(0, 0, None, 100.0) self.params[category]["maxHeight"] = CVParameter(1000, 0, None, 100.0) - def configure_backend(self, backend=None, category="cascade", reset=False): + def configure_backend(self, backend: str = None, category: str = "cascade", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -1728,12 +1739,11 @@ def configure_backend(self, backend=None, category="cascade", reset=False): """ self.__configure_backend(backend, category, reset) - def find(self, needle, haystack): + def find(self, needle: "Pattern", haystack: "Image") -> "list[Match]": """ Custom implementation of the base method. :param needle: target pattern (cascade) to search for - :type needle: :py:class:`Pattern` See base method for details. """ @@ -1788,7 +1798,7 @@ class TextFinder(ContourFinder): Neumann L., Matas J.: Real-Time Scene Text Localization and Recognition, CVPR 2012 """ - def __init__(self, configure=True, synchronize=True): + def __init__(self, configure: bool = True, synchronize: bool = True) -> None: """Build a CV backend using OpenCV's text matching options.""" super(TextFinder, self).__init__(configure=False, synchronize=False) @@ -1817,7 +1827,8 @@ def __init__(self, configure=True, synchronize=True): if synchronize: self.__synchronize(reset=False) - def __configure_backend(self, backend=None, category="text", reset=False): + def __configure_backend(self, backend: str = None, category: str = "text", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -1953,7 +1964,8 @@ def __configure_backend(self, backend=None, category="text", reset=False): # 0 (precise) or 3x3 or 5x5 (the latest only works with Euclidean distance CV_DIST_L2) self.params[category]["dt_mask_size"] = CVParameter(3, 0, 5, 8.0, 2.0) - def configure_backend(self, backend=None, category="text", reset=False): + def configure_backend(self, backend: str = None, category: str = "text", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -1961,9 +1973,9 @@ def configure_backend(self, backend=None, category="text", reset=False): """ self.__configure_backend(backend, category, reset) - def __configure(self, text_detector=None, text_recognizer=None, - threshold_filter=None, threshold_filter2=None, - threshold_filter3=None, reset=True): + def __configure(self, text_detector: str = None, text_recognizer: str = None, + threshold_filter: str = None, threshold_filter2: str = None, + threshold_filter3: str = None, reset: bool = True) -> None: self.__configure_backend(category="text", reset=reset) self.__configure_backend(text_detector, "tdetect") self.__configure_backend(text_recognizer, "ocr") @@ -1972,28 +1984,26 @@ def __configure(self, text_detector=None, text_recognizer=None, self.__configure_backend(threshold_filter2, "threshold2") self.__configure_backend(threshold_filter3, "threshold3") - def configure(self, text_detector=None, text_recognizer=None, - threshold_filter=None, threshold_filter2=None, - threshold_filter3=None, reset=True, **kwargs): + def configure(self, text_detector: str = None, text_recognizer: str = None, + threshold_filter: str = None, threshold_filter2: str = None, + threshold_filter3: str = None, reset: bool = True, + **kwargs: dict[str, type]) -> None: """ Custom implementation of the base method. :param text_detector: name of a preselected backend - :type text_detector: str or None :param text_recognizer: name of a preselected backend - :type text_recognizer: str or None :param threshold_filter: threshold filter for the text detection stage - :type threshold_filter: str or None :param threshold_filter2: additional threshold filter for the OCR stage - :type threshold_filter2: str or None :param threshold_filter3: additional threshold filter for distance transformation - :type threshold_filter3: str or None + :param reset: whether to (re)set all parent configurations as well """ self.__configure(text_detector, text_recognizer, threshold_filter, threshold_filter2, threshold_filter3, reset) - def __synchronize_backend(self, backend=None, category="text", reset=False): + def __synchronize_backend(self, backend: str = None, category: str = "text", + reset: bool = False) -> None: if category not in ["text", "tdetect", "ocr", "contour", "threshold", "threshold2", "threshold3"]: raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -2102,7 +2112,8 @@ def __synchronize_backend(self, backend=None, category="text", reset=False): else: raise ValueError("Invalid OCR backend '%s'" % backend) - def synchronize_backend(self, backend=None, category="text", reset=False): + def synchronize_backend(self, backend: str = None, category: str = "text", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -2110,9 +2121,9 @@ def synchronize_backend(self, backend=None, category="text", reset=False): """ self.__synchronize_backend(backend, category, reset) - def __synchronize(self, text_detector=None, text_recognizer=None, - threshold_filter=None, threshold_filter2=None, - threshold_filter3=None, reset=True): + def __synchronize(self, text_detector: str = None, text_recognizer: str = None, + threshold_filter: str = None, threshold_filter2: str = None, + threshold_filter3: str = None, reset: bool = True) -> None: self.__synchronize_backend(category="text", reset=reset) self.__synchronize_backend(text_detector, "tdetect") self.__synchronize_backend(text_recognizer, "ocr") @@ -2121,33 +2132,28 @@ def __synchronize(self, text_detector=None, text_recognizer=None, self.__synchronize_backend(threshold_filter2, "threshold2") self.__synchronize_backend(threshold_filter3, "threshold3") - def synchronize(self, text_detector=None, text_recognizer=None, - threshold_filter=None, threshold_filter2=None, - threshold_filter3=None, reset=True): + def synchronize(self, text_detector: str = None, text_recognizer: str = None, + threshold_filter: str = None, threshold_filter2: str = None, + threshold_filter3: str = None, reset: bool = True) -> None: """ Custom implementation of the base method. :param text_detector: name of a preselected backend - :type text_detector: str or None :param text_recognizer: name of a preselected backend - :type text_recognizer: str or None :param threshold_filter: threshold filter for the text detection stage - :type threshold_filter: str or None :param threshold_filter2: additional threshold filter for the OCR stage - :type threshold_filter2: str or None :param threshold_filter3: additional threshold filter for distance transformation - :type threshold_filter3: str or None + :param reset: whether to (re)set all parent configurations as well """ self.__synchronize(text_detector, text_recognizer, threshold_filter, threshold_filter2, threshold_filter3, reset) - def find(self, needle, haystack): + def find(self, needle: "Text", haystack: "Image") -> "list[Match]": """ Custom implementation of the base method. :param needle: target text to search for - :type needle: :py:class:`Text` See base method for details. """ @@ -2184,7 +2190,7 @@ def find(self, needle, haystack): log.debug("Recognizing text with %s", backend) from .match import Match matches = [] - def binarize_step(threshold, text_img): + def binarize_step(threshold: str, text_img: "Matlike") -> "Matlike": if self.params["ocr"]["binarize_text"].value: first_threshold = self.params["threshold"] self.params["threshold"] = self.params[threshold] @@ -2279,7 +2285,7 @@ def binarize_step(threshold, text_img): self.imglog.log(30) return matches - def _detect_text_boxes(self, haystack): + def _detect_text_boxes(self, haystack: "Image") -> list[list[int]]: import cv2 import numpy @@ -2350,7 +2356,7 @@ def _detect_text_boxes(self, haystack): return text_regions - def _detect_text_east(self, haystack): + def _detect_text_east(self, haystack: "Image") -> list[tuple[int, int, int, int]]: #:.. note:: source implementation by Adrian Rosebrock from his post: #: https://www.pyimagesearch.com/2018/08/20/opencv-text-detection-east-text-detector/ import cv2 @@ -2418,11 +2424,13 @@ def _detect_text_east(self, haystack): # nothing to do for just one region if len(region_queue) < 2: break - r1, flag1 = region_queue.pop(0) + rtuple = region_queue.pop(0) + r1: tuple[int, int, int, int] = rtuple[0] + flag1 = rtuple[1] if not flag1: continue for r2pair in region_queue: - r2, _ = r2pair + r2: tuple[int, int, int, int] = r2pair[0] # if the two regions intersect if (r1[0] < r2[0] + r2[2] and r1[0] + r1[2] > r2[0] and r1[1] < r2[1] + r2[3] and r1[1] + r1[3] > r2[1]): @@ -2438,7 +2446,7 @@ def _detect_text_east(self, haystack): logging.debug("A total of %s final text regions found", len(text_regions)) return text_regions - def _detect_text_erstat(self, haystack): + def _detect_text_erstat(self, haystack: "Image") -> list[tuple[int, int, int, int]]: import cv2 import numpy img = numpy.array(haystack.pil_image) @@ -2502,7 +2510,7 @@ def _detect_text_erstat(self, haystack): final_regions.append(r1) return final_regions - def _detect_text_contours(self, haystack): + def _detect_text_contours(self, haystack: "Image") -> list[tuple[int, int, int, int]]: import cv2 import numpy img = numpy.array(haystack.pil_image) @@ -2533,7 +2541,7 @@ def _detect_text_contours(self, haystack): else: cv2.rectangle(char_canvas, (x, y), (x+w, y+h), (0, 0, 0), 2) cv2.rectangle(char_canvas, (x, y), (x+w, y+h), (0, 0, 255), 1) - char_regions.append([x, y, w, h]) + char_regions.append((x, y, w, h)) char_regions = sorted(char_regions, key=lambda x: x[0]) # group characters into horizontally-correlated regions @@ -2557,7 +2565,7 @@ def _detect_text_contours(self, haystack): elif text_orientation == 1: is_text = y2 - (y1 + h1) < dy and y1 - (y2 + h2) < dy and abs(x1 - x2) < dx and abs(w1 - w2) < 2*dx if is_text: - region1 = [min(x1, x2), min(y1, y2), max(x1+w1, x2+w2)-min(x1, x2), max(y1+h1, y2+h2)-min(y1, y2)] + region1 = (min(x1, x2), min(y1, y2), max(x1+w1, x2+w2)-min(x1, x2), max(y1+h1, y2+h2)-min(y1, y2)) chars_for_text += 1 char_regions[j] = None if chars_for_text < min_chars_for_text: @@ -2572,7 +2580,7 @@ def _detect_text_contours(self, haystack): return text_regions - def _detect_text_components(self, haystack): + def _detect_text_components(self, haystack: "Image") -> list[tuple[int, int, int, int]]: import cv2 import numpy img = numpy.array(haystack.pil_image) @@ -2607,7 +2615,7 @@ def _detect_text_components(self, haystack): # myblobs.filter_blobs(325, 2000) # blob_count = myblobs.GetNumBlobs() - def log(self, lvl): + def log(self, lvl: int) -> None: """ Custom implementation of the base method. @@ -2658,7 +2666,7 @@ class TemplateFeatureFinder(TemplateFinder, FeatureFinder): would otherwise be distracting for the second stage feature matching. """ - def __init__(self, configure=True, synchronize=True): + def __init__(self, configure: bool = True, synchronize: bool = True) -> None: """Build a CV backend using OpenCV's template and feature matching.""" super(TemplateFeatureFinder, self).__init__(configure=False, synchronize=False) @@ -2670,7 +2678,8 @@ def __init__(self, configure=True, synchronize=True): if synchronize: FeatureFinder.synchronize(self, reset=False) - def __configure_backend(self, backend=None, category="tempfeat", reset=False): + def __configure_backend(self, backend: str = None, category: str = "tempfeat", + reset: bool = False) -> None: if category not in ["tempfeat", "template", "feature", "fdetect", "fextract", "fmatch"]: raise UnsupportedBackendError("Backend category '%s' is not supported" % category) elif category in ["feature", "fdetect", "fextract", "fmatch"]: @@ -2692,7 +2701,8 @@ def __configure_backend(self, backend=None, category="tempfeat", reset=False): self.params[category]["backend"] = backend self.params[category]["front_similarity"] = CVParameter(0.7, 0.0, 1.0) - def configure_backend(self, backend=None, category="tempfeat", reset=False): + def configure_backend(self, backend: str = None, category: str = "tempfeat", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -2700,8 +2710,9 @@ def configure_backend(self, backend=None, category="tempfeat", reset=False): """ self.__configure_backend(backend, category, reset) - def __configure(self, template_match=None, feature_detect=None, - feature_extract=None, feature_match=None, reset=True): + def __configure(self, template_match: str = None, feature_detect: str = None, + feature_extract: str = None, feature_match: str = None, + reset: bool = True) -> None: self.__configure_backend(category="tempfeat", reset=reset) self.__configure_backend(template_match, "template") self.__configure_backend(category="feature") @@ -2709,9 +2720,9 @@ def __configure(self, template_match=None, feature_detect=None, self.__configure_backend(feature_extract, "fextract") self.__configure_backend(feature_match, "fmatch") - def configure(self, template_match=None, feature_detect=None, - feature_extract=None, feature_match=None, - reset=True, **kwargs): + def configure(self, template_match: str = None, feature_detect: str = None, + feature_extract: str = None, feature_match: str = None, + reset: bool = True, **kwargs: dict[str, type]) -> None: """ Custom implementation of the base methods. @@ -2719,8 +2730,8 @@ def configure(self, template_match=None, feature_detect=None, """ self.__configure(template_match, feature_detect, feature_extract, feature_match, reset) - def synchronize(self, feature_detect=None, feature_extract=None, - feature_match=None, reset=True): + def synchronize(self, feature_detect: str = None, feature_extract: str = None, + feature_match: str = None, reset: bool = True) -> None: """ Custom implementation of the base method. @@ -2733,7 +2744,7 @@ def synchronize(self, feature_detect=None, feature_extract=None, feature_match=feature_match, reset=False) - def find(self, needle, haystack): + def find(self, needle: "Image", haystack: "Image") -> "list[Match]": """ Custom implementation of the base method. @@ -2863,7 +2874,7 @@ def find(self, needle, haystack): self.imglog.log(30) return matches - def log(self, lvl): + def log(self, lvl: int) -> None: """ Custom implementation of the base method. @@ -2915,7 +2926,8 @@ class DeepFinder(Finder): _cache = {} - def __init__(self, classifier_datapath=".", configure=True, synchronize=True): + def __init__(self, classifier_datapath: str = ".", configure: bool = True, + synchronize: bool = True) -> None: """Build a CV backend using OpenCV's text matching options.""" super(DeepFinder, self).__init__(configure=False, synchronize=False) @@ -2932,7 +2944,8 @@ def __init__(self, classifier_datapath=".", configure=True, synchronize=True): if synchronize: self.__synchronize_backend(reset=False) - def __configure_backend(self, backend=None, category="deep", reset=False): + def __configure_backend(self, backend: str = None, category: str = "deep", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -2960,7 +2973,8 @@ def __configure_backend(self, backend=None, category="deep", reset=False): # file to load pre-trained model weights from self.params[category]["model"] = CVParameter("") - def configure_backend(self, backend=None, category="deep", reset=False): + def configure_backend(self, backend: str = None, category: str = "deep", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -2968,7 +2982,8 @@ def configure_backend(self, backend=None, category="deep", reset=False): """ self.__configure_backend(backend, category, reset) - def __synchronize_backend(self, backend=None, category="deep", reset=False): + def __synchronize_backend(self, backend: str = None, category: str = "deep", + reset: bool = False) -> None: if category != "deep": raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -3037,7 +3052,8 @@ def __synchronize_backend(self, backend=None, category="deep", reset=False): else: raise ValueError("Invalid DL backend '%s'" % backend) - def synchronize_backend(self, backend=None, category="deep", reset=False): + def synchronize_backend(self, backend: str = None, category: str = "deep", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -3045,12 +3061,11 @@ def synchronize_backend(self, backend=None, category="deep", reset=False): """ self.__synchronize_backend(backend, category, reset) - def find(self, needle, haystack): + def find(self, needle: "Pattern", haystack: "Image") -> "list[Match]": """ Custom implementation of the base method. :param needle: target pattern (cascade) to search for - :type needle: :py:class:`Pattern` See base method for details. """ @@ -3072,6 +3087,7 @@ def find(self, needle, haystack): "is too unstable at present") assert backend == "pytorch", "Only PyTorch model zoo/garden is supported" import torch + classes: Callable[[Any], str] = None if needle.data_file is not None: with open(needle.data_file, "rt") as f: classes_list = [line.rstrip() for line in f.readlines()] @@ -3133,7 +3149,7 @@ def find(self, needle, haystack): self.imglog.log(30) return matches - def log(self, lvl): + def log(self, lvl: int) -> None: """ Custom implementation of the base method. @@ -3173,7 +3189,7 @@ class HybridFinder(Finder): the chain is reached. """ - def __init__(self, configure=True, synchronize=True): + def __init__(self, configure: bool = True, synchronize: bool = True) -> None: """Build a hybrid matcher.""" super(HybridFinder, self).__init__(configure=False, synchronize=False) @@ -3190,7 +3206,8 @@ def __init__(self, configure=True, synchronize=True): if synchronize: self.__synchronize_backend(reset=False) - def __configure_backend(self, backend=None, category="hybrid", reset=False): + def __configure_backend(self, backend: str = None, category: str = "hybrid", + reset: bool = False) -> None: if category != "hybrid": raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -3205,7 +3222,8 @@ def __configure_backend(self, backend=None, category="hybrid", reset=False): self.params[category] = {} self.params[category]["backend"] = backend - def configure_backend(self, backend=None, category="hybrid", reset=False): + def configure_backend(self, backend: str = None, category: str = "hybrid", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -3213,7 +3231,8 @@ def configure_backend(self, backend=None, category="hybrid", reset=False): """ self.__configure_backend(backend, category, reset) - def __synchronize_backend(self, backend=None, category="hybrid", reset=False): + def __synchronize_backend(self, backend: str = None, category: str = "hybrid", + reset: bool = False) -> None: if category != "hybrid": raise UnsupportedBackendError("Backend category '%s' is not supported" % category) if reset: @@ -3240,7 +3259,8 @@ def __synchronize_backend(self, backend=None, category="hybrid", reset=False): elif backend == "deep": self.matcher = DeepFinder() - def synchronize_backend(self, backend=None, category="hybrid", reset=False): + def synchronize_backend(self, backend: str = None, category: str = "hybrid", + reset: bool = False) -> None: """ Custom implementation of the base method. @@ -3248,7 +3268,7 @@ def synchronize_backend(self, backend=None, category="hybrid", reset=False): """ self.__synchronize_backend(backend, category, reset) - def find(self, needle, haystack): + def find(self, needle: "Image", haystack: "Image") -> "list[Match]": """ Custom implementation of the base method. diff --git a/guibot/guibot.py b/guibot/guibot.py index 9f613bda..47154436 100644 --- a/guibot/guibot.py +++ b/guibot/guibot.py @@ -31,6 +31,8 @@ from .fileresolver import FileResolver from .region import Region +from .controller import Controller +from .finder import Finder log = logging.getLogger('guibot') @@ -45,14 +47,12 @@ class GuiBot(Region): .. seealso:: Real API is inherited from :py:class:`region.Region`. """ - def __init__(self, dc=None, cv=None): + def __init__(self, dc: Controller = None, cv: Finder = None) -> None: """ Build a guibot object. :param dc: DC backend used for any display control - :type dc: :py:class:`controller.Controller` or None :param cv: CV backend used for any target finding - :type cv: :py:class:`finder.Finder` or None We will initialize with default region of full screen and default display control and computer vision backends if none are provided. @@ -61,19 +61,19 @@ def __init__(self, dc=None, cv=None): self.file_resolver = FileResolver() - def add_path(self, directory): + def add_path(self, directory: str) -> None: """ Add a path to the list of currently accessible paths if it wasn't already added. - :param str directory: path to add + :param directory: path to add """ self.file_resolver.add_path(directory) - def remove_path(self, directory): + def remove_path(self, directory: str) -> None: """ Remove a path from the list of currently accessible paths. - :param str directory: path to add + :param directory: path to add """ self.file_resolver.remove_path(directory) diff --git a/guibot/guibot_proxy.py b/guibot/guibot_proxy.py index 07d3429a..b30c0ca6 100644 --- a/guibot/guibot_proxy.py +++ b/guibot/guibot_proxy.py @@ -32,6 +32,7 @@ """ import re +from typing import Any try: import Pyro5 as pyro @@ -40,17 +41,17 @@ from . import errors from .guibot import GuiBot +from .finder import Finder +from .controller import Controller -def serialize_custom_error(class_obj): +def serialize_custom_error(class_obj: type) -> dict[str, "str | getset_descriptor | dictproxy"]: """ Serialization method for the :py:class:`errors.UnsupportedBackendError` which was chosen just as a sample. :param class_obj: class object for the serialized error class - :type class_obj: classobj :returns: serialization dictionary with the class name, arguments, and attributes - :rtype: {str, str or getset_descriptor or dictproxy} """ serialized = {} serialized["__class__"] = re.search("", str(type(class_obj))).group(1) @@ -59,7 +60,7 @@ def serialize_custom_error(class_obj): return serialized -def register_exception_serialization(): +def register_exception_serialization() -> None: """ We put here any exceptions that are too complicated for the default serialization and define their serialization methods. @@ -82,7 +83,7 @@ class GuiBotProxy(GuiBot): from code which is executed on another machine somewhere on the network. """ - def __init__(self, dc=None, cv=None): + def __init__(self, dc: Controller = None, cv: Finder = None) -> None: """Build a proxy guibot object of the original main guibot object.""" super(GuiBotProxy, self).__init__(dc=dc, cv=cv) # NOTE: the following attribute is set by PyRO when registering @@ -91,38 +92,38 @@ def __init__(self, dc=None, cv=None): # register exceptions as an extra step register_exception_serialization() - def _proxify(self, obj): + def _proxify(self, obj: Any) -> Any: if isinstance(obj, (int, float, bool, str)) or obj is None: return obj if obj not in self._pyroDaemon.objectsById.values(): self._pyroDaemon.register(obj) return obj - def nearby(self, *args, **kwargs): + def nearby(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).nearby(*args, **kwargs)) - def above(self, *args, **kwargs): + def above(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).above(*args, **kwargs)) - def below(self, *args, **kwargs): + def below(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).below(*args, **kwargs)) - def left(self, *args, **kwargs): + def left(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).left(*args, **kwargs)) - def right(self, *args, **kwargs): + def right(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).right(*args, **kwargs)) - def find(self, *args, **kwargs): + def find(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).find(*args, **kwargs)) - def find_all(self, *args, **kwargs): + def find_all(self, *args: tuple[int, ...], **kwargs: dict[str, type]) -> list[str]: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" matches = super(GuiBotProxy, self).find_all(*args, **kwargs) proxified = [] @@ -130,118 +131,118 @@ def find_all(self, *args, **kwargs): proxified.append(self._proxify(match)) return proxified - def sample(self, *args, **kwargs): + def sample(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).sample(*args, **kwargs)) - def exists(self, *args, **kwargs): + def exists(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).exists(*args, **kwargs)) - def wait(self, *args, **kwargs): + def wait(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).wait(*args, **kwargs)) - def wait_vanish(self, *args, **kwargs): + def wait_vanish(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).wait_vanish(*args, **kwargs)) - def idle(self, *args, **kwargs): + def idle(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).idle(*args, **kwargs)) - def hover(self, *args, **kwargs): + def hover(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).hover(*args, **kwargs)) - def click(self, *args, **kwargs): + def click(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).click(*args, **kwargs)) - def right_click(self, *args, **kwargs): + def right_click(self,*args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).right_click(*args, **kwargs)) - def middle_click(self, *args, **kwargs): + def middle_click(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).middle_click(*args, **kwargs)) - def double_click(self, *args, **kwargs): + def double_click(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).double_click(*args, **kwargs)) - def multi_click(self, *args, **kwargs): + def multi_click(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).multi_click(*args, **kwargs)) - def click_expect(self, *args, **kwargs): + def click_expect(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).click_expect(*args, **kwargs)) - def click_vanish(self, *args, **kwargs): + def click_vanish(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).click_vanish(*args, **kwargs)) - def click_at_index(self, *args, **kwargs): + def click_at_index(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).click_at_index(*args, **kwargs)) - def mouse_down(self, *args, **kwargs): + def mouse_down(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).mouse_down(*args, **kwargs)) - def mouse_up(self, *args, **kwargs): + def mouse_up(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).mouse_up(*args, **kwargs)) - def mouse_scroll(self, *args, **kwargs): + def mouse_scroll(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).mouse_scroll(*args, **kwargs)) - def drag_drop(self, *args, **kwargs): + def drag_drop(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).drag_drop(*args, **kwargs)) - def drag_from(self, *args, **kwargs): + def drag_from(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).drag_from(*args, **kwargs)) - def drop_at(self, *args, **kwargs): + def drop_at(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).drop_at(*args, **kwargs)) - def press_keys(self, *args, **kwargs): + def press_keys(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).press_keys(*args, **kwargs)) - def press_at(self, *args, **kwargs): + def press_at(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).press_at(*args, **kwargs)) - def press_expect(self, *args, **kwargs): + def press_expect(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).press_expect(*args, **kwargs)) - def press_vanish(self, *args, **kwargs): + def press_vanish(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).press_vanish(*args, **kwargs)) - def type_text(self, *args, **kwargs): + def type_text(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).type_text(*args, **kwargs)) - def type_at(self, *args, **kwargs): + def type_at(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).type_at(*args, **kwargs)) - def click_at(self, *args, **kwargs): + def click_at(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).click_at(*args, **kwargs)) - def fill_at(self, *args, **kwargs): + def fill_at(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).fill_at(*args, **kwargs)) - def select_at(self, *args, **kwargs): + def select_at(self, *args: tuple[type, ...], **kwargs: dict[str, type]) -> str: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" return self._proxify(super(GuiBotProxy, self).select_at(*args, **kwargs)) diff --git a/guibot/guibot_simple.py b/guibot/guibot_simple.py index 85315662..361ab0e2 100644 --- a/guibot/guibot_simple.py +++ b/guibot/guibot_simple.py @@ -32,16 +32,19 @@ from collections import namedtuple +from guibot.match import Match +from .location import Location +from .region import Region from .guibot import GuiBot # accessible attributes of this module guibot = None last_match = None -buttons = namedtuple('Buttons', ["mouse", "key", "mod"]) +buttons = namedtuple('buttons', ["mouse", "key", "mod"]) -def initialize(): +def initialize() -> None: """Initialize the simple API.""" global guibot guibot = GuiBot() @@ -54,211 +57,211 @@ def initialize(): buttons.mod = guibot.dc_backend.modmap -def check_initialized(): +def check_initialized() -> None: """Make sure the simple API is initialized.""" if guibot is None: raise AssertionError("Guibot module not initialized - run initialize() before using the simple API") -def add_path(*args, **kwargs): +def add_path(*args: tuple[type, ...], **kwargs: dict[str, type]) -> None: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() guibot.add_path(*args, **kwargs) -def remove_path(*args, **kwargs): +def remove_path(*args: tuple[type, ...], **kwargs: dict[str, type]) -> None: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() guibot.remove_path(*args, **kwargs) -def find(*args, **kwargs): +def find(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Region: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.find(*args, **kwargs) -def find_all(*args, **kwargs): +def find_all(*args: tuple[type, ...], **kwargs: dict[str, type]) -> list[Match]: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.find_all(*args, **kwargs) -def sample(*args, **kwargs): +def sample(*args: tuple[type, ...], **kwargs: dict[str, type]) -> float: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.sample(*args, **kwargs) -def exists(*args, **kwargs): +def exists(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Match | None: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.exists(*args, **kwargs) -def wait(*args, **kwargs): +def wait(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Region: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.wait(*args, **kwargs) -def wait_vanish(*args, **kwargs): +def wait_vanish(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Region: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.wait_vanish(*args, **kwargs) -def get_mouse_location(*args, **kwargs): +def get_mouse_location(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Location: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.get_mouse_location(*args, **kwargs) -def idle(*args, **kwargs): +def idle(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Region: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.idle(*args, **kwargs) -def hover(*args, **kwargs): +def hover(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Match | None: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.hover(*args, **kwargs) -def click(*args, **kwargs): +def click(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Match | None: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.click(*args, **kwargs) -def right_click(*args, **kwargs): +def right_click(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Match | None: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.right_click(*args, **kwargs) -def middle_click(*args, **kwargs): +def middle_click(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Match | None: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.middle_click(*args, **kwargs) -def double_click(*args, **kwargs): +def double_click(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Match | None: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.double_click(*args, **kwargs) -def multi_click(*args, **kwargs): +def multi_click(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Match | None: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.multi_click(*args, **kwargs) -def click_expect(*args, **kwargs): +def click_expect(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Match | Region: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.click_expect(*args, **kwargs) -def click_vanish(*args, **kwargs): +def click_vanish(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Region: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.click_vanish(*args, **kwargs) -def click_at_index(*args, **kwargs): +def click_at_index(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Match: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.click_at_index(*args, **kwargs) -def mouse_down(*args, **kwargs): +def mouse_down(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Match | None: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.mouse_down(*args, **kwargs) -def mouse_up(*args, **kwargs): +def mouse_up(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Match | None: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.mouse_up(*args, **kwargs) -def mouse_scroll(*args, **kwargs): +def mouse_scroll(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Match | None: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.mouse_scroll(*args, **kwargs) -def drag_drop(*args, **kwargs): +def drag_drop(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Match | None: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.drag_drop(*args, **kwargs) -def drag_from(*args, **kwargs): +def drag_from(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Region: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.drag_from(*args, **kwargs) -def drop_at(*args, **kwargs): +def drop_at(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Match: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.drop_at(*args, **kwargs) -def press_keys(*args, **kwargs): +def press_keys(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Region: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.press_keys(*args, **kwargs) -def press_at(*args, **kwargs): +def press_at(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Region: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.press_at(*args, **kwargs) -def press_expect(*args, **kwargs): +def press_expect(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Region: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.press_expect(*args, **kwargs) -def press_vanish(*args, **kwargs): +def press_vanish(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Region: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.press_vanish(*args, **kwargs) -def type_text(*args, **kwargs): +def type_text(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Region: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.type_text(*args, **kwargs) -def type_at(*args, **kwargs): +def type_at(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Region: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.type_at(*args, **kwargs) -def click_at(*args, **kwargs): +def click_at(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Region: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.click_at(*args, **kwargs) -def fill_at(*args, **kwargs): +def fill_at(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Region: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.fill_at(*args, **kwargs) -def select_at(*args, **kwargs): +def select_at(*args: tuple[type, ...], **kwargs: dict[str, type]) -> Region: """See :py:class:`guibot.guibot.GuiBot` and its inherited :py:class:`guibot.region.Region` for details.""" check_initialized() return guibot.select_at(*args, **kwargs) diff --git a/guibot/imagelogger.py b/guibot/imagelogger.py index c3745b36..9e460ac6 100644 --- a/guibot/imagelogger.py +++ b/guibot/imagelogger.py @@ -28,6 +28,7 @@ import os import shutil import PIL.Image +import numpy from .config import GlobalConfig @@ -52,14 +53,14 @@ class ImageLogger(object): accumulate_logging = False #: level for the image logging - logging_level = GlobalConfig.image_logging_level + logging_level: int = GlobalConfig.image_logging_level #: destination for the image logging in order to dump images #: (the executing code decides when to clean this directory) - logging_destination = GlobalConfig.image_logging_destination + logging_destination: str = GlobalConfig.image_logging_destination #: number of digits for the counter of logged steps - step_width = GlobalConfig.image_logging_step_width + step_width: int = GlobalConfig.image_logging_step_width - def __init__(self): + def __init__(self) -> None: """Build an imagelogger object.""" self.needle = None self.haystack = None @@ -74,37 +75,36 @@ def __init__(self): ImageLogger.logging_destination = GlobalConfig.image_logging_destination ImageLogger.step_width = GlobalConfig.image_logging_step_width - def get_printable_step(self): + def get_printable_step(self) -> str: """ Getter for readonly attribute. :returns: step number prepended with zeroes to obtain a fixed length enumeration - :rtype: str """ return ("%0" + str(ImageLogger.step_width) + "d") % ImageLogger.step printable_step = property(fget=get_printable_step) - def debug(self): + def debug(self) -> None: """Log images with a DEBUG logging level.""" self.log(10) - def info(self): + def info(self) -> None: """Log images with an INFO logging level.""" self.log(20) - def warning(self): + def warning(self) -> None: """Log images with a WARNING logging level.""" self.log(30) - def error(self): + def error(self) -> None: """Log images with an ERROR logging level.""" self.log(40) - def critical(self): + def critical(self) -> None: """Log images with a CRITICAL logging level.""" self.log(50) - def dump_matched_images(self): + def dump_matched_images(self) -> None: """ Write file with the current needle and haystack. @@ -131,13 +131,12 @@ def dump_matched_images(self): haystack_name) self.haystack.save(haystack_path) - def dump_hotmap(self, name, hotmap): + def dump_hotmap(self, name: str, hotmap: PIL.Image.Image | numpy.ndarray) -> None: """ Write a file the given hotmap. - :param str name: filename to use for the image + :param name: filename to use for the image :param hotmap: image (with matching results) to write - :type hotmap: :py:class:`PIL.Image` or :py:class:`numpy.ndarray` """ if ImageLogger.logging_level > 30: return @@ -155,7 +154,7 @@ def dump_hotmap(self, name, hotmap): pil_image = pil_image.convert('RGB') pil_image.save(path, compress_level=GlobalConfig.image_quality) - def clear(self): + def clear(self) -> None: """Clear all accumulated logging including hotmaps, similarities, and locations.""" self.needle = None self.haystack = None diff --git a/guibot/inputmap.py b/guibot/inputmap.py index f9f83c04..bb98b42b 100644 --- a/guibot/inputmap.py +++ b/guibot/inputmap.py @@ -26,94 +26,96 @@ """ +from typing import Any + + class Key(object): """Helper to contain all key mappings for a custom display control backend.""" - def __init__(self): + def __init__(self) -> None: """Build an instance containing an empty key map.""" - self.ENTER = None - self.TAB = None - self.ESC = None - self.BACKSPACE = None - self.DELETE = None - self.INSERT = None - - self.CTRL = None - self.ALT = None - self.SHIFT = None - self.META = None - self.RCTRL = None - self.RALT = None - self.RSHIFT = None - self.RMETA = None - - self.F1 = None - self.F2 = None - self.F3 = None - self.F4 = None - self.F5 = None - self.F6 = None - self.F7 = None - self.F8 = None - self.F9 = None - self.F10 = None - self.F11 = None - self.F12 = None - self.F13 = None - self.F14 = None - self.F15 = None - self.F16 = None - self.F17 = None - self.F18 = None - self.F19 = None - self.F20 = None - - self.HOME = None - self.END = None - self.LEFT = None - self.RIGHT = None - self.UP = None - self.DOWN = None - self.PAGE_DOWN = None - self.PAGE_UP = None - - self.CAPS_LOCK = None - self.PRINTSCREEN = None - self.PAUSE = None - self.SCROLL_LOCK = None - self.NUM_LOCK = None - self.SYS_REQ = None - self.SUPER = None - self.RSUPER = None - self.HYPER = None - self.RHYPER = None - self.MENU = None - - self.KP0 = None - self.KP1 = None - self.KP2 = None - self.KP3 = None - self.KP4 = None - self.KP5 = None - self.KP6 = None - self.KP7 = None - self.KP8 = None - self.KP9 = None - self.KP_ENTER = None - self.KP_DIVIDE = None - self.KP_MULTIPLY = None - self.KP_SUBTRACT = None - self.KP_ADD = None - self.KP_DECIMAL = None - - def to_string(self, key): + self.ENTER: Any = None + self.TAB: Any = None + self.ESC: Any = None + self.BACKSPACE: Any = None + self.DELETE: Any = None + self.INSERT: Any = None + + self.CTRL: Any = None + self.ALT: Any = None + self.SHIFT: Any = None + self.META: Any = None + self.RCTRL: Any = None + self.RALT: Any = None + self.RSHIFT: Any = None + self.RMETA: Any = None + + self.F1: Any = None + self.F2: Any = None + self.F3: Any = None + self.F4: Any = None + self.F5: Any = None + self.F6: Any = None + self.F7: Any = None + self.F8: Any = None + self.F9: Any = None + self.F10: Any = None + self.F11: Any = None + self.F12: Any = None + self.F13: Any = None + self.F14: Any = None + self.F15: Any = None + self.F16: Any = None + self.F17: Any = None + self.F18: Any = None + self.F19: Any = None + self.F20: Any = None + + self.HOME: Any = None + self.END: Any = None + self.LEFT: Any = None + self.RIGHT: Any = None + self.UP: Any = None + self.DOWN: Any = None + self.PAGE_DOWN: Any = None + self.PAGE_UP: Any = None + + self.CAPS_LOCK: Any = None + self.PRINTSCREEN: Any = None + self.PAUSE: Any = None + self.SCROLL_LOCK: Any = None + self.NUM_LOCK: Any = None + self.SYS_REQ: Any = None + self.SUPER: Any = None + self.RSUPER: Any = None + self.HYPER: Any = None + self.RHYPER: Any = None + self.MENU: Any = None + + self.KP0: Any = None + self.KP1: Any = None + self.KP2: Any = None + self.KP3: Any = None + self.KP4: Any = None + self.KP5: Any = None + self.KP6: Any = None + self.KP7: Any = None + self.KP8: Any = None + self.KP9: Any = None + self.KP_ENTER: Any = None + self.KP_DIVIDE: Any = None + self.KP_MULTIPLY: Any = None + self.KP_SUBTRACT: Any = None + self.KP_ADD: Any = None + self.KP_DECIMAL: Any = None + + def to_string(self, key: str) -> str: """ Provide with a text representation of a desired key according to the custom BC backend. - :param str key: selected key name according to the custom backend + :param key: selected key name according to the custom backend :returns: text representation of the selected key - :rtype: str :raises: :py:class:`ValueError` if `key` is not found in the current key map """ if key is None: @@ -192,7 +194,7 @@ def to_string(self, key): class AutoPyKey(Key): """Helper to contain all key mappings for the AutoPy DC backend.""" - def __init__(self): + def __init__(self) -> None: """Build an instance containing the key map for the AutoPy backend.""" super().__init__() @@ -279,7 +281,7 @@ def __init__(self): class XDoToolKey(Key): """Helper to contain all key mappings for the xdotool DC backend.""" - def __init__(self): + def __init__(self) -> None: """Build an instance containing the key map for the xdotool backend.""" super().__init__() @@ -365,7 +367,7 @@ def __init__(self): class VNCDoToolKey(Key): """Helper to contain all key mappings for the VNCDoTool DC backend.""" - def __init__(self): + def __init__(self) -> None: """Build an instance containing the key map for the VNCDoTool backend.""" super().__init__() @@ -453,7 +455,7 @@ def __init__(self): class PyAutoGUIKey(Key): """Helper to contain all key mappings for the PyAutoGUI DC backend.""" - def __init__(self): + def __init__(self) -> None: """Build an instance containing the key map for the PyAutoGUI backend.""" super().__init__() @@ -542,22 +544,21 @@ def __init__(self): class KeyModifier(object): """Helper to contain all modifier key mappings for a custom display control backend.""" - def __init__(self): + def __init__(self) -> None: """Build an instance containing an empty modifier key map.""" - self.MOD_NONE = None - self.MOD_CTRL = None - self.MOD_ALT = None - self.MOD_SHIFT = None - self.MOD_META = None + self.MOD_NONE: Any = None + self.MOD_CTRL: Any = None + self.MOD_ALT: Any = None + self.MOD_SHIFT: Any = None + self.MOD_META: Any = None - def to_string(self, key): + def to_string(self, key: str) -> str: """ Provide with a text representation of a desired modifier key according to the custom BC backend. - :param str key: selected modifier name according to the current backend + :param key: selected modifier name according to the current backend :returns: text representation of the selected modifier - :rtype: str :raises: :py:class:`ValueError` if `key` is not found in the current modifier map """ if key is None: @@ -572,7 +573,7 @@ def to_string(self, key): class AutoPyKeyModifier(KeyModifier): """Helper to contain all modifier key mappings for the AutoPy DC backend.""" - def __init__(self): + def __init__(self) -> None: """Build an instance containing the modifier key map for the AutoPy backend.""" super().__init__() @@ -589,7 +590,7 @@ def __init__(self): class XDoToolKeyModifier(KeyModifier): """Helper to contain all modifier key mappings for the xdotool DC backend.""" - def __init__(self): + def __init__(self) -> None: """Build an instance containing the modifier key map for the xdotool backend.""" super().__init__() @@ -604,7 +605,7 @@ def __init__(self): class VNCDoToolKeyModifier(KeyModifier): """Helper to contain all modifier key mappings for the VNCDoTool DC backend.""" - def __init__(self): + def __init__(self) -> None: """Build an instance containing the modifier key map for the VNCDoTool backend.""" super().__init__() @@ -619,7 +620,7 @@ def __init__(self): class PyAutoGUIKeyModifier(KeyModifier): """Helper to contain all modifier key mappings for the PyAutoGUI DC backend.""" - def __init__(self): + def __init__(self) -> None: """Build an instance containing the modifier key map for the PyAutoGUI backend.""" super().__init__() @@ -635,20 +636,19 @@ def __init__(self): class MouseButton(object): """Helper to contain all mouse button mappings for a custom display control backend.""" - def __init__(self): + def __init__(self) -> None: """Build an instance containing an empty mouse button map.""" - self.LEFT_BUTTON = None - self.RIGHT_BUTTON = None - self.CENTER_BUTTON = None + self.LEFT_BUTTON: Any = None + self.RIGHT_BUTTON: Any = None + self.CENTER_BUTTON: Any = None - def to_string(self, key): + def to_string(self, key: str) -> str: """ Provide with a text representation of a desired mouse button according to the custom BC backend. - :param str key: selected mouse button according to the current backend + :param key: selected mouse button according to the current backend :returns: text representation of the selected mouse button - :rtype: str :raises: :py:class:`ValueError` if `key` is not found in the current mouse map """ if key is None: @@ -661,7 +661,7 @@ def to_string(self, key): class AutoPyMouseButton(MouseButton): """Helper to contain all mouse button mappings for the AutoPy DC backend.""" - def __init__(self): + def __init__(self) -> None: """Build an instance containing the mouse button map for the AutoPy backend.""" super().__init__() @@ -675,7 +675,7 @@ def __init__(self): class XDoToolMouseButton(MouseButton): """Helper to contain all mouse button mappings for the xdotool DC backend.""" - def __init__(self): + def __init__(self) -> None: """Build an instance containing the mouse button map for the xdotool backend.""" super().__init__() @@ -687,7 +687,7 @@ def __init__(self): class VNCDoToolMouseButton(MouseButton): """Helper to contain all mouse button mappings for the VNCDoTool DC backend.""" - def __init__(self): + def __init__(self) -> None: """Build an instance containing the mouse button map for the VNCDoTool backend.""" super().__init__() @@ -699,7 +699,7 @@ def __init__(self): class PyAutoGUIMouseButton(MouseButton): """Helper to contain all mouse button mappings for the PyAutoGUI DC backend.""" - def __init__(self): + def __init__(self) -> None: """Build an instance containing the mouse button map for the PyAutoGUI backend.""" super().__init__() diff --git a/guibot/location.py b/guibot/location.py index edcdec1f..d6668e4b 100644 --- a/guibot/location.py +++ b/guibot/location.py @@ -32,36 +32,34 @@ class Location(object): """Simple location on a 2D surface, region, or screen.""" - def __init__(self, xpos=0, ypos=0): + def __init__(self, xpos: int = 0, ypos: int = 0) -> None: """ Build a location object. - :param int xpos: x coordinate of the location - :param int ypos: y coordinate of the location + :param xpos: x coordinate of the location + :param ypos: y coordinate of the location """ self._xpos = xpos self._ypos = ypos - def __str__(self): + def __str__(self) -> str: """Provide a compact form for the location.""" return "(%s, %s)" % (self._xpos, self._ypos) - def get_x(self): + def get_x(self) -> int: """ Getter for readonly attribute. :returns: x coordinate of the location - :rtype: int """ return self._xpos x = property(fget=get_x) - def get_y(self): + def get_y(self) -> int: """ Getter for readonly attribute. :returns: y coordinate of the location - :rtype: int """ return self._ypos y = property(fget=get_y) diff --git a/guibot/match.py b/guibot/match.py index b92efb10..26fc930f 100644 --- a/guibot/match.py +++ b/guibot/match.py @@ -37,18 +37,19 @@ class Match(Region): of matches on a screen. """ - def __init__(self, xpos, ypos, width, height, dx=0, dy=0, - similarity=0.0, dc=None, cv=None): + def __init__(self, xpos: int, ypos: int, width: int, height: int, + dx: int = 0, dy: int = 0, similarity: float = 0.0, + dc: Controller = None, cv: "Finder" = None) -> None: """ Build a match object. - :param int xpos: x coordinate of the upleft vertex of the match region - :param int ypos: y coordinate of the upleft vertex of the match region - :param int width: x distance from upleft to downright vertex of the match region - :param int height: y distance from upleft to downright vertex of the match region - :param int dx: x offset from the center of the match region - :param int dy: y offset from the center of the match region - :param float similarity: attained similarity of the match region + :param xpos: x coordinate of the upleft vertex of the match region + :param ypos: y coordinate of the upleft vertex of the match region + :param width: x distance from upleft to downright vertex of the match region + :param height: y distance from upleft to downright vertex of the match region + :param dx: x offset from the center of the match region + :param dy: y offset from the center of the match region + :param similarity: attained similarity of the match region """ dc = Controller() if dc is None else dc cv = Finder() if cv is None else cv @@ -59,88 +60,81 @@ def __init__(self, xpos, ypos, width, height, dx=0, dy=0, self._similarity = similarity self._dx, self._dy = dx, dy - def __str__(self): + def __str__(self) -> str: """Provide the target location of the match distinguishing it from any location.""" return "%s (match)" % self.target - def set_x(self, value): + def set_x(self, value: int) -> None: """ Setter for previously readonly attribute. Necessary to override match location in a subregion (displaced). :param value: x coordinate of the upleft vertex of the region - :type value: int """ self._xpos = value x = property(fget=Region.get_x, fset=set_x) - def set_y(self, value): + def set_y(self, value: int) -> None: """ Setter for previously readonly attribute. Necessary to override match location in a subregion (displaced). :param value: y coordinate of the upleft vertex of the region - :type value: int """ self._ypos = value y = property(fget=Region.get_y, fset=set_y) - def get_dx(self): + def get_dx(self) -> int: """ Getter for readonly attribute. :returns: x offset from the center of the match region - :rtype: int """ return self._dx dx = property(fget=get_dx) - def get_dy(self): + def get_dy(self) -> int: """ Getter for readonly attribute. :returns: y offset from the center of the match region - :rtype: int """ return self._dy dy = property(fget=get_dy) - def get_similarity(self): + def get_similarity(self) -> float: """ Getter for readonly attribute. :returns: similarity the match was obtained with - :rtype: float """ return self._similarity similarity = property(fget=get_similarity) - def get_target(self): + def get_target(self) -> Location: """ Getter for readonly attribute. :returns: target location to click on if clicking on the match - :rtype: :py:class:`location.Location` """ return self.calc_click_point(self._xpos, self._ypos, self._width, self._height, Location(self._dx, self._dy)) target = property(fget=get_target) - def calc_click_point(self, xpos, ypos, width, height, offset): + def calc_click_point(self, xpos: int, ypos: int, width: int, height: int, + offset: Location) -> Location: """ Calculate target location to click on if clicking on the match. - :param int xpos: x coordinate of upleft vertex of the match region - :param int ypos: y coordinate of upleft vertex of the match region - :param int width: width of the match region - :param int height: height of the match region + :param xpos: x coordinate of upleft vertex of the match region + :param ypos: y coordinate of upleft vertex of the match region + :param width: width of the match region + :param height: height of the match region :param offset: offset from the match region center for the final target - :type offset: :py:class:`location.Location` :returns: target location to click on if clicking on the match - :rtype: :py:class:`location.Location` """ center_region = Region(0, 0, width, height, dc=self.dc_backend, cv=self.cv_backend) diff --git a/guibot/region.py b/guibot/region.py index 7f3a85f0..44b6f323 100644 --- a/guibot/region.py +++ b/guibot/region.py @@ -52,19 +52,17 @@ class Region(object): validation of expected images, and mouse and keyboard control. """ - def __init__(self, xpos=0, ypos=0, width=0, height=0, - dc=None, cv=None): + def __init__(self, xpos: int = 0, ypos: int = 0, width: int = 0, height: int = 0, + dc: Controller = None, cv: "Finder" = None) -> None: """ Build a region object from upleft to downright vertex coordinates. - :param int xpos: x coordinate of the upleft vertex of the region - :param int ypos: y coordinate of the upleft vertex of the region - :param int width: width of the region (xpos+width for downright vertex x) - :param int height: height of the region (ypos+height for downright vertex y) + :param xpos: x coordinate of the upleft vertex of the region + :param ypos: y coordinate of the upleft vertex of the region + :param width: width of the region (xpos+width for downright vertex x) + :param height: height of the region (ypos+height for downright vertex y) :param dc: DC backend used for any display control - :type dc: :py:class:`controller.Controller` or None :param cv: CV backend used for any target finding - :type cv: :py:class:`finder.Finder` or None :raises: :py:class:`UninitializedBackendError` if the region is empty If any of the backends is not defined a new one will be initiated @@ -138,7 +136,7 @@ def __init__(self, xpos=0, ypos=0, width=0, height=0, if modifier_key.startswith('MOD_'): setattr(self, modifier_key, getattr(mod_map, modifier_key)) - def _ensure_screen_clipping(self): + def _ensure_screen_clipping(self) -> None: screen_width = self.dc_backend.width screen_height = self.dc_backend.height @@ -160,52 +158,47 @@ def _ensure_screen_clipping(self): if self._ypos + self._height > screen_height: self._height = screen_height - self._ypos - def get_x(self): + def get_x(self) -> int: """ Getter for readonly attribute. :returns: x coordinate of the upleft vertex of the region - :rtype: int """ return self._xpos x = property(fget=get_x) - def get_y(self): + def get_y(self) -> int: """ Getter for readonly attribute. :returns: y coordinate of the upleft vertex of the region - :rtype: int """ return self._ypos y = property(fget=get_y) - def get_width(self): + def get_width(self) -> int: """ Getter for readonly attribute. :returns: width of the region (xpos+width for downright vertex x) - :rtype: int """ return self._width width = property(fget=get_width) - def get_height(self): + def get_height(self) -> int: """ Getter for readonly attribute. :returns: height of the region (ypos+height for downright vertex y) - :rtype: int """ return self._height height = property(fget=get_height) - def get_center(self): + def get_center(self) -> Location: """ Getter for readonly attribute. :returns: center of the region - :rtype: :py:class:`location.Location` """ xpos = self._xpos + int(self._width / 2) ypos = self._ypos + int(self._height / 2) @@ -213,86 +206,78 @@ def get_center(self): return Location(xpos, ypos) center = property(fget=get_center) - def get_top_left(self): + def get_top_left(self) -> Location: """ Getter for readonly attribute. :returns: upleft vertex of the region - :rtype: :py:class:`location.Location` """ return Location(self._xpos, self._ypos) top_left = property(fget=get_top_left) - def get_top_right(self): + def get_top_right(self) -> Location: """ Getter for readonly attribute. :returns: upright vertex of the region - :rtype: :py:class:`location.Location` """ return Location(self._xpos + self._width, self._ypos) top_right = property(fget=get_top_right) - def get_bottom_left(self): + def get_bottom_left(self) -> Location: """ Getter for readonly attribute. :returns: downleft vertex of the region - :rtype: :py:class:`location.Location` """ return Location(self._xpos, self._ypos + self._height) bottom_left = property(fget=get_bottom_left) - def get_bottom_right(self): + def get_bottom_right(self) -> Location: """ Getter for readonly attribute. :returns: downright vertex of the region - :rtype: :py:class:`location.Location` """ return Location(self._xpos + self._width, self._ypos + self._height) bottom_right = property(fget=get_bottom_right) - def is_empty(self): + def is_empty(self) -> bool: """ Getter for readonly attribute. :returns: whether the region is empty, i.e. has zero size - :rtype: bool """ return self._width == 0 and self._height == 0 is_empty = property(fget=is_empty) - def get_last_match(self): + def get_last_match(self) -> "Match": """ Getter for readonly attribute. :returns: last match obtained from finding a target within the region - :rtype: :py:class:`match.Match` """ return self._last_match last_match = property(fget=get_last_match) - def get_mouse_location(self): + def get_mouse_location(self) -> Location: """ Getter for readonly attribute. :returns: mouse location - :rtype: :py:class:`location.Location` """ return self.dc_backend.mouse_location mouse_location = property(fget=get_mouse_location) """Main region methods""" - def nearby(self, rrange=50): + def nearby(self, rrange: int = 50) -> "Region": """ Obtain a region containing the previous one but enlarged by a number of pixels on each side. - :param int rrange: number of pixels to add + :param rrange: number of pixels to add :returns: new region enlarged by `rrange` on all sides - :rtype: :py:class:`Region` """ log.debug("Checking nearby the current region") new_xpos = self._xpos - rrange @@ -310,14 +295,13 @@ def nearby(self, rrange=50): return Region(new_xpos, new_ypos, new_width, new_height, self.dc_backend, self.cv_backend) - def above(self, rrange=0): + def above(self, rrange: int = 0) -> "Region": """ Obtain a region containing the previous one but enlarged by a number of pixels on the upper side. - :param int rrange: number of pixels to add + :param rrange: number of pixels to add :returns: new region enlarged by `rrange` on upper side - :rtype: :py:class:`Region` """ log.debug("Checking above the current region") if rrange == 0: @@ -334,14 +318,13 @@ def above(self, rrange=0): return Region(self._xpos, new_ypos, self._width, new_height, self.dc_backend, self.cv_backend) - def below(self, rrange=0): + def below(self, rrange: int = 0) -> "Region": """ Obtain a region containing the previous one but enlarged by a number of pixels on the lower side. - :param int rrange: number of pixels to add + :param rrange: number of pixels to add :returns: new region enlarged by `rrange` on lower side - :rtype: :py:class:`Region` """ log.debug("Checking below the current region") if rrange == 0: @@ -353,14 +336,13 @@ def below(self, rrange=0): return Region(self._xpos, self._ypos, self._width, new_height, self.dc_backend, self.cv_backend) - def left(self, rrange=0): + def left(self, rrange: int = 0) -> "Region": """ Obtain a region containing the previous one but enlarged by a number of pixels on the left side. - :param int rrange: number of pixels to add + :param rrange: number of pixels to add :returns: new region enlarged by `rrange` on left side - :rtype: :py:class:`Region` """ log.debug("Checking left of the current region") if rrange == 0: @@ -377,14 +359,13 @@ def left(self, rrange=0): return Region(new_xpos, self._ypos, new_width, self._height, self.dc_backend, self.cv_backend) - def right(self, rrange=0): + def right(self, rrange: int = 0) -> "Region": """ Obtain a region containing the previous one but enlarged by a number of pixels on the right side. - :param int rrange: number of pixels to add + :param rrange: number of pixels to add :returns: new region enlarged by `rrange` on right side - :rtype: :py:class:`Region` """ log.debug("Checking right of the current region") if rrange == 0: @@ -398,15 +379,13 @@ def right(self, rrange=0): """Image expect methods""" - def find(self, target, timeout=10): + def find(self, target: str | Target, timeout: int = 10) -> "Match": """ Find a target on the screen. :param target: target to look for - :type target: str or :py:class:`target.Target` - :param int timeout: timeout before giving up + :param timeout: timeout before giving up :returns: match obtained from finding the target within the region - :rtype: :py:class:`match.Match` This method is the main entrance to all our target finding capabilities and is the milestone for all target expect methods. @@ -414,16 +393,15 @@ def find(self, target, timeout=10): matches = self.find_all(target, timeout=timeout, allow_zero=False) return matches[0] - def find_all(self, target, timeout=10, allow_zero=False): + def find_all(self, target: str | Target, timeout: int = 10, + allow_zero: bool = False) -> "list[Match]": """ Find multiples of a target on the screen. :param target: target to look for - :type target: str or :py:class:`target.Target` - :param int timeout: timeout before giving up - :param bool allow_zero: whether to allow zero matches or raise error + :param timeout: timeout before giving up + :param allow_zero: whether to allow zero matches or raise error :returns: matches obtained from finding the target within the region - :rtype: [:py:class:`match.Match`] :raises: :py:class:`errors.FindError` if no matches are found and zero matches are not allowed @@ -459,14 +437,14 @@ def find_all(self, target, timeout=10, allow_zero=False): moving_targets = True last_matches.append(new_match) self._last_match = last_matches[-1] - if not GlobalConfig.wait_for_animations or not moving_targets: + if not GlobalConfig.wait_for_animations == True or not moving_targets: return last_matches elif time.time() > timeout_limit: if allow_zero: return last_matches else: - if GlobalConfig.save_needle_on_error: + if GlobalConfig.save_needle_on_error == True: if not os.path.exists(ImageLogger.logging_destination): os.mkdir(ImageLogger.logging_destination) dump_path = GlobalConfig.image_logging_destination @@ -480,7 +458,7 @@ def find_all(self, target, timeout=10, allow_zero=False): # don't hog the CPU time.sleep(GlobalConfig.rescan_speed_on_find) - def _target_from_string(self, target_str): + def _target_from_string(self, target_str: str) -> Target: # handle some specific target types try: # guess from a match file has the highest precedence @@ -495,7 +473,7 @@ def _target_from_string(self, target_str): # if anything else goes wrong fail on the default type return self.default_target_type(target_str) - def _determine_cv_backend(self, target): + def _determine_cv_backend(self, target: Target) -> "Match": if target.use_own_settings: log.debug("Using special settings to match %s", target) return target.match_settings @@ -509,15 +487,13 @@ def _determine_cv_backend(self, target): target.match_settings = self.cv_backend return self.cv_backend - def sample(self, target): + def sample(self, target: str | Target) -> float: """ Sample the similarity between a target and the screen, i.e. an empirical probability that the target is on the screen. :param target: target to look for - :type target: str or :py:class:`target.Target` :returns: similarity with best match on the screen - :rtype: float .. note:: Not all matchers support a 'similarity' value. The ones that don't will return zero similarity (similarly to the target logging case). @@ -533,17 +509,15 @@ def sample(self, target): similarity = match.similarity return similarity - def exists(self, target, timeout=0): + def exists(self, target: str | Target, timeout: int = 0) -> "Match | None": """ Check if a target exists on the screen using the matching success as a threshold for the existence. :param target: target to look for - :type target: str or :py:class:`target.Target` - :param int timeout: timeout before giving up + :param timeout: timeout before giving up :returns: match obtained from finding the target within the region or nothing if no match is found - :rtype: :py:class:`match.Match` or None """ log.info("Checking if %s is present", target) try: @@ -552,31 +526,27 @@ def exists(self, target, timeout=0): log.info("%s is not present", target) return None - def wait(self, target, timeout=30): + def wait(self, target: str | Target, timeout: int = 30) -> "Match": """ Wait for a target to appear (be matched) with a given timeout as failing tolerance. :param target: target to look for - :type target: str or :py:class:`target.Target` - :param int timeout: timeout before giving up + :param timeout: timeout before giving up :returns: match obtained from finding the target within the region - :rtype: :py:class:`match.Match` :raises: :py:class:`errors.FindError` if no match is found """ log.info("Waiting for %s", target) return self.find(target, timeout) - def wait_vanish(self, target, timeout=30): + def wait_vanish(self, target: str | Target, timeout: int = 30) -> "Region": """ Wait for a target to disappear (be unmatched, i.e. matched without success) with a given timeout as failing tolerance. :param target: target to look for - :type target: str or :py:class:`target.Target` - :param int timeout: timeout before giving up + :param timeout: timeout before giving up :returns: self - :rtype: :py:class:`Region` :raises: :py:class:`errors.NotFindError` if match is still found """ log.info("Waiting for %s to vanish", target) @@ -591,13 +561,12 @@ def wait_vanish(self, target, timeout=30): # target is still there raise NotFindError(target) - def idle(self, timeout): + def idle(self, timeout: int) -> "Region": """ Wait for a number of seconds and continue the nested call chain. :param int timeout: timeout to wait for :returns: self - :rtype: :py:class:`Region` This method can be used as both a way to compactly wait for some time while not breaking the call chain. e.g.:: @@ -612,15 +581,12 @@ def idle(self, timeout): """Mouse methods""" - def hover(self, target_or_location): + def hover(self, target_or_location: "Match | Location | str | Target") -> "Match | None": """ Hover the mouse over a target or location. :param target_or_location: target or location to hover to - :type target_or_location: :py:class:`match.Match` or :py:class:`location.Location` or - str or :py:class:`target.Target` :returns: match from finding the target or nothing if hovering over a known location - :rtype: :py:class:`match.Match` or None """ log.info("Hovering over %s", target_or_location) smooth = GlobalConfig.smooth_mouse_drag @@ -642,19 +608,16 @@ def hover(self, target_or_location): return match - def click(self, target_or_location, modifiers=None): + def click(self, target_or_location: "Match | Location | str | Target", + modifiers: list[str] = None) -> "Match | None": """ Click on a target or location using the left mouse button and optionally holding special keys. :param target_or_location: target or location to click on - :type target_or_location: :py:class:`match.Match` or :py:class:`location.Location` or - str or :py:class:`target.Target` :param modifiers: special keys to hold during clicking (see :py:class:`inputmap.KeyModifier` for extensive list) - :type modifiers: [str] :returns: match from finding the target or nothing if clicking on a known location - :rtype: :py:class:`match.Match` or None The special keys refer to a list of key modifiers, e.g.:: @@ -667,7 +630,8 @@ def click(self, target_or_location, modifiers=None): self.dc_backend.mouse_click(self.LEFT_BUTTON, 1, modifiers) return match - def right_click(self, target_or_location, modifiers=None): + def right_click(self, target_or_location: "Match | Location | str | Target", + modifiers: list[str] = None) -> "Match | None": """ Click on a target or location using the right mouse button and optionally holding special keys. @@ -681,7 +645,8 @@ def right_click(self, target_or_location, modifiers=None): self.dc_backend.mouse_click(self.RIGHT_BUTTON, 1, modifiers) return match - def middle_click(self, target_or_location, modifiers=None): + def middle_click(self, target_or_location: "Match | Location | str | Target", + modifiers: list[str] = None) -> "Match | None": """ Click on a target or location using the middle mouse button and optionally holding special keys. @@ -695,7 +660,8 @@ def middle_click(self, target_or_location, modifiers=None): self.dc_backend.mouse_click(self.CENTER_BUTTON, 1, modifiers) return match - def double_click(self, target_or_location, modifiers=None): + def double_click(self, target_or_location: "Match | Location | str | Target", + modifiers: list[str] = None) -> "Match | None": """ Double click on a target or location using the left mouse button and optionally holding special keys. @@ -709,7 +675,8 @@ def double_click(self, target_or_location, modifiers=None): self.dc_backend.mouse_click(self.LEFT_BUTTON, 2, modifiers) return match - def multi_click(self, target_or_location, count=3, modifiers=None): + def multi_click(self, target_or_location: "Match | Location | str | Target", + count: int = 3, modifiers: list[str] = None) -> "Match | None": """ Click N times on a target or location using the left mouse button and optionally holding special keys. @@ -723,21 +690,18 @@ def multi_click(self, target_or_location, count=3, modifiers=None): self.dc_backend.mouse_click(self.LEFT_BUTTON, count, modifiers) return match - def click_expect(self, click_image_or_location, expect_target, - modifiers=None, timeout=60, retries=3): + def click_expect(self, click_image_or_location: Image | Location, + expect_target: str | Target, modifiers: list[str] = None, + timeout: int = 60, retries: int = 3) -> "Match | Region": """ Click on an image or location and wait for another one to appear. :param click_image_or_location: image or location to click on - :type click_image_or_location: Image or Location :param expect_target: target to wait for - :type expect_target: :type target: str or :py:class:`target.Target` :param modifiers: key modifiers when clicking - :type modifiers: [Key] or None - :param int timeout: time in seconds to wait for - :param int retries: number of retries to reach expected target behavior + :param timeout: time in seconds to wait for + :param retries: number of retries to reach expected target behavior :returns: match obtained from finding the second target within the region - :rtype: :py:class:`match.Match` """ for i in range(retries): if i > 0: @@ -749,22 +713,20 @@ def click_expect(self, click_image_or_location, expect_target, self.hover(Location(0, 0)) if i == retries - 1: raise error + return self - def click_vanish(self, click_image_or_location, expect_target, - modifiers=None, timeout=60, retries=3): + def click_vanish(self, click_image_or_location: Image | Location, + expect_target: str | Target, modifiers: list[str] = None, + timeout: int = 60, retries: int = 3) -> "Region": """ Click on an image or location and wait for another one to disappear. :param click_image_or_location: image or location to click on - :type click_image_or_location: Image or Location :param expect_target: target to wait for - :type expect_target: :type target: str or :py:class:`target.Target` :param modifiers: key modifiers when clicking - :type modifiers: [Key] or None - :param int timeout: time in seconds to wait for - :param int retries: number of retries to reach expected target behavior + :param timeout: time in seconds to wait for + :param retries: number of retries to reach expected target behavior :returns: self - :rtype: :py:class:`Region` """ for i in range(retries): if i > 0: @@ -776,22 +738,22 @@ def click_vanish(self, click_image_or_location, expect_target, self.hover(Location(0, 0)) if i == retries - 1: raise error + return self - def click_at_index(self, anchor, index=0, find_number=3, timeout=10): + def click_at_index(self, anchor: str | Target, index: int = 0, + find_number: int = 3, timeout: int = 10) -> "Match": """ Find all instances of an anchor image and click on the one with the desired index given that they are horizontally then vertically sorted. :param anchor: image to find all matches of - :type anchor: str or :py:class:`target.Target` - :param int index: index of the match to click on (assuming >=1 matches), - sorted according to their (x,y) coordinates - :param int find_number: expected number of matches which is necessary - for fast failure in case some elements are not visualized and/or - proper matching result - :param int timeout: timeout before which the number of matches should be found + :param index: index of the match to click on (assuming >=1 matches), + sorted according to their (x,y) coordinates + :param find_number: expected number of matches which is necessary + for fast failure in case some elements are not visualized and/or + proper matching result + :param timeout: timeout before which the number of matches should be found :returns: match from finding the target of the desired index - :rtype: :py:class:`match.Match` .. note:: This method is a good replacement of a number of coincident limitations regarding the Windows version of autopy and PyRO and @@ -827,18 +789,15 @@ def click_at_index(self, anchor, index=0, find_number=3, timeout=10): self.click(sorted_targets[index]) return sorted_targets[index] - def mouse_down(self, target_or_location, button=None): + def mouse_down(self, target_or_location: "Match | Location | str | Target", + button: int = None) -> "Match | None": """ Hold down an arbitrary mouse button on a target or location. :param target_or_location: target or location to toggle on - :type target_or_location: :py:class:`match.Match` or :py:class:`location.Location` or - str or :py:class:`target.Target` :param button: button index depending on backend (default is left button) (see :py:class:`inputmap.MouseButton` for extensive list) - :type button: int or None :returns: match from finding the target or nothing if toggling on a known location - :rtype: :py:class:`match.Match` or None """ if button is None: button = self.LEFT_BUTTON @@ -847,18 +806,15 @@ def mouse_down(self, target_or_location, button=None): self.dc_backend.mouse_down(button) return match - def mouse_up(self, target_or_location, button=None): + def mouse_up(self, target_or_location: "Match | Location | str | Target", + button: int = None) -> "Match | None": """ Release an arbitrary mouse button on a target or location. :param target_or_location: target or location to toggle on - :type target_or_location: :py:class:`match.Match` or :py:class:`location.Location` or - str or :py:class:`target.Target` :param button: button index depending on backend (default is left button) (see :py:class:`inputmap.MouseButton` for extensive list) - :type button: int or None :returns: match from finding the target or nothing if toggling on a known location - :rtype: :py:class:`match.Match` or None """ if button is None: button = self.LEFT_BUTTON @@ -867,18 +823,16 @@ def mouse_up(self, target_or_location, button=None): self.dc_backend.mouse_up(button) return match - def mouse_scroll(self, target_or_location, clicks=10, horizontal=False): + def mouse_scroll(self, target_or_location: "Match | Location | str | Target", + clicks: int = 10, horizontal: bool = False) -> "Match | None": """ Scroll the mouse for a number of clicks. :param target_or_location: target or location to scroll on - :type target_or_location: :py:class:`match.Match` or :py:class:`location.Location` or - str or :py:class:`target.Target` - :param int clicks: number of clicks to scroll up (positive) or down (negative) - :param bool horizontal: whether to perform a horizontal scroll instead + :param clicks: number of clicks to scroll up (positive) or down (negative) + :param horizontal: whether to perform a horizontal scroll instead (only available on some platforms) :returns: match from finding the target or nothing if scrolling on a known location - :rtype: :py:class:`match.Match` or None """ match = self.hover(target_or_location) log.debug("Scrolling the mouse %s for %s clicks at %s", @@ -887,27 +841,24 @@ def mouse_scroll(self, target_or_location, clicks=10, horizontal=False): self.dc_backend.mouse_scroll(clicks, horizontal) return match - def drag_drop(self, src_target_or_location, dst_target_or_location, modifiers=None): + def drag_drop(self, src_target_or_location: "Match | Location | str | Target", + dst_target_or_location: "Match | Location | str | Target", + modifiers: list[str] = None) -> "Match | None": """ Drag from and drop at a target or location optionally holding special keys. :param src_target_or_location: target or location to drag from - :type src_target_or_location: :py:class:`match.Match` or :py:class:`location.Location` or - str or :py:class:`target.Target` :param dst_target_or_location: target or location to drop at - :type dst_target_or_location: :py:class:`match.Match` or :py:class:`location.Location` or - str or :py:class:`target.Target` :param modifiers: special keys to hold during dragging and dropping (see :py:class:`inputmap.KeyModifier` for extensive list) - :type modifiers: [str] :returns: match from finding the target or nothing if dropping at a known location - :rtype: :py:class:`match.Match` or None """ self.drag_from(src_target_or_location, modifiers) match = self.drop_at(dst_target_or_location, modifiers) return match - def drag_from(self, target_or_location, modifiers=None): + def drag_from(self, target_or_location: "Match | Location | str | Target", + modifiers: list[str] = None) -> "Match": """ Drag from a target or location optionally holding special keys. @@ -928,7 +879,8 @@ def drag_from(self, target_or_location, modifiers=None): return match - def drop_at(self, target_or_location, modifiers=None): + def drop_at(self, target_or_location: "Match | Location | str | Target", + modifiers: list[str] = None) -> "Match": """ Drop at a target or location optionally holding special keys. @@ -950,15 +902,13 @@ def drop_at(self, target_or_location, modifiers=None): """Keyboard methods""" - def press_keys(self, keys): + def press_keys(self, keys: str | list[str]) -> "Region": """ Press a single key or a list of keys simultaneously. :param keys: characters or special keys depending on the backend (see :py:class:`inputmap.Key` for extensive list) - :type keys: [str] or str (possibly special keys in both cases) :returns: self - :rtype: :py:class:`Region` Thus, the line ``self.press_keys([Key.ENTER])`` is equivalent to the line ``self.press_keys(Key.ENTER)``. Other examples are:: @@ -971,7 +921,8 @@ def press_keys(self, keys): self.dc_backend.keys_press(keys_list) return self - def press_at(self, keys, target_or_location): + def press_at(self, keys: str | list[str], + target_or_location: "Match | Location | str | Target") -> "Match": """ Press a single key or a list of keys simultaneously at a specified target or location. @@ -985,7 +936,8 @@ def press_at(self, keys, target_or_location): self.dc_backend.keys_press(keys_list) return match - def _parse_keys(self, keys, target_or_location=None): + def _parse_keys(self, keys: str | list[str], + target_or_location: "Match | Location | str | Target" = None) -> list[str]: at_str = " at %s" % target_or_location if target_or_location else "" keys_list = [] @@ -1019,21 +971,17 @@ def _parse_keys(self, keys, target_or_location=None): keys_list.append(key) return keys_list - def press_expect(self, keys, expect_target, timeout=60, retries=3): + def press_expect(self, keys: list[str] | str, expect_target: str | Target, + timeout: int = 60, retries: int = 3) -> "Match": """ Press a key and wait for a target to appear. :param keys: characters or special keys depending on the backend (see :py:class:`inputmap.Key` for extensive list) - :type keys: [str] or str (possibly special keys in both cases) :param expect_target: target to wait for - :type expect_target: :type target: str or :py:class:`target.Target` - :param modifiers: key modifiers when clicking - :type modifiers: [Key] or None - :param int timeout: time in seconds to wait for - :param int retries: number of retries to reach expected target behavior + :param timeout: time in seconds to wait for + :param retries: number of retries to reach expected target behavior :returns: match obtained from finding the second target within the region - :rtype: :py:class:`match.Match` """ for i in range(retries): if i > 0: @@ -1045,21 +993,17 @@ def press_expect(self, keys, expect_target, timeout=60, retries=3): if i == retries - 1: raise error - def press_vanish(self, keys, expect_target, timeout=60, retries=3): + def press_vanish(self, keys: list[str] | str, expect_target: str | Target, + timeout: int = 60, retries: int = 3) -> "Region": """ Press a key and wait for a target to disappear. :param keys: characters or special keys depending on the backend (see :py:class:`inputmap.Key` for extensive list) - :type keys: [str] or str (possibly special keys in both cases) :param expect_target: target to wait for - :type expect_target: :type target: str or :py:class:`target.Target` - :param modifiers: key modifiers when clicking - :type modifiers: [Key] or None - :param int timeout: time in seconds to wait for - :param int retries: number of retries to reach expected target behavior + :param timeout: time in seconds to wait for + :param retries: number of retries to reach expected target behavior :returns: self - :rtype: :py:class:`Region` """ for i in range(retries): if i > 0: @@ -1070,18 +1014,16 @@ def press_vanish(self, keys, expect_target, timeout=60, retries=3): except NotFindError as error: if i == retries - 1: raise error + return self - def type_text(self, text, modifiers=None): + def type_text(self, text: list[str] | str, modifiers: list[str] = None) -> "Region": """ Type a list of consecutive character keys (without special keys). :param text: characters or strings (independent of the backend) - :type text: [str] or str (no special keys in both cases) :param modifiers: special keys to hold during typing (see :py:class:`inputmap.KeyModifier` for extensive list) - :type modifiers: [str] :returns: self - :rtype: :py:class:`Region` Thus, the line ``self.type_text(['hello'])`` is equivalent to the line ``self.type_text('hello')``. Other examples are:: @@ -1102,7 +1044,8 @@ def type_text(self, text, modifiers=None): self.dc_backend.keys_type(text_list, modifiers) return self - def type_at(self, text, target_or_location, modifiers=None): + def type_at(self, text: list[str] | str, target_or_location: "Match | Location | str | Target", + modifiers: list[str] = None) -> "Match": """ Type a list of consecutive character keys (without special keys) at a specified target or location. @@ -1122,7 +1065,8 @@ def type_at(self, text, target_or_location, modifiers=None): self.dc_backend.keys_type(text_list, modifiers) return match - def _parse_text(self, text, target_or_location=None): + def _parse_text(self, text: list[str] | str, + target_or_location: "Match | Location | str | Target" = None) -> list[str]: at_str = " at %s" % target_or_location if target_or_location else "" text_list = [] @@ -1142,17 +1086,16 @@ def _parse_text(self, text, target_or_location=None): return text_list """Mixed (form) methods""" - def click_at(self, anchor, dx, dy, count=1): + def click_at(self, anchor: "Match | Location | Target | str", + dx: int, dy: int, count: int = 1) -> "Region": """ Clicks on a relative location using a displacement from an anchor. :param anchor: target of reference for relative location - :type anchor: :py:class:`Match` or :py:class:`Location` or :py:class:`Target` or str - :param int dx: displacement from the anchor in the x direction - :param int dy: displacement from the anchor in the y direction - :param int count: 0, 1, 2, ... clicks on the relative location + :param dx: displacement from the anchor in the x direction + :param dy: displacement from the anchor in the y direction + :param count: 0, 1, 2, ... clicks on the relative location :returns: self - :rtype: :py:class:`Region` :raises: :py:class:`exceptions.ValueError` if `count` is not acceptable value """ from .match import Match @@ -1168,22 +1111,20 @@ def click_at(self, anchor, dx, dy, count=1): return self - def fill_at(self, anchor, text, dx, dy, - del_flag=True, esc_flag=True, - mark_clicks=1): + def fill_at(self, anchor: "Match | Location | Target | str", + text: str, dx: int, dy: int, del_flag: bool = True, + esc_flag: bool = True, mark_clicks: int = 1) -> "Region": """ Fills a new text at a text box using a displacement from an anchor. :param anchor: target of reference for the input field - :type anchor: :py:class:`Match` or :py:class:`Location` or :py:class:`Target` or str - :param str text: text to fill in - :param int dx: displacement from the anchor in the x direction - :param int dy: displacement from the anchor in the y direction - :param bool del_flag: whether to delete the highlighted text - :param bool esc_flag: whether to escape any possible fill suggestions - :param int mark_clicks: 0, 1, 2, ... clicks to highlight previous text + :param text: text to fill in + :param dx: displacement from the anchor in the x direction + :param dy: displacement from the anchor in the y direction + :param del_flag: whether to delete the highlighted text + :param esc_flag: whether to escape any possible fill suggestions + :param mark_clicks: 0, 1, 2, ... clicks to highlight previous text :returns: self - :rtype: :py:class:`Region` :raises: :py:class:`exceptions.ValueError` if `mark_click` is not acceptable value If the delete flag is set the previous content will be deleted or @@ -1198,7 +1139,7 @@ def fill_at(self, anchor, text, dx, dy, """ # NOTE: handle cases of empty value no filling anything if not text: - return + return self self.click_at(anchor, dx, dy, count=mark_clicks) # make sure any highlighting is given enough time self.idle(1) @@ -1220,26 +1161,24 @@ def fill_at(self, anchor, text, dx, dy, return self - def select_at(self, anchor, image_or_index, dx, dy, - dw=0, dh=0, ret_flag=True, - mark_clicks=1, tries=3): + def select_at(self, anchor: "Match | Location | Target | str", + image_or_index: str | int, dx: int, dy: int, dw: int = 0, + dh: int = 0, ret_flag: bool = True, mark_clicks: int = 1, + tries: int = 3) -> "Region": """ Select an option at a dropdown list using either an integer index or an option image if the order cannot be easily inferred. :param anchor: target of reference for the input dropdown menu - :type anchor: :py:class:`Match` or :py:class:`Location` or :py:class:`Target` or str :param image_or_index: item image or item index - :type image_or_index: str or int - :param int dx: displacement from the anchor in the x direction - :param int dy: displacement from the anchor in the y direction - :param int dw: width to add to the displacement for an image search area - :param int dh: height to add to the displacement for an image search area - :param bool ret_flag: whether to press Enter after selecting - :param int mark_clicks: 0, 1, 2, ... clicks to highlight previous text - :param int tries: retries if the dropdown menu doesn't open after the initial click + :param dx: displacement from the anchor in the x direction + :param dy: displacement from the anchor in the y direction + :param dw: width to add to the displacement for an image search area + :param dh: height to add to the displacement for an image search area + :param ret_flag: whether to press Enter after selecting + :param mark_clicks: 0, 1, 2, ... clicks to highlight previous text + :param tries: retries if the dropdown menu doesn't open after the initial click :returns: self - :rtype: :py:class:`Region` It uses an anchor image which is rather constant and a displacement to locate the dropdown location. It moves down to the option if @@ -1253,7 +1192,7 @@ def select_at(self, anchor, image_or_index, dx, dy, """ # NOTE: handle cases of empty value no filling anything if not image_or_index: - return + return self self.click_at(anchor, dx, dy, count=mark_clicks) # make sure the dropdown options appear self.idle(1) diff --git a/guibot/target.py b/guibot/target.py index 2925f600..a2381911 100644 --- a/guibot/target.py +++ b/guibot/target.py @@ -29,6 +29,7 @@ import os import re import PIL.Image +from typing import Iterator from .config import GlobalConfig from .location import Location @@ -47,13 +48,12 @@ class Target(object): """ @staticmethod - def from_data_file(filename): + def from_data_file(filename: str) -> "Target": """ Read the target type from the extension of the target filename. - :param str filename: data filename for the target + :param filename: data filename for the target :returns: target of type determined from its data filename extension - :rtype: :py:class:`target.Target` :raises: :py:class:`errors.IncompatibleTargetFileError` if the data file if of unknown type """ if not os.path.exists(filename): @@ -75,13 +75,12 @@ def from_data_file(filename): return target @staticmethod - def from_match_file(filename): + def from_match_file(filename: str) -> "Target": """ Read the target type and configuration from a match file with the given filename. - :param str filename: match filename for the configuration + :param filename: match filename for the configuration :returns: target of type determined from its parsed (and generated) settings - :rtype: :py:class:`target.Target` """ if not os.path.exists(filename): filename = FileResolver().search(filename) @@ -102,12 +101,11 @@ def from_match_file(filename): return target - def __init__(self, match_settings=None): + def __init__(self, match_settings: "Finder" = None) -> None: """ Build a target object. :param match_settings: predefined configuration for the CV backend if any - :type match_settings: :py:class:`finder.Finder` or None """ self.match_settings = match_settings if self.match_settings is not None: @@ -135,26 +133,24 @@ def __init__(self, match_settings=None): self._center_offset = Location(0, 0) - def __str__(self): + def __str__(self) -> str: """Provide a constant name 'target'.""" return "target" - def get_similarity(self): + def get_similarity(self) -> float: """ Getter for readonly attribute. :returns: similarity required for the image to be matched - :rtype: float """ return self.match_settings.params["find"]["similarity"].value similarity = property(fget=get_similarity) - def get_center_offset(self): + def get_center_offset(self) -> Location: """ Getter for readonly attribute. :returns: offset with respect to the target center (used for clicking) - :rtype: :py:class:`location.Location` This clicking location is set in the target in order to be customizable, it is then taken when matching to produce a clicking target for a match. @@ -162,11 +158,11 @@ def get_center_offset(self): return self._center_offset center_offset = property(fget=get_center_offset) - def load(self, filename, **kwargs): + def load(self, filename: str, **kwargs: dict[str, type]) -> None: """ Load target from a file. - :param str filename: name for the target file + :param filename: name for the target file If no local file is found, we will perform search in the previously added paths. @@ -183,50 +179,47 @@ def load(self, filename, **kwargs): pass self.use_own_settings = True - def save(self, filename): + def save(self, filename: str) -> None: """ Save target to a file. - :param str filename: name for the target file + :param filename: name for the target file """ match_filename = os.path.splitext(filename)[0] + ".match" if self.use_own_settings: Finder.to_match_file(self.match_settings, match_filename) - def copy(self): + def copy(self) -> "Target": """ Perform a copy of the target data and match settings. :returns: copy of the current target (with settings) - :rtype: :py:class:`target.Target` """ selfcopy = copy.copy(self) copy_settings = self.match_settings.copy() selfcopy.match_settings = copy_settings return selfcopy - def with_center_offset(self, xpos, ypos): + def with_center_offset(self, xpos: int, ypos: int) -> "Target": """ Perform a copy of the target data with new match settings and with a newly defined center offset. - :param int xpos: new offset in the x direction - :param int ypos: new offset in the y direction + :param xpos: new offset in the x direction + :param ypos: new offset in the y direction :returns: copy of the current target with new center offset - :rtype: :py:class:`target.Target` """ new_target = self.copy() new_target._center_offset = Location(xpos, ypos) return new_target - def with_similarity(self, new_similarity): + def with_similarity(self, new_similarity: float) -> "Target": """ Perform a copy of the target data with new match settings and with a newly defined required similarity. - :param float new_similarity: new required similarity + :param new_similarity: new required similarity :returns: copy of the current target with new similarity - :rtype: :py:class:`target.Target` """ new_target = self.copy() new_target.match_settings.params["find"]["similarity"].value = new_similarity @@ -241,27 +234,23 @@ class Image(Target): _cache = {} - def __init__(self, image_filename=None, - pil_image=None, match_settings=None, - use_cache=True): + def __init__(self, image_filename: str = "", pil_image: PIL.Image.Image = None, + match_settings: "Finder" = None, use_cache: bool = True) -> None: """ Build an image object. :param image_filename: name of the image file if any - :type image_filename: str or None :param pil_image: image data - use cache or recreate if none - :type pil_image: :py:class:`PIL.Image` or None :param match_settings: predefined configuration for the CV backend if any - :type match_settings: :py:class:`finder.Finder` or None - :param bool use_cache: whether to cache image data for better performance + :param use_cache: whether to cache image data for better performance """ super(Image, self).__init__(match_settings) self._filename = image_filename - self._pil_image = None + self._pil_image: PIL.Image.Image = None self._width = 0 self._height = 0 - if self._filename is not None: + if self._filename != "": self.load(self._filename, use_cache) # per instance pil image has the final word if pil_image is not None: @@ -275,56 +264,52 @@ def __init__(self, image_filename=None, self._width = self._pil_image.size[0] self._height = self._pil_image.size[1] - def __str__(self): + def __str__(self) -> str: """Provide the image filename.""" - return "noname" if self._filename is None else os.path.splitext(os.path.basename(self._filename))[0] + return "noname" if self._filename == "" else os.path.splitext(os.path.basename(self._filename))[0] - def get_filename(self): + def get_filename(self) -> str: """ Getter for readonly attribute. :returns: filename of the image - :rtype: str """ return self._filename filename = property(fget=get_filename) - def get_width(self): + def get_width(self) -> int: """ Getter for readonly attribute. :returns: width of the image - :rtype: int """ return self._width width = property(fget=get_width) - def get_height(self): + def get_height(self) -> int: """ Getter for readonly attribute. :returns: height of the image - :rtype: int """ return self._height height = property(fget=get_height) - def get_pil_image(self): + def get_pil_image(self) -> PIL.Image.Image: """ Getter for readonly attribute. :returns: image data of the image - :rtype: :py:class:`PIL.Image` """ return self._pil_image pil_image = property(fget=get_pil_image) - def load(self, filename, use_cache=True, **kwargs): + def load(self, filename: str, use_cache: bool = True, **kwargs: dict[str, type]) -> None: """ Load image from a file. - :param str filename: name for the target file - :param bool use_cache: whether to cache image data for better performance + :param filename: name for the target file + :param use_cache: whether to cache image data for better performance """ super(Image, self).load(filename) if not os.path.exists(filename): @@ -340,13 +325,12 @@ def load(self, filename, use_cache=True, **kwargs): self._cache[filename] = self._pil_image self._filename = filename - def save(self, filename): + def save(self, filename: str) -> "Image": """ Save image to a file. - :param str filename: name for the target file + :param filename: name for the target file :returns: copy of the current image with the new filename - :rtype: :py:class:`target.Image` The image is compressed upon saving with a PNG compression setting specified by :py:func:`config.GlobalConfig.image_quality`. @@ -355,7 +339,7 @@ def save(self, filename): filename += ".png" if os.path.splitext(filename)[-1] != ".png" else "" self.pil_image.save(filename, compress_level=GlobalConfig.image_quality) - new_image = self.copy() + new_image: Image = self.copy() new_image._filename = filename return new_image @@ -367,17 +351,17 @@ class Text(Target): using OCR or general text detection methods. """ - def __init__(self, value=None, text_filename=None, match_settings=None): + def __init__(self, value: str = None, text_filename: str = None, + match_settings: "Finder" = None) -> None: """ Build a text object. - :param str value: text value to search for - :param str text_filename: custom filename to read the text from + :param value: text value to search for + :param text_filename: custom filename to read the text from :param match_settings: predefined configuration for the CV backend if any - :type match_settings: :py:class:`finder.Finder` or None """ super(Text, self).__init__(match_settings) - self.value = value + self.value: str = value self.filename = text_filename try: @@ -388,15 +372,15 @@ def __init__(self, value=None, text_filename=None, match_settings=None): # text generated on the fly is also acceptable pass - def __str__(self): + def __str__(self) -> str: """Provide a part of the text value.""" return self.value[:30].replace('/', '').replace('\\', '') - def load(self, filename, **kwargs): + def load(self, filename: str, **kwargs: dict[str, type]) -> None: """ Load text from a file. - :param str filename: name for the target file + :param filename: name for the target file """ super(Text, self).load(filename) if not os.path.exists(filename): @@ -404,26 +388,25 @@ def load(self, filename, **kwargs): with open(filename) as f: self.value = f.read() - def save(self, filename): + def save(self, filename: str) -> None: """ Save text to a file. - :param str filename: name for the target file + :param filename: name for the target file """ super(Text, self).save(filename) filename += ".txt" if os.path.splitext(filename)[-1] != ".txt" else "" with open(filename, "w") as f: f.write(self.value) - def distance_to(self, str2): + def distance_to(self, str2: str) -> float: """ Approximate Hungarian distance. - :param str str2: string to compare to + :param str2: string to compare to :returns: string distance value - :rtype: float """ - str1 = self.value + str1 = str(self.value) import numpy M = numpy.empty((len(str1) + 1, len(str2) + 1), int) @@ -446,13 +429,12 @@ class Pattern(Target): training of a classifier in order to recognize a target. """ - def __init__(self, id, match_settings=None): + def __init__(self, id: str, match_settings: "Finder" = None) -> None: """ Build a pattern object. - :param str id: alphanumeric id of logit or label for the given pattern + :param id: alphanumeric id of logit or label for the given pattern :param match_settings: predefined configuration for the CV backend if any - :type match_settings: :py:class:`finder.Finder` or None """ super(Pattern, self).__init__(match_settings) self.id = id @@ -472,15 +454,15 @@ def __init__(self, id, match_settings=None): self.match_settings = match_settings self.use_own_settings = True - def __str__(self): + def __str__(self) -> str: """Provide the data filename.""" return self.id - def load(self, filename, **kwargs): + def load(self, filename: str, **kwargs: dict[str, type]) -> None: """ Load pattern from a file. - :param str filename: name for the target file + :param filename: name for the target file """ super(Pattern, self).load(filename) if not os.path.exists(filename): @@ -488,11 +470,11 @@ def load(self, filename, **kwargs): # loading the actual data is backend specific so only register its path self.data_file = filename - def save(self, filename): + def save(self, filename: str) -> None: """ Save pattern to a file. - :param str filename: name for the target file + :param filename: name for the target file """ super(Pattern, self).save(filename) filename += ".csv" if "." not in str(self.id) else "" @@ -512,42 +494,40 @@ class Chain(Target): step did not succeed. """ - def __init__(self, target_name, match_settings=None): + def __init__(self, target_name: str, match_settings: "Finder" = None) -> None: """ Build an chain object. - :param str target_name: name of the target for all steps + :param target_name: name of the target for all steps :param match_settings: predefined configuration for the CV backend if any - :type match_settings: :py:class:`finder.Finder` or None """ super(Chain, self).__init__(match_settings) self.target_name = target_name self._steps = [] self.load(self.target_name) - def __str__(self): + def __str__(self) -> str: """Provide the target name.""" return self.target_name - def __iter__(self): + def __iter__(self) -> Iterator["Target"]: """Provide an interator over the steps.""" return self._steps.__iter__() - def load(self, steps_filename, **kwargs): + def load(self, steps_filename: str, **kwargs: dict[str, type]) -> None: """ Load steps from a sequence definition file. - :param str steps_filename: names for the sequence definition file + :param steps_filename: names for the sequence definition file :raises: :py:class:`errors.UnsupportedBackendError` if a chain step is of unknown type :raises: :py:class:`IOError` if an chain step line cannot be parsed """ - def resolve_stepsfile(filename): + def resolve_stepsfile(filename: str) -> str: """ Try to find a valid steps file from a given file name. - :param str filename: full or partial name of the file to find + :param filename: full or partial name of the file to find :returns: valid path to a steps file - :rtype: str """ if not filename.endswith(".steps"): filename += ".steps" @@ -604,11 +584,11 @@ def resolve_stepsfile(filename): # now define own match configuration super(Chain, self).load(steps_filename) - def save(self, steps_filename): + def save(self, steps_filename: str) -> None: """ Save steps to a sequence definition file. - :param str steps_filename: names for the sequence definition file + :param steps_filename: names for the sequence definition file """ super(Chain, self).save(self.target_name) save_lines = [] diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..16d30ff9 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,19 @@ +[mypy] +# enable all additional optional checks +strict = True +# so far we are still for using "None" to implicitly assume Optional type +no_implicit_optional = False +# type hint errors we choose to ignore +# TODO: reduce this list as much as possible +disable_error_code = attr-defined,call-arg,assignment,comparison-overlap,arg-type,has-type,misc,union-attr,override,name-defined,var-annotated,call-overload +# TODO: consider this for the long term +ignore_missing_imports = True +# also related to missing imports are the following: +# do not validate subclassing any types (without any knowledge) +disallow_subclassing_any = False +# some returned values are from imported functions and considered as Any +warn_return_any = False +# TODO: additional settings to consider +#disallow_untyped_defs = True +#check_untyped_defs = True +#warn_unused_ignores = True diff --git a/tests/qt5_application.py b/tests/qt5_application.py index a2a60813..dd80d2dc 100644 --- a/tests/qt5_application.py +++ b/tests/qt5_application.py @@ -26,7 +26,7 @@ class ControlsWithLayout(QtWidgets.QWidget): - def __init__(self, parent=None): + def __init__(self, parent: QtWidgets.QWidget = None): QtWidgets.QWidget.__init__(self, parent) self.setWindowTitle('guibot test application') @@ -136,16 +136,16 @@ def __init__(self, parent=None): QtWidgets.QApplication.setStyle(QtWidgets.QStyleFactory.create('cleanlooks')) - def quit_on_type(self): + def quit_on_type(self) -> None: sender = self.sender() if sender.text() == "quit": self.close() - def mousePressEvent(self, e): + def mousePressEvent(self, e) -> None: if e.button() == QtCore.Qt.MiddleButton: self.close() - def keyPressEvent(self, e): + def keyPressEvent(self, e) -> None: if e.key() == QtCore.Qt.Key_Escape: self.close() elif e.key() == QtCore.Qt.Key_Shift: @@ -155,72 +155,72 @@ def keyPressEvent(self, e): else: self.changing_image_counter += 1 - def closeEvent(self, e): + def closeEvent(self, e) -> None: self.close() class DragQuitLabel(QtWidgets.QLabel): - def __init__(self, title, parent): + def __init__(self, title: str, parent: QtWidgets.QWidget) -> None: super(DragQuitLabel, self).__init__(title, parent) self.setAcceptDrops(True) - def dragEnterEvent(self, e): + def dragEnterEvent(self, e) -> None: self.parent().close() class DropQuitLabel(QtWidgets.QLabel): - def __init__(self, title, parent): + def __init__(self, title: str, parent: QtWidgets.QWidget) -> None: super(DropQuitLabel, self).__init__(title, parent) self.setAcceptDrops(True) - def dragEnterEvent(self, e): + def dragEnterEvent(self, e) -> None: if e.mimeData().hasFormat('text/plain'): e.accept() else: e.ignore() - def dropEvent(self, e): + def dropEvent(self, e) -> None: self.parent().close() class MouseDownQuitLabel(QtWidgets.QLabel): - def __init__(self, title, parent): + def __init__(self, title: str, parent: QtWidgets.QWidget) -> None: super(MouseDownQuitLabel, self).__init__(title, parent) - def mousePressEvent(self, e): + def mousePressEvent(self, e) -> None: self.parent().close() class MouseUpQuitLabel(QtWidgets.QLabel): - def __init__(self, title, parent): + def __init__(self, title: str, parent: QtWidgets.QWidget) -> None: super(MouseUpQuitLabel, self).__init__(title, parent) # self.setAcceptDrops(True) - def mouseReleaseEvent(self, e): + def mouseReleaseEvent(self, e) -> None: self.parent().close() class ImageQuitLabel(QtWidgets.QLabel): - def __init__(self, parent): + def __init__(self, parent: QtWidgets.QWidget) -> None: super(ImageQuitLabel, self).__init__(parent) - def mousePressEvent(self, e): + def mousePressEvent(self, e) -> None: self.parent().close() class ImageChangeLabel(QtWidgets.QLabel): - def __init__(self, image, parent): + def __init__(self, image, parent: QtWidgets.QWidget) -> None: super(ImageChangeLabel, self).__init__(parent) self.image = image self.counter = 1 - def mousePressEvent(self, e): + def mousePressEvent(self, e) -> None: if self.counter == 3: self.image.setPixmap(QtGui.QPixmap(os.path.join(common_test.unittest_dir, "images/shape_black_box.png"))) diff --git a/tests/qt5_image.py b/tests/qt5_image.py index 974afdd0..889a1d52 100644 --- a/tests/qt5_image.py +++ b/tests/qt5_image.py @@ -23,7 +23,7 @@ class ImageWithLayout(QtWidgets.QWidget): - def __init__(self, filename, title="show_picture", parent=None): + def __init__(self, filename: str, title: str = "show_picture", parent: QtWidgets.QWidget = None) -> None: QtWidgets.QWidget.__init__(self, parent) self.setWindowTitle(title) diff --git a/tests/test_calibrator.py b/tests/test_calibrator.py index 88524fd1..7c86a0c2 100755 --- a/tests/test_calibrator.py +++ b/tests/test_calibrator.py @@ -31,7 +31,7 @@ class CalibratorTest(unittest.TestCase): @classmethod - def setUpClass(cls): + def setUpClass(cls) -> None: cls.patfile_resolver = FileResolver() cls.patfile_resolver.add_path(os.path.join(common_test.unittest_dir, 'images')) random.seed(42) @@ -40,12 +40,12 @@ def setUpClass(cls): cls.orig_https_context = ssl._create_default_https_context ssl._create_default_https_context = ssl._create_unverified_context - def tearDown(self): + def tearDown(self) -> None: if os.path.exists("pairs.list"): os.unlink("pairs.list") @classmethod - def tearDownClass(cls): + def tearDownClass(cls) -> None: # TODO: PyTorch has bugs downloading models from their hub on Windows ssl._create_default_https_context = cls.orig_https_context @@ -57,19 +57,19 @@ def calibration_setUp(self, needle, haystack, calibrate_backends): calibrator = Calibrator(Image(needle), Image(haystack)) return calibrator.calibrate(finder) - def list_setUp(self): + def list_setUp(self) -> Calibrator: with open("pairs.list", "w") as f: f.write("n_ibs" + " " + "h_ibs_viewport" + " max" + "\n") f.write("n_ibs" + " " + "h_ibs_rotated" + " max" + "\n") f.write("n_ibs" + " " + "h_ibs_scaled" + " max" + "\n") return Calibrator(config="pairs.list") - def benchmark_setUp(self): + def benchmark_setUp(self) -> None: # remove any randomness in the unit tests in the Monte Carlo search CVParameter.random_value = lambda self, _mu, _sigma: self.value @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_calibrate(self): + def test_calibrate(self) -> None: """Check that minimal calibration with an identical image improves over time.""" raw_similarity = self.calibration_setUp('n_ibs', 'n_ibs', []) cal_similarity = self.calibration_setUp('n_ibs', 'n_ibs', @@ -80,7 +80,7 @@ def test_calibrate(self): " or equal to the similarity after calibration") @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_calibrate_viewport(self): + def test_calibrate_viewport(self) -> None: """Check that minimal calibration with a view transformed image improves over time.""" raw_similarity = self.calibration_setUp('n_ibs', 'h_ibs_viewport', []) cal_similarity = self.calibration_setUp('n_ibs', 'h_ibs_viewport', @@ -91,7 +91,7 @@ def test_calibrate_viewport(self): " or equal to the similarity after calibration") @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_calibrate_rotation(self): + def test_calibrate_rotation(self) -> None: """Check that minimal calibration with a rotated image improves over time.""" raw_similarity = self.calibration_setUp('n_ibs', 'h_ibs_rotated', []) cal_similarity = self.calibration_setUp('n_ibs', 'h_ibs_rotated', @@ -102,7 +102,7 @@ def test_calibrate_rotation(self): " or equal to the similarity after calibration") @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_calibrate_scaling(self): + def test_calibrate_scaling(self) -> None: """Check that minimal calibration with a scaled image improves over time.""" raw_similarity = self.calibration_setUp('n_ibs', 'h_ibs_scaled', []) cal_similarity = self.calibration_setUp('n_ibs', 'h_ibs_scaled', @@ -113,7 +113,7 @@ def test_calibrate_scaling(self): " or equal to the similarity after calibration") @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_calibrate_list(self): + def test_calibrate_list(self) -> None: """Check that calibration with a list of image pairs improves over time.""" calibrator = self.list_setUp() # use a single finder type for these tests @@ -127,7 +127,7 @@ def test_calibrate_list(self): " or equal to the similarity after calibration") @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_search(self): + def test_search(self) -> None: """Check that search with a list of image pairs improves over time.""" calibrator = self.list_setUp() # use a single finder type for these tests @@ -141,7 +141,7 @@ def test_search(self): " or equal to the similarity after a search") @unittest.skipIf(os.environ.get('DISABLE_AUTOPY', "0") == "1", "AutoPy disabled") - def test_benchmark_autopy(self): + def test_benchmark_autopy(self) -> None: """Check that benchmarking of the AutoPy backend produces correct results.""" self.benchmark_setUp() calibrator = Calibrator(Image('shape_blue_circle'), Image('all_shapes')) @@ -157,7 +157,7 @@ def test_benchmark_autopy(self): @unittest.skipIf(os.name == 'nt', "Exhibits hiccups on Windows") @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_benchmark_contour(self): + def test_benchmark_contour(self) -> None: """Check that benchmarking of the OpenCV countour backend produces correct results.""" self.benchmark_setUp() # matching all shapes will require a modification of the minArea parameter @@ -173,7 +173,7 @@ def test_benchmark_contour(self): @unittest.skipIf(os.name == 'nt', "Exhibits hiccups on Windows") @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_benchmark_template(self): + def test_benchmark_template(self) -> None: """Check that benchmarking of the OpenCV template backend produces correct results.""" self.benchmark_setUp() calibrator = Calibrator(Image('shape_blue_circle'), Image('all_shapes')) @@ -189,7 +189,7 @@ def test_benchmark_template(self): @unittest.skip("Unit test takes too long") #@unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_benchmark_feature(self): + def test_benchmark_feature(self) -> None: """Check that benchmarking of the OpenCV feature backend produces correct results.""" self.benchmark_setUp() calibrator = Calibrator(Image('n_ibs'), Image('n_ibs')) @@ -205,7 +205,7 @@ def test_benchmark_feature(self): @unittest.skipIf(os.name == 'nt', "Exhibits hiccups on Windows") @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_benchmark_cascade(self): + def test_benchmark_cascade(self) -> None: """Check that benchmarking of the OpenCV cascade backend produces correct results.""" self.benchmark_setUp() calibrator = Calibrator(Pattern('shape_blue_circle.xml'), Image('all_shapes')) @@ -222,7 +222,7 @@ def test_benchmark_cascade(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_OCR', "0") == "1", "Disabled OpenCV or OCR") - def test_benchmark_text(self): + def test_benchmark_text(self) -> None: """Check that benchmarking of OCR backends produces correct results.""" self.benchmark_setUp() calibrator = Calibrator(Text('Text'), Image('all_shapes')) @@ -256,7 +256,7 @@ def test_benchmark_text(self): @unittest.skip("Unit test takes too long") #@unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_benchmark_tempfeat(self): + def test_benchmark_tempfeat(self) -> None: """Check that benchmarking of template-feature backends produces correct results.""" self.benchmark_setUp() calibrator = Calibrator(Image('shape_blue_circle'), Image('all_shapes')) @@ -273,7 +273,7 @@ def test_benchmark_tempfeat(self): self.assertGreater(result[2], 0.0, "Strictly positive time is required to run case '%s' %s %s" % result) @unittest.skipIf(os.environ.get('DISABLE_PYTORCH', "0") == "1", "PyTorch disabled") - def test_benchmark_deep(self): + def test_benchmark_deep(self) -> None: """Check that benchmarking of deep backends produces correct results.""" self.benchmark_setUp() calibrator = Calibrator(Pattern('cat'), Image('coco_cat')) diff --git a/tests/test_config.py b/tests/test_config.py index df73f8f6..441dbbc6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -20,7 +20,7 @@ class ConfigTest(unittest.TestCase): - def test_temporary_config(self): + def test_temporary_config(self) -> None: """Check that using a temporary config has a temporary effect.""" original_value = GlobalConfig.delay_before_drop new_value = original_value * 10 diff --git a/tests/test_controller.py b/tests/test_controller.py index 44065c3a..6f6e32f7 100755 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -20,6 +20,7 @@ import shutil import unittest import subprocess +from typing import Any import common_test from guibot.errors import * @@ -32,7 +33,7 @@ class ControllerTest(unittest.TestCase): @classmethod - def setUpClass(cls): + def setUpClass(cls) -> None: # the VNC display controller is disabled on OS-es like Windows if os.environ.get('DISABLE_VNCDOTOOL', "0") == "1": return @@ -53,7 +54,7 @@ def setUpClass(cls): ":99", "-rfbauth", passfile]) @classmethod - def tearDownClass(cls): + def tearDownClass(cls) -> None: # the VNC display controller is disabled on OS-es like Windows if os.environ.get('DISABLE_VNCDOTOOL', "0") == "1": return @@ -64,7 +65,7 @@ def tearDownClass(cls): if os.path.exists(vnc_config_dir): shutil.rmtree(vnc_config_dir) - def setUp(self): + def setUp(self) -> None: # gui test scripts self.script_app = os.path.join(common_test.unittest_dir, 'qt5_application.py') self.child_app = None @@ -93,7 +94,7 @@ def setUp(self): vncdotool.synchronize_backend() self.backends += [vncdotool] - def tearDown(self): + def tearDown(self) -> None: self.close_windows() if os.path.exists(GlobalConfig.image_logging_destination): shutil.rmtree(GlobalConfig.image_logging_destination) @@ -103,13 +104,13 @@ def tearDown(self): if isinstance(display, VNCDoToolController): display._backend_obj.disconnect() - def show_application(self): + def show_application(self) -> None: python = 'python.exe' if os.name == 'nt' else 'python3' self.child_app = subprocess.Popen([python, self.script_app]) # HACK: avoid small variability in loading speed time.sleep(3) - def close_windows(self): + def close_windows(self) -> None: if self.child_app is not None: self.child_app.terminate() self.wait_end(self.child_app) @@ -118,7 +119,7 @@ def close_windows(self): # HACK: make sure app is really closed time.sleep(0.5) - def wait_end(self, subprocess_pipe, timeout=30): + def wait_end(self, subprocess_pipe: Any, timeout: int = 30) -> int: expires = time.time() + timeout while True: @@ -132,7 +133,7 @@ def wait_end(self, subprocess_pipe, timeout=30): time.sleep(0.2) - def test_basic(self): + def test_basic(self) -> None: """Check basic functionality for all display controller backends.""" for display in self.backends: self.assertTrue(display.width > 0) @@ -146,7 +147,7 @@ def test_basic(self): self.assertLessEqual(location.x, display.width) self.assertLessEqual(location.y, display.height) - def test_single_backend(self): + def test_single_backend(self) -> None: """Check display controller backend configuration and synchronization.""" for display in self.backends: # the VNC controller has additional setup in these tests @@ -184,7 +185,7 @@ def test_single_backend(self): with self.assertRaises(UnsupportedBackendError): display.synchronize_backend(backend=category, category="control") - def test_capture(self): + def test_capture(self) -> None: """Check screendump capabilities for all display controller backends.""" for display in self.backends: screen_width = display.width @@ -206,7 +207,7 @@ def test_capture(self): self.assertEqual(320, captured.width) self.assertEqual(200, captured.height) - def test_capture_clipping(self): + def test_capture_clipping(self) -> None: """Check screendump clipping for all display controller backends.""" for display in self.backends: screen_width = display.width @@ -220,7 +221,7 @@ def test_capture_clipping(self): self.assertEqual(1, captured.width) self.assertEqual(1, captured.height) - def test_mouse_move(self): + def test_mouse_move(self) -> None: """Check mouse move locations for all display controller backends.""" for display in self.backends: for is_smooth in [False, True]: @@ -237,7 +238,7 @@ def test_mouse_move(self): self.assertAlmostEqual(location.y, 20, delta=1) @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_mouse_click(self): + def test_mouse_click(self) -> None: """Check mouse click effect for all display controller backends.""" for display in self.backends: mouse = display.mousemap @@ -271,7 +272,7 @@ def test_mouse_click(self): self.child_app = None @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_mouse_updown(self): + def test_mouse_updown(self) -> None: """Check mouse up/down effect for all display controller backends.""" for display in self.backends: mouse = display.mousemap @@ -292,7 +293,7 @@ def test_mouse_updown(self): self.child_app = None @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_mouse_scroll(self): + def test_mouse_scroll(self) -> None: """Check mouse scroll effect for all display controller backends.""" for display in self.backends: for horizontal in [False, True]: @@ -312,7 +313,7 @@ def test_mouse_scroll(self): self.child_app = None @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_keys_press(self): + def test_keys_press(self) -> None: """Check key press effect for all display controller backends.""" for display in self.backends: key = display.keymap @@ -331,7 +332,7 @@ def test_keys_press(self): self.child_app = None @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_keys_type(self): + def test_keys_type(self) -> None: """Check key type effect for all display controller backends.""" for display in self.backends: # include some modifiers without direct effect in this case diff --git a/tests/test_fileresolver.py b/tests/test_fileresolver.py index cba60454..16e5141d 100644 --- a/tests/test_fileresolver.py +++ b/tests/test_fileresolver.py @@ -30,22 +30,22 @@ class FileResolverTest(unittest.TestCase): """Tests for the FileResolverTest class.""" @classmethod - def setUpClass(cls): + def setUpClass(cls) -> None: # Change to 'tests' directory cls.saved_working_dir = os.getcwd() os.chdir(common_test.unittest_dir) @classmethod - def tearDownClass(cls): + def tearDownClass(cls) -> None: os.chdir(cls.saved_working_dir) - def setUp(self): + def setUp(self) -> None: self.resolver = FileResolver() # Clear paths from any previous unit test since # the paths are shared between all FileResolver instances self.resolver.clear() - def test_deprecated_class(self): + def test_deprecated_class(self) -> None: """Check that the deprecated :py:class:`Path` class still works.""" logger = logging.getLogger("guibot.path") # import the legacy path module should log a warning @@ -55,21 +55,21 @@ def test_deprecated_class(self): mock_warn.assert_called_once() self.assertEqual(Path, FileResolver) - def test_add_path(self): + def test_add_path(self) -> None: """Test that adding a path works.""" self.resolver.add_path("paths") - def test_remove_path(self): + def test_remove_path(self) -> None: """Test that removing a path works.""" self.resolver.add_path("images") self.assertEqual(True, self.resolver.remove_path("images")) self.assertEqual(False, self.resolver.remove_path("images")) - def test_remove_unknown_path(self): + def test_remove_unknown_path(self) -> None: """Check that removing unknown paths doesn't break anything.""" self.resolver.remove_path("foobar_does_not_exist") - def test_search(self): + def test_search(self) -> None: """Check that different :py:class:`FileResolver` instances contain the same paths.""" self.resolver.add_path("images") self.assertEqual(os.path.join("images", "shape_black_box.png"), @@ -79,12 +79,12 @@ def test_search(self): self.assertEqual(os.path.join("images", "shape_black_box.png"), new_finder.search("shape_black_box")) - def test_search_fail(self): + def test_search_fail(self) -> None: """Test failed search.""" self.resolver.add_path("images") self.assertRaises(FileNotFoundError, self.resolver.search, "foobar_does_not_exist") - def test_search_type(self): + def test_search_type(self) -> None: """Test that searching file names without extension works.""" self.resolver.add_path("images") @@ -96,7 +96,7 @@ def test_search_type(self): self.assertEqual(os.path.join("images", "circle.steps"), self.resolver.search("circle")) - def test_search_precedence(self): + def test_search_precedence(self) -> None: """Check the precedence of extensions when searching.""" self.resolver.add_path("images") @@ -106,14 +106,14 @@ def test_search_precedence(self): self.assertEqual(os.path.join("images", "shape_blue_circle.png"), self.resolver.search("shape_blue_circle")) - def test_search_keyword(self): + def test_search_keyword(self) -> None: """Check if the path restriction results in an empty set.""" self.resolver.add_path("images") self.assertEqual(os.path.join("images", "shape_black_box.png"), self.resolver.search("shape_black_box.png", "images")) self.assertRaises(FileNotFoundError, self.resolver.search, "shape_black_box.png", "other-images") - def test_search_silent(self): + def test_search_silent(self) -> None: """Check that we can disable exceptions from being raised when searching.""" self.resolver.add_path("images") self.assertEqual(os.path.join("images", "shape_black_box.png"), @@ -123,14 +123,14 @@ def test_search_silent(self): target = self.resolver.search("shape_missing_box.png", silent=True) self.assertIsNone(target) - def test_paths_iterator(self): + def test_paths_iterator(self) -> None: """Test that the FileResolver iterator yields the correct list.""" self.assertListEqual(self.resolver._target_paths, [x for x in self.resolver]) class CustomFileResolverTest(unittest.TestCase): """Tests for the CustomFileResolver class.""" - def test_custom_paths(self): + def test_custom_paths(self) -> None: """Test if custom paths work correctly.""" # temporary directory 1 tmp_dir1 = mkdtemp() diff --git a/tests/test_finder.py b/tests/test_finder.py index 7fc8475e..531c83ce 100644 --- a/tests/test_finder.py +++ b/tests/test_finder.py @@ -32,7 +32,7 @@ class FinderTest(unittest.TestCase): @classmethod - def setUpClass(cls): + def setUpClass(cls) -> None: cls.file_resolver = FileResolver() cls.file_resolver.add_path(os.path.join(common_test.unittest_dir, 'images')) @@ -51,7 +51,7 @@ def setUpClass(cls): ssl._create_default_https_context = ssl._create_unverified_context @classmethod - def tearDownClass(cls): + def tearDownClass(cls) -> None: GlobalConfig.image_logging_level = cls.prev_loglevel GlobalConfig.image_logging_destination = cls.prev_logpath GlobalConfig.image_logging_step_width = cls.prev_logwidth @@ -59,19 +59,19 @@ def tearDownClass(cls): # TODO: PyTorch has bugs downloading models from their hub on Windows ssl._create_default_https_context = cls.orig_https_context - def setUp(self): + def setUp(self) -> None: # the image logger will recreate its logging destination ImageLogger.step = 1 ImageLogger.accumulate_logging = False - def tearDown(self): + def tearDown(self) -> None: if os.path.exists(GlobalConfig.image_logging_destination): shutil.rmtree(GlobalConfig.image_logging_destination) - def _get_matches_in(self, pattern, dumps): + def _get_matches_in(self, pattern: str, dumps: list[str]) -> list[str]: return [match.group(0) for d in dumps for match in [re.search(pattern, d)] if match] - def _verify_and_get_dumps(self, count, index=1, multistep=False): + def _verify_and_get_dumps(self, count: int, index: int = 1, multistep: bool = False) -> list[str]: dumps = os.listdir(self.logpath) self.assertEqual(len(dumps), count) steps = self._get_matches_in('imglog\d\d\d\d-.+', dumps) @@ -83,7 +83,7 @@ def _verify_and_get_dumps(self, count, index=1, multistep=False): self.assertLessEqual(len(first_steps), len(steps)) return dumps - def _verify_dumped_images(self, needle_name, haystack_name, dumps, backend): + def _verify_dumped_images(self, needle_name, haystack_name, dumps: list[str], backend) -> None: needles = self._get_matches_in(".*needle.*", dumps) self.assertEqual(len(needles), 2) target, config = reversed(needles) if needles[0].endswith(".match") else needles @@ -113,7 +113,7 @@ def _verify_dumped_images(self, needle_name, haystack_name, dumps, backend): self.assertIn(haystack_name, haystack) self.assertTrue(os.path.isfile(os.path.join(self.logpath, haystack))) - def _verify_single_hotmap(self, dumps, backend): + def _verify_single_hotmap(self, dumps: list[str], backend) -> None: hotmaps = self._get_matches_in('.*hotmap.*', dumps) self.assertEqual(len(hotmaps), 1) self.assertIn('3hotmap', hotmaps[0]) @@ -122,7 +122,7 @@ def _verify_single_hotmap(self, dumps, backend): self.assertTrue(os.path.isfile(os.path.join(self.logpath, hotmaps[0]))) @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_configure_backend(self): + def test_configure_backend(self) -> None: """Test for effective configuration for all CV backends.""" finder = Finder() finder.configure_backend("feature") @@ -177,7 +177,7 @@ def test_configure_backend(self): self.assertEqual(finder.params["fmatch"]["backend"], "BruteForce-Hamming") @unittest.skipIf(os.environ.get('DISABLE_AUTOPY', "0") == "1", "AutoPy disabled") - def test_autopy_same(self): + def test_autopy_same(self) -> None: """Test for successful match of same images for the AutoPy CV backend.""" finder = AutoPyFinder() finder.params["find"]["similarity"].value = 1.0 @@ -196,7 +196,7 @@ def test_autopy_same(self): self._verify_single_hotmap(dumps, "autopy") @unittest.skipIf(os.environ.get('DISABLE_AUTOPY', "0") == "1", "AutoPy disabled") - def test_autopy_nomatch(self): + def test_autopy_nomatch(self) -> None: """Test for unsuccessful match of different images for the AutoPy CV backend.""" finder = AutoPyFinder() finder.params["find"]["similarity"].value = 0.25 @@ -211,7 +211,7 @@ def test_autopy_nomatch(self): self._verify_single_hotmap(dumps, "autopy") @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_contour_same(self): + def test_contour_same(self) -> None: """Test for successful match of same images for all contour CV backends.""" finder = ContourFinder() # shape matching is not perfect @@ -253,7 +253,7 @@ def test_contour_same(self): i += 1 @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_contour_nomatch(self): + def test_contour_nomatch(self) -> None: """Test for unsuccessful match of different images for all contour CV backends.""" finder = ContourFinder() finder.params["find"]["similarity"].value = 0.25 @@ -287,7 +287,7 @@ def test_contour_nomatch(self): i += 1 @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_template_same(self): + def test_template_same(self) -> None: """Test for successful match of same images for all template CV backends.""" finder = TemplateFinder() finder.params["find"]["similarity"].value = 1.0 @@ -325,7 +325,7 @@ def test_template_same(self): i += 1 @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_template_nomatch(self): + def test_template_nomatch(self) -> None: """Test for unsuccessful match of different images for all template CV backends.""" finder = TemplateFinder() finder.params["find"]["similarity"].value = 0.25 @@ -359,7 +359,7 @@ def test_template_nomatch(self): i += 1 @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_template_nocolor(self): + def test_template_nocolor(self) -> None: """Test for successful match of colorless images for all template CV backends.""" finder = TemplateFinder() # template matching without color is not perfect @@ -378,7 +378,7 @@ def test_template_nocolor(self): self.assertEqual(matches[0].height, 151) @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_template_multiple(self): + def test_template_multiple(self) -> None: """Test for multiple successful matches of images for default template CV backend.""" finder = TemplateFinder() finder.find(Image('shape_red_box'), Image('all_shapes')) @@ -399,7 +399,7 @@ def test_template_multiple(self): self.assertTrue(os.path.isfile(os.path.join(self.logpath, hotmap))) @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_feature_same(self): + def test_feature_same(self) -> None: """Test for successful match of same images for all feature CV backends.""" finder = FeatureFinder() finder.params["find"]["similarity"].value = 1.0 @@ -444,7 +444,7 @@ def test_feature_same(self): i += 1 @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_feature_nomatch(self): + def test_feature_nomatch(self) -> None: """Test for unsuccessful match of different images for all feature CV backends.""" finder = FeatureFinder() finder.params["find"]["similarity"].value = 0.25 @@ -485,7 +485,7 @@ def test_feature_nomatch(self): i += 1 @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_feature_scaling(self): + def test_feature_scaling(self) -> None: """Test for successful match of scaled images for default feature CV backend.""" finder = FeatureFinder() finder.params["find"]["similarity"].value = 0.25 @@ -497,7 +497,7 @@ def test_feature_scaling(self): self.assertAlmostEqual(matches[0].height, 150, delta=10) @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_feature_rotation(self): + def test_feature_rotation(self) -> None: """Test for successful match of rotated images for default feature CV backend.""" finder = FeatureFinder() finder.params["find"]["similarity"].value = 0.45 @@ -509,7 +509,7 @@ def test_feature_rotation(self): self.assertAlmostEqual(matches[0].height, 180, delta=10) @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_feature_viewport(self): + def test_feature_viewport(self) -> None: """Test for successful match of view-trasformed images for default feature CV backend.""" finder = FeatureFinder() finder.params["find"]["similarity"].value = 0.4 @@ -521,7 +521,7 @@ def test_feature_viewport(self): self.assertAlmostEqual(matches[0].height, 235, delta=10) @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_cascade_same(self): + def test_cascade_same(self) -> None: """Test for successful match of same images for the cascade CV backend.""" finder = CascadeFinder() # no similarty parameter is supported - this is a binary match case @@ -541,7 +541,7 @@ def test_cascade_same(self): self._verify_single_hotmap(dumps, "cascade") @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_cascade_nomatch(self): + def test_cascade_nomatch(self) -> None: """Test for unsuccessful match of different images for the cascade CV backend.""" finder = CascadeFinder() # no similarty parameter is supported - this is a binary match case @@ -557,7 +557,7 @@ def test_cascade_nomatch(self): self._verify_single_hotmap(dumps, "cascade") @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_cascade_scaling(self): + def test_cascade_scaling(self) -> None: """Test for successful match of scaled images for the cascade CV backend.""" finder = CascadeFinder() matches = finder.find(Pattern('n_ibs.xml'), Image('h_ibs_scaled')) @@ -570,7 +570,7 @@ def test_cascade_scaling(self): self.assertAlmostEqual(matches[0].height, 165, delta=5) @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_cascade_rotation(self): + def test_cascade_rotation(self) -> None: """Test for successful match of rotated images for the cascade CV backend.""" finder = CascadeFinder() matches = finder.find(Pattern('n_ibs.xml'), Image('h_ibs_rotated')) @@ -582,7 +582,7 @@ def test_cascade_rotation(self): #self.assertAlmostEqual(matches[0].height, 180, delta=10) @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_cascade_viewport(self): + def test_cascade_viewport(self) -> None: """Test for successful match of view transformed images for the cascade CV backend.""" finder = CascadeFinder() matches = finder.find(Pattern('n_ibs.xml'), Image('h_ibs_viewport')) @@ -597,7 +597,7 @@ def test_cascade_viewport(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_OCR', "0") == "1", "Disabled OpenCV or OCR") - def test_text_same(self): + def test_text_same(self) -> None: """Test for successful match of same images for all text (OCR) CV backends.""" finder = TextFinder() finder.params["find"]["similarity"].value = 1.0 @@ -673,7 +673,7 @@ def test_text_same(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_OCR', "0") == "1", "Disabled OpenCV or OCR") - def test_text_nomatch(self): + def test_text_nomatch(self) -> None: """Test for unsuccessful match of different images for all text (OCR) CV backends.""" finder = TextFinder() finder.params["find"]["similarity"].value = 0.25 @@ -728,7 +728,7 @@ def test_text_nomatch(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_OCR', "0") == "1", "Disabled OpenCV or OCR") - def test_text_basic(self): + def test_text_basic(self) -> None: """Test for successful match of basic text for default text CV (OCR) backend.""" finder = TextFinder() matches = finder.find(Text('Find the word here'), Image('sentence_sans')) @@ -742,7 +742,7 @@ def test_text_basic(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_OCR', "0") == "1", "Disabled OpenCV or OCR") - def test_text_bold(self): + def test_text_bold(self) -> None: """Test for successful match of bold text for default text CV (OCR) backend.""" finder = TextFinder() matches = finder.find(Text('Find the word'), Image('sentence_bold')) @@ -755,7 +755,7 @@ def test_text_bold(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_OCR', "0") == "1", "Disabled OpenCV or OCR") - def test_text_italic(self): + def test_text_italic(self) -> None: """Test for successful match of italic text for default text CV (OCR) backend.""" finder = TextFinder() matches = finder.find(Text('Find the word here'), Image('sentence_italic')) @@ -768,7 +768,7 @@ def test_text_italic(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_OCR', "0") == "1", "Disabled OpenCV or OCR") - def test_text_larger(self): + def test_text_larger(self) -> None: """Test for successful match of larger text for default text CV (OCR) backend.""" finder = TextFinder() matches = finder.find(Text('Find the word'), Image('sentence_larger')) @@ -782,7 +782,7 @@ def test_text_larger(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_OCR', "0") == "1", "Disabled OpenCV or OCR") - def test_text_font(self): + def test_text_font(self) -> None: """Test for successful match of different font text for default text CV (OCR) backend.""" finder = TextFinder() matches = finder.find(Text('Find the word here'), Image('sentence_font')) @@ -793,7 +793,7 @@ def test_text_font(self): self.assertAlmostEqual(matches[0].height, 10, delta=5) @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_tempfeat_same(self): + def test_tempfeat_same(self) -> None: """Test for successful match of same images for the template-feature CV backend.""" finder = TemplateFeatureFinder() finder.params["find"]["similarity"].value = 1.0 @@ -832,7 +832,7 @@ def test_tempfeat_same(self): i += 1 @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "OpenCV disabled") - def test_tempfeat_nomatch(self): + def test_tempfeat_nomatch(self) -> None: """Test for unsuccessful match of different images for the template-feature CV backend.""" finder = TemplateFeatureFinder() finder.params["find"]["similarity"].value = 0.25 @@ -862,7 +862,7 @@ def test_tempfeat_nomatch(self): i += 1 @unittest.skipIf(os.environ.get('DISABLE_PYTORCH', "0") == "1", "PyTorch disabled") - def test_deep_same(self): + def test_deep_same(self) -> None: """Test for successful match of same images for all deep (DL) CV backends.""" finder = DeepFinder() # pattern matching is not perfect @@ -897,7 +897,7 @@ def test_deep_same(self): finder.find(Pattern('cat'), Image('coco_cat')) @unittest.skipIf(os.environ.get('DISABLE_PYTORCH', "0") == "1", "PyTorch disabled") - def test_deep_nomatch(self): + def test_deep_nomatch(self) -> None: """Test for unsuccessful match of different images for all deep (DL) CV backends.""" finder = DeepFinder() finder.params["find"]["similarity"].value = 0.25 @@ -921,7 +921,7 @@ def test_deep_nomatch(self): self.assertTrue(os.path.isfile(os.path.join(self.logpath, hotmap))) @unittest.skipIf(os.environ.get('DISABLE_PYTORCH', "0") == "1", "PyTorch disabled") - def test_deep_cache(self): + def test_deep_cache(self) -> None: """Test the neural network cached storage of deep finders.""" finder = DeepFinder(synchronize=False) @@ -948,7 +948,7 @@ def test_deep_cache(self): finder.net) @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "Disabled OpenCV") - def test_hybrid_same(self): + def test_hybrid_same(self) -> None: """Test for successful match of same images for default hybrid CV backend.""" finder = HybridFinder() finder.configure_backend("template") @@ -968,7 +968,7 @@ def test_hybrid_same(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "Disabled OpenCV") @unittest.skipIf(os.environ.get('DISABLE_OCR', "0") == "1", "Disabled OCR") - def test_hybrid_nomatch(self): + def test_hybrid_nomatch(self) -> None: """Test for unsuccessful match of different images for default hybrid CV backend.""" finder = HybridFinder() finder.configure_backend("autopy") @@ -982,7 +982,7 @@ def test_hybrid_nomatch(self): # verify dumped files count and names (4+4+7) dumps = self._verify_and_get_dumps(5+5+7, multistep=True) - def test_hybrid_fallback(self): + def test_hybrid_fallback(self) -> None: """Test successful match of fallback representations after unsuccessful one.""" finder = HybridFinder() finder.configure_backend("autopy") @@ -1000,7 +1000,7 @@ def test_hybrid_fallback(self): @unittest.skipIf(os.environ.get('DISABLE_AUTOPY', "0") == "1", "AutoPy disabled") @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "Disabled OpenCV") - def test_hybrid_multiconfig(self): + def test_hybrid_multiconfig(self) -> None: """Test hybrid matching with multiple chain configs.""" finder = HybridFinder() # TOOD: replace autopy to improve coverage across variants @@ -1020,7 +1020,7 @@ def test_hybrid_multiconfig(self): class CVParameterTest(unittest.TestCase): """Tests for the computer vision backends parameters.""" - def test_instance_comparison(self): + def test_instance_comparison(self) -> None: """Check if equality and inequality operators are correctly implemented.""" cv1 = CVParameter( 3, min_val=0.003, max_val=150, delta=1030.25, @@ -1036,7 +1036,7 @@ def test_instance_comparison(self): self.assertFalse(cv1 == 10) self.assertTrue(cv1 != 10) - def test_parameter_parsing(self): + def test_parameter_parsing(self) -> None: """Check that basic parameter parsing works.""" expected = CVParameter( 3, min_val=0.003, max_val=150, delta=1030.25, @@ -1045,7 +1045,7 @@ def test_parameter_parsing(self): parsed = CVParameter.from_string("") self.assertEqual(parsed, expected) - def test_value_with_dots(self): + def test_value_with_dots(self) -> None: """Check that the parser doesn't mark values with dots at the end as floats.""" expected = CVParameter( "123456789.", min_val=None, max_val=None, delta=1030.25, @@ -1054,7 +1054,7 @@ def test_value_with_dots(self): parsed = CVParameter.from_string("") self.assertEqual(parsed, expected) - def test_empty_value(self): + def test_empty_value(self) -> None: """Check that the parser handles empty CVParameter value gracefully.""" expected = CVParameter( "", min_val=None, max_val=None, delta=10.0, diff --git a/tests/test_imagelogger.py b/tests/test_imagelogger.py index 7825f109..2141f32a 100644 --- a/tests/test_imagelogger.py +++ b/tests/test_imagelogger.py @@ -27,7 +27,7 @@ class ImageLoggerTest(unittest.TestCase): """Tests for the ImageLogger class.""" @classmethod - def setUpClass(cls): + def setUpClass(cls) -> None: cls._original_logging_level = GlobalConfig.image_logging_level cls._original_destination = GlobalConfig.image_logging_destination ImageLogger.logging_destination @@ -35,12 +35,12 @@ def setUpClass(cls): return super().setUpClass() @classmethod - def tearDownClass(cls): + def tearDownClass(cls) -> None: GlobalConfig.image_logging_level = cls._original_logging_level GlobalConfig.image_logging_destination = cls._original_destination return super().tearDownClass() - def setUp(self): + def setUp(self) -> None: ImageLogger.step = 1 self.imglog = ImageLogger() self.imglog.needle = MagicMock() @@ -50,17 +50,17 @@ def setUp(self): self._patch_mkdir = patch("os.mkdir") self.mock_mkdir = self._patch_mkdir.start() - def tearDown(self): + def tearDown(self) -> None: self._patch_mkdir.stop() return super().tearDown() - def test_step_print(self): + def test_step_print(self) -> None: """Test the string representation of the current step.""" for i in range(1, 10): ImageLogger.step = i self.assertEqual(self.imglog.get_printable_step(), "00{}".format(i)) - def test_image_logging(self): + def test_image_logging(self) -> None: """Test whether the log methods are called with the correct parameters.""" level_mapping = { "debug": 10, @@ -75,14 +75,14 @@ def test_image_logging(self): getattr(self.imglog, name)() self.imglog.log.assert_called_once_with(level) - def test_log_level(self): + def test_log_level(self) -> None: """Check that above a certain log level, images are not logged.""" with TemporaryConfig() as cfg: cfg.image_logging_level = 35 self.assertIsNone(ImageLogger().dump_matched_images()) self.assertIsNone(ImageLogger().dump_hotmap(None, None)) - def test_image_dumping(self): + def test_image_dumping(self) -> None: """Check that images are dumped correctly.""" ImageLogger.step = 18 with patch("os.path.exists", side_effect=lambda _: False): @@ -92,7 +92,7 @@ def test_image_dumping(self): self.imglog.needle.save.assert_called_once_with(os.path.join('imglog', 'imglog018-1needle-test_needle')) self.imglog.haystack.save.assert_called_once_with(os.path.join('imglog', 'imglog018-2haystack-test_haystack')) - def test_hotmap_dumping(self): + def test_hotmap_dumping(self) -> None: """Check that hotmaps are dumped correctly.""" ImageLogger.step = 25 ImageLogger.logging_destination = "some_path" diff --git a/tests/test_interfaces.py b/tests/test_interfaces.py index 66ea6266..c72f4efe 100644 --- a/tests/test_interfaces.py +++ b/tests/test_interfaces.py @@ -23,13 +23,13 @@ class SimpleAPITest(TestCase): - def setUp(self): + def setUp(self) -> None: from guibot import guibot_simple as simple simple.guibot = mock.MagicMock() simple.check_initialized = mock.MagicMock() self.interface = simple - def test_call_delegations(self): + def test_call_delegations(self) -> None: """Test that all calls from the interface to the actual object are valid.""" args = [True, 1, 2.0, "test-args"] kwargs = {"bool": False, "int": 0, "float": 3.0, "str": "test-kwargs"} @@ -52,7 +52,7 @@ def test_call_delegations(self): self.interface.check_initialized.reset_mock() @mock.patch("guibot.guibot_simple.GuiBot") - def test_key_imports(self, mock_guibot): + def test_key_imports(self, mock_guibot) -> None: """Test that all keys imported by the simple interface work.""" self.interface.initialize() mock_guibot.return_value.dc_backend.keymap.ESC = "esc" @@ -67,18 +67,18 @@ def test_key_imports(self, mock_guibot): class ProxyAPITest(TestCase): - def setUp(self): + def setUp(self) -> None: # fake the remote objects dependency for this interface sys.modules["Pyro4"] = mock.MagicMock() from guibot import guibot_proxy as remote self.interface = remote.GuiBotProxy(cv=None, dc=None) self.interface._proxify = mock.MagicMock() - def tearDown(self): + def tearDown(self) -> None: del sys.modules["Pyro4"] @mock.patch('guibot.guibot_proxy.super') - def test_call_delegations(self, mock_super): + def test_call_delegations(self, mock_super) -> None: """Test that all calls from the interface to the actual object are valid.""" args = [True, 1, 2.0, "test-args"] kwargs = {"bool": False, "int": 0, "float": 3.0, "str": "test-kwargs"} diff --git a/tests/test_region_calc.py b/tests/test_region_calc.py index b80f8448..ef12dbf0 100755 --- a/tests/test_region_calc.py +++ b/tests/test_region_calc.py @@ -26,10 +26,10 @@ class RegionTest(unittest.TestCase): @classmethod - def setUpClass(cls): + def setUpClass(cls) -> None: cls.screen = PyAutoGUIController() - def test_position_calc(self): + def test_position_calc(self) -> None: region = Region(10, 20, 300, 200) center = region.center @@ -52,7 +52,7 @@ def test_position_calc(self): self.assertEqual(310, bottom_right.x) self.assertEqual(220, bottom_right.y) - def test_screen_clipping(self): + def test_screen_clipping(self) -> None: screen = RegionTest.screen screen_width = screen.width screen_height = screen.height @@ -73,7 +73,7 @@ def test_screen_clipping(self): self.assertEqual(screen_width - region.x, region.width) self.assertEqual(screen_height - region.y, region.height) - def test_empty_screen_clipping(self): + def test_empty_screen_clipping(self) -> None: screen = Controller() screen_width = screen.width screen_height = screen.height @@ -96,7 +96,7 @@ def test_empty_screen_clipping(self): self.assertEqual(region.width, 300) self.assertEqual(region.height, 200) - def test_nearby(self): + def test_nearby(self) -> None: screen = RegionTest.screen screen_width = screen.width screen_height = screen.height @@ -120,7 +120,7 @@ def test_nearby(self): self.assertEqual(20, region.width) self.assertEqual(10, region.height) - def test_nearby_clipping(self): + def test_nearby_clipping(self) -> None: screen = RegionTest.screen screen_width = screen.width screen_height = screen.height @@ -153,7 +153,7 @@ def test_nearby_clipping(self): self.assertEqual(80, region.width) self.assertEqual(110, region.height) - def test_above(self): + def test_above(self) -> None: region = Region(200, 100, 20, 10).above(50) self.assertEqual(200, region.x) self.assertEqual(50, region.y) @@ -173,7 +173,7 @@ def test_above(self): self.assertEqual(20, region.width) self.assertEqual(110, region.height) - def test_below(self): + def test_below(self) -> None: screen_height = RegionTest.screen.height region = Region(200, 100, 20, 10).below(50) @@ -195,7 +195,7 @@ def test_below(self): self.assertEqual(20, region.width) self.assertEqual(screen_height - region.y, region.height) - def test_left(self): + def test_left(self) -> None: region = Region(200, 100, 20, 10).left(50) self.assertEqual(150, region.x) self.assertEqual(100, region.y) @@ -215,7 +215,7 @@ def test_left(self): self.assertEqual(220, region.width) self.assertEqual(10, region.height) - def test_right(self): + def test_right(self) -> None: screen_width = RegionTest.screen.width region = Region(200, 100, 20, 10).right(50) diff --git a/tests/test_region_control.py b/tests/test_region_control.py index deb3cd47..c5226ff4 100644 --- a/tests/test_region_control.py +++ b/tests/test_region_control.py @@ -19,6 +19,7 @@ import time import shutil import subprocess +from typing import Any import common_test from guibot.config import GlobalConfig @@ -33,7 +34,7 @@ class RegionTest(unittest.TestCase): @classmethod - def setUpClass(cls): + def setUpClass(cls) -> None: cls.file_resolver = FileResolver() cls.file_resolver.add_path(os.path.join(common_test.unittest_dir, 'images')) @@ -44,11 +45,11 @@ def setUpClass(cls): GlobalConfig.image_logging_destination = os.path.join(common_test.unittest_dir, 'tmp') @classmethod - def tearDownClass(cls): + def tearDownClass(cls) -> None: GlobalConfig.image_logging_level = cls.prev_loglevel GlobalConfig.image_logging_destination = cls.prev_logpath - def setUp(self): + def setUp(self) -> None: # gui test scripts self.script_app = os.path.join(common_test.unittest_dir, 'qt5_application.py') self.child_app = None @@ -70,18 +71,18 @@ def setUp(self): self.region = Region() - def tearDown(self): + def tearDown(self) -> None: self.close_windows() if os.path.exists(GlobalConfig.image_logging_destination): shutil.rmtree(GlobalConfig.image_logging_destination) - def show_application(self): + def show_application(self) -> None: python = 'python.exe' if os.name == 'nt' else 'python3' self.child_app = subprocess.Popen([python, self.script_app]) # HACK: avoid small variability in loading speed time.sleep(3) - def close_windows(self): + def close_windows(self) -> None: if self.child_app is not None: self.child_app.terminate() self.wait_end(self.child_app) @@ -90,7 +91,7 @@ def close_windows(self): # HACK: make sure app is really closed time.sleep(0.5) - def wait_end(self, subprocess_pipe, timeout=30): + def wait_end(self, subprocess_pipe: Any, timeout: int = 30) -> int: expires = time.time() + timeout while True: @@ -104,7 +105,7 @@ def wait_end(self, subprocess_pipe, timeout=30): time.sleep(0.2) - def test_get_mouse_location(self): + def test_get_mouse_location(self) -> None: self.region.hover(Location(0, 0)) pos = self.region.mouse_location # Exact match currently not possible, autopy is not pixel perfect. @@ -120,7 +121,7 @@ def test_get_mouse_location(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled OpenCV or PyQt") - def test_hover(self): + def test_hover(self) -> None: self.show_application() match = self.region.find('shape_green_box') @@ -136,14 +137,14 @@ def test_hover(self): self.close_windows() @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_click(self): + def test_click(self) -> None: self.show_application() self.region.click(self.click_control) self.assertEqual(0, self.wait_end(self.child_app)) self.child_app = None @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_right_click(self): + def test_right_click(self) -> None: self.show_application() self.region.right_click(self.context_menu_control) self.region.idle(3).click(self.context_menu_close_control) @@ -151,21 +152,21 @@ def test_right_click(self): self.child_app = None @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_middle_click(self): + def test_middle_click(self) -> None: self.show_application() self.region.middle_click(self.no_control) self.assertEqual(0, self.wait_end(self.child_app)) self.child_app = None @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_double_click(self): + def test_double_click(self) -> None: self.show_application() self.region.double_click(self.double_click_control) self.assertEqual(0, self.wait_end(self.child_app)) self.child_app = None @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_multi_click(self): + def test_multi_click(self) -> None: self.show_application() self.region.multi_click(self.click_control, count=1) self.assertEqual(0, self.wait_end(self.child_app)) @@ -179,7 +180,7 @@ def test_multi_click(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled OpenCV or PyQt") - def test_click_expect(self): + def test_click_expect(self) -> None: self.show_application() with self.assertRaises(FindError): self.region.click_expect('shape_green_box', 'shape_black_box', @@ -191,7 +192,7 @@ def test_click_expect(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled OpenCV or PyQt") - def test_click_vanish(self): + def test_click_vanish(self) -> None: self.show_application() with self.assertRaises(NotFindError): self.region.click_vanish('shape_green_box', 'shape_red_box', @@ -203,14 +204,14 @@ def test_click_vanish(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled OpenCV or PyQt") - def test_click_at_index(self): + def test_click_at_index(self) -> None: self.show_application() self.region.click_at_index('shape_red_box', 0) self.assertEqual(0, self.wait_end(self.child_app)) self.child_app = None @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_mouse_down(self): + def test_mouse_down(self) -> None: self.show_application() self.region.mouse_down(self.mouse_down_control) @@ -222,7 +223,7 @@ def test_mouse_down(self): self.child_app = None @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_mouse_up(self): + def test_mouse_up(self) -> None: self.show_application() # TODO: the GUI only works if mouse-up event is on the previous location @@ -237,7 +238,7 @@ def test_mouse_up(self): @unittest.skipIf(os.environ.get('DISABLE_PYAUTOGUI', "0") == "1", "PyAutoGUI disabled") @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_mouse_scroll(self): + def test_mouse_scroll(self) -> None: # TODO: method not available for other backends self.region.dc_backend = PyAutoGUIController() self.show_application() @@ -252,7 +253,7 @@ def test_mouse_scroll(self): @unittest.skipIf(os.environ.get('DISABLE_DRAG', "0") == "1", "Drag and drop disabled") @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_drag_drop(self): + def test_drag_drop(self) -> None: self.show_application() self.region.drag_drop(self.textedit_control, self.textedit_quit_control) self.assertEqual(0, self.wait_end(self.child_app)) @@ -262,7 +263,7 @@ def test_drag_drop(self): @unittest.skip("Unit test either errors out or is expected failure") #@unittest.expectedFailure # hangs with PyQt5 (worked with PyQt4) #@unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_drag_from(self): + def test_drag_from(self) -> None: self.show_application() self.region.drag_from(self.textedit_control) @@ -276,7 +277,7 @@ def test_drag_from(self): @unittest.skipIf(os.environ.get('DISABLE_DRAG', "0") == "1", "Drag and drop disabled") @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_drop_at(self): + def test_drop_at(self) -> None: self.show_application() self.region.drag_from(self.textedit_control) @@ -286,7 +287,7 @@ def test_drop_at(self): self.child_app = None @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_press_keys(self): + def test_press_keys(self) -> None: self.show_application() time.sleep(1) self.region.press_keys(self.region.ESC) @@ -301,7 +302,7 @@ def test_press_keys(self): self.child_app = None @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_press_at(self): + def test_press_at(self) -> None: self.show_application() self.region.press_at([self.region.ESC], self.textedit_any_control) self.assertEqual(0, self.wait_end(self.child_app)) @@ -310,7 +311,7 @@ def test_press_at(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled OpenCV or PyQt") - def test_press_expect(self): + def test_press_expect(self) -> None: self.show_application() with self.assertRaises(FindError): self.region.press_expect(self.region.SHIFT, 'shape_black_box', @@ -322,7 +323,7 @@ def test_press_expect(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled OpenCV or PyQt") - def test_press_vanish(self): + def test_press_vanish(self) -> None: self.show_application() with self.assertRaises(NotFindError): self.region.press_vanish(self.region.SHIFT, 'shape_green_box', @@ -332,7 +333,7 @@ def test_press_vanish(self): self.close_windows() @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_type_text(self): + def test_type_text(self) -> None: self.show_application() self.region.click(self.textedit_quit_control) self.region.idle(0.2).type_text('quit') @@ -340,27 +341,27 @@ def test_type_text(self): self.child_app = None @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_type_at(self): + def test_type_at(self) -> None: self.show_application() self.region.type_at('quit', self.textedit_quit_control) self.assertEqual(0, self.wait_end(self.child_app)) self.child_app = None @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_click_at(self): + def test_click_at(self) -> None: self.show_application() self.region.click_at(self.click_control, 0, 0) self.assertEqual(0, self.wait_end(self.child_app)) self.child_app = None @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "PyQt disabled") - def test_fill_at(self): + def test_fill_at(self) -> None: self.show_application() self.region.fill_at(self.textedit_quit_control, 'quit', 0, 0) self.assertEqual(0, self.wait_end(self.child_app)) self.child_app = None - def test_select_at(self): + def test_select_at(self) -> None: # NOTE: autopy has a bug with arrow keys which would result in a fatal error # here breaking the entire run self.show_application() diff --git a/tests/test_region_expect.py b/tests/test_region_expect.py index fce8bad9..7af0239a 100644 --- a/tests/test_region_expect.py +++ b/tests/test_region_expect.py @@ -19,6 +19,7 @@ import time import shutil import subprocess +from typing import Any import common_test from guibot.config import GlobalConfig, TemporaryConfig @@ -36,7 +37,7 @@ class RegionTest(unittest.TestCase): @classmethod - def setUpClass(cls): + def setUpClass(cls) -> None: cls.file_resolver = FileResolver() cls.file_resolver.add_path(os.path.join(common_test.unittest_dir, 'images')) @@ -49,22 +50,22 @@ def setUpClass(cls): GlobalConfig.image_logging_destination = os.path.join(common_test.unittest_dir, 'tmp') @classmethod - def tearDownClass(cls): + def tearDownClass(cls) -> None: GlobalConfig.image_logging_level = cls.prev_loglevel GlobalConfig.image_logging_destination = cls.prev_logpath - def setUp(self): + def setUp(self) -> None: self.child_img = None # initialize template matching region to support multiple matches GlobalConfig.hybrid_match_backend = "template" self.region = Region() - def tearDown(self): + def tearDown(self) -> None: self.close_windows() if os.path.exists(GlobalConfig.image_logging_destination): shutil.rmtree(GlobalConfig.image_logging_destination) - def assertAlmostIn(self, match, matches, delta=5): + def assertAlmostIn(self, match, matches, delta: int = 5) -> None: x, y = match for m in matches: mx, my = m @@ -73,14 +74,14 @@ def assertAlmostIn(self, match, matches, delta=5): return raise AssertionError("%s not near any of %s" % (match, matches)) - def show_image(self, filename): + def show_image(self, filename: str) -> None: filename = self.file_resolver.search(filename) python = 'python.exe' if os.name == 'nt' else 'python3' self.child_img = subprocess.Popen([python, self.script_img, filename]) # HACK: avoid small variability in loading speed time.sleep(3) - def close_windows(self): + def close_windows(self) -> None: if self.child_img is not None: self.child_img.terminate() self.wait_end(self.child_img) @@ -89,7 +90,7 @@ def close_windows(self): # make sure image is really closed time.sleep(0.5) - def wait_end(self, subprocess_pipe, timeout=30): + def wait_end(self, subprocess_pipe: Any, timeout: int = 30) -> int: expires = time.time() + timeout while True: @@ -104,7 +105,7 @@ def wait_end(self, subprocess_pipe, timeout=30): time.sleep(0.2) @unittest.skipIf(os.environ.get('DISABLE_PYAUTOGUI', "0") == "1", "PyAutoGUI disabled") - def test_initialize(self): + def test_initialize(self) -> None: screen_width = PyAutoGUIController().width screen_height = PyAutoGUIController().height @@ -122,7 +123,7 @@ def test_initialize(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled OpenCV or PyQt") - def test_find(self): + def test_find(self) -> None: self.show_image('all_shapes') match = self.region.find(Image('shape_green_box')) @@ -148,7 +149,7 @@ def test_find(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled OpenCV or PyQt") - def test_find_center_offset(self): + def test_find_center_offset(self) -> None: self.show_image('all_shapes.png') match = self.region.find(Image('shape_blue_circle.png')) @@ -165,7 +166,7 @@ def test_find_center_offset(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "Disabled OpenCV") - def test_find_error(self): + def test_find_error(self) -> None: try: self.region.find(Image('shape_blue_circle.png'), 0) self.fail('exception was not thrown') @@ -181,7 +182,7 @@ def test_find_error(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled OpenCV or PyQt") - def test_find_all(self): + def test_find_all(self) -> None: self.show_image('all_shapes') greenbox = Image('shape_green_box') @@ -236,7 +237,7 @@ def test_find_all(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled OpenCV or PyQt") - def test_find_zero_matches(self): + def test_find_zero_matches(self) -> None: self.show_image('all_shapes') matches = self.region.find_all(Image('shape_blue_circle')) @@ -247,7 +248,7 @@ def test_find_zero_matches(self): self.assertEqual(len(matches), 0) self.close_windows() - def test_find_in_animation(self): + def test_find_in_animation(self) -> None: """Test a switch where a moving match is actually matched when stopping.""" match_frames = [Match(0, 0, 10, 20, 0, 0, 1.0), Match(30, 30, 10, 20, 0, 0, 1.0), Match(30, 45, 10, 20, 0, 0, 1.0), Match(30, 45, 10, 20, 0, 0, 1.0)] @@ -263,7 +264,7 @@ def test_find_in_animation(self): @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled PyQt") @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "Disabled OpenCV") - def test_find_guess_target_image(self): + def test_find_guess_target_image(self) -> None: """Test finding image from string with and without extension.""" self.show_image('all_shapes') imgroot = os.path.join(common_test.unittest_dir, 'images') @@ -277,7 +278,7 @@ def test_find_guess_target_image(self): @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled PyQt") @unittest.skipIf(os.environ.get('DISABLE_OCR', "0") == "1", "Disabled OCR") - def test_find_guess_target_match(self): + def test_find_guess_target_match(self) -> None: """Test target guess from match file configuration (target has match config).""" self.show_image('all_shapes') imgroot = os.path.join(common_test.unittest_dir, 'images') @@ -299,7 +300,7 @@ def test_find_guess_target_match(self): @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled PyQt") @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "Disabled OpenCV") @unittest.skipIf(os.environ.get('DISABLE_AUTOPY', "0") == "1", "Disabled AutoPy") - def test_find_guess_target_steps(self): + def test_find_guess_target_steps(self) -> None: """Test target guess from data file extension (target has no match config).""" self.show_image('all_shapes') imgroot = os.path.join(common_test.unittest_dir, 'images') @@ -311,7 +312,7 @@ def test_find_guess_target_steps(self): @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled PyQt") @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "Disabled OpenCV") - def test_find_guess_target_default(self): + def test_find_guess_target_default(self) -> None: """Test target guess ending with default type if also unknown data type.""" self.show_image('all_shapes') imgroot = os.path.join(common_test.unittest_dir, 'images') @@ -325,7 +326,7 @@ def test_find_guess_target_default(self): @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled PyQt") @unittest.skipIf(os.environ.get('DISABLE_OCR', "0") == "1", "Disabled OCR") - def test_find_guess_target_from_match(self): + def test_find_guess_target_from_match(self) -> None: """Test target guess not failing with default text type if also missing data file.""" self.show_image('all_shapes') imgroot = os.path.join(common_test.unittest_dir, 'images') @@ -347,7 +348,7 @@ def test_find_guess_target_from_match(self): @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled PyQt") @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1", "Disabled OpenCV") - def test_sample(self): + def test_sample(self) -> None: """Test that sampling results in good similarity for identical visuals.""" self.show_image('all_shapes') @@ -360,7 +361,7 @@ def test_sample(self): @unittest.skipIf(os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled PyQt") @unittest.skipIf(os.environ.get('DISABLE_AUTOPY', "0") == "1", "Disabled AutoPy") - def test_sample_no_similarity(self): + def test_sample_no_similarity(self) -> None: """Test that sampling results in 0.0 similarity if backend doesn't support it.""" self.show_image('all_shapes') @@ -374,7 +375,7 @@ def test_sample_no_similarity(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled OpenCV or PyQt") - def test_exists(self): + def test_exists(self) -> None: self.show_image('all_shapes') match = self.region.find(Image('shape_blue_circle')) @@ -388,7 +389,7 @@ def test_exists(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled OpenCV or PyQt") - def test_wait(self): + def test_wait(self) -> None: self.show_image('all_shapes') match = self.region.wait(Image('shape_blue_circle'), timeout=5) @@ -399,7 +400,7 @@ def test_wait(self): @unittest.skipIf(os.environ.get('DISABLE_OPENCV', "0") == "1" or os.environ.get('DISABLE_PYQT', "0") == "1", "Disabled OpenCV or PyQt") - def test_wait_vanish(self): + def test_wait_vanish(self) -> None: self.show_image('all_shapes') self.assertRaises(NotFindError, self.region.wait_vanish, 'all_shapes', timeout=10) diff --git a/tests/test_target.py b/tests/test_target.py index 517b6490..a8121dc9 100644 --- a/tests/test_target.py +++ b/tests/test_target.py @@ -28,10 +28,10 @@ class ImageTest(unittest.TestCase): - def setUp(self): + def setUp(self) -> None: self.file_all_shapes = os.path.join(common_test.unittest_dir, 'images', 'all_shapes.png') - def test_basic(self): + def test_basic(self) -> None: """Test basic image target initialization.""" image = Image(self.file_all_shapes) @@ -42,7 +42,7 @@ def test_basic(self): self.assertIsInstance(image.match_settings, Finder) self.assertFalse(image.use_own_settings) - def test_copy_object(self): + def test_copy_object(self) -> None: """Test sane image target copying.""" image = Image(self.file_all_shapes) @@ -55,7 +55,7 @@ def test_copy_object(self): self.assertEqual(image.height, my_copy.height) self.assertEqual(image.center_offset, my_copy.center_offset) - def test_center_offset(self): + def test_center_offset(self) -> None: """Test image target center offset calculation.""" image = Image(self.file_all_shapes) @@ -80,7 +80,7 @@ def test_center_offset(self): self.assertEqual(0, center_offset.x) self.assertEqual(0, center_offset.y) - def test_similarity(self): + def test_similarity(self) -> None: """Test image target copying based on similarity.""" image = Image(self.file_all_shapes) @@ -96,7 +96,7 @@ def test_similarity(self): self.assertEqual(image.height, new_image.height) self.assertEqual(image.center_offset, new_image.center_offset) - def test_save(self): + def test_save(self) -> None: """Test image target data and match settings saving.""" image = Image(self.file_all_shapes) @@ -135,7 +135,7 @@ def test_save(self): self.assertEqual(returned_image.match_settings.params[category][key].fixed, loaded_image.match_settings.params[category][key].fixed) - def test_nonexisting_image(self): + def test_nonexisting_image(self) -> None: """Test image target initialization with missing image data.""" try: Image('foobar_does_not_exist') @@ -143,7 +143,7 @@ def test_nonexisting_image(self): except FileNotFoundError: pass - def test_image_cache(self): + def test_image_cache(self) -> None: """Test image target caching for the image data.""" image = Image(self.file_all_shapes) @@ -185,7 +185,7 @@ class ChainTest(unittest.TestCase): os.path.join(gettempdir(), "17.csv.steps"), ] - def setUp(self): + def setUp(self) -> None: """Create mocks and enable patches.""" # start with a clean environment self._old_paths = list(FileResolver._target_paths) @@ -209,7 +209,7 @@ def setUp(self): self._patches["PIL_Image_open"].start() return super().setUp() - def tearDown(self): + def tearDown(self) -> None: """Cleanup removing any patches and files created.""" # start with a clean environment FileResolver().clear() @@ -223,28 +223,26 @@ def tearDown(self): os.unlink(fn) return super().tearDown() - def _build_chain(self, stepsfile_contents, stepsfile=None): + def _build_chain(self, stepsfile_contents: str, stepsfile: str = None) -> Finder: """ Create an instance of :py:class:`guibot.target.Chain` to be used by the tests. - :param str stepsfile_contents: contents for the stepsfile to be passed when creating the finder - :param str stepsfile: name of the stepsfile to load or None to use the default + :param stepsfile_contents: contents for the stepsfile to be passed when creating the finder + :param stepsfile: name of the stepsfile to load or None to use the default :returns: an instance of the finder - :rtype: :py:class:`finder.Finder` """ filename = self._create_temp_file(prefix=self.stepsfile_name, extension=".steps", contents=stepsfile_contents) return Chain(os.path.splitext(filename)[0]) - def _get_match_file(self, filename): + def _get_match_file(self, filename: str) -> Finder: """ Mock function to replace py:func:`Finder.from_match_file`. It will generated a finder based on the filename provided. - :param str filename: match filename for the configuration + :param filename: match filename for the configuration :returns: target finder with the parsed (and generated) settings - :rtype: :py:class:`finder.Finder` """ # guess the backend from the filename parts = filename.split("_") @@ -257,15 +255,14 @@ def _get_match_file(self, filename): } return finder_mock - def _create_temp_file(self, prefix=None, extension=None, contents=None): + def _create_temp_file(self, prefix: str = None, extension: str = None, contents: str = None) -> str: """ Create a temporary file, keeping track of it for auto-removal. - :param str prefix: string to prepend to the file name - :param str extension: extension of the generated file - :param str contents: contents to write on the file + :param prefix: string to prepend to the file name + :param extension: extension of the generated file + :param contents: contents to write on the file :returns: name of the temporary file generated - :rtype: str """ fd, filename = mkstemp(prefix=prefix, suffix=extension) if contents: @@ -274,13 +271,12 @@ def _create_temp_file(self, prefix=None, extension=None, contents=None): self._tmpfiles.append(filename) return filename - def _create_temp_text_file(self, filename): + def _create_temp_text_file(self, filename: str) -> str: """ Create a temporary text file, needed for some text file including tests. - :param str filename: name of the fake text file + :param filename: name of the fake text file :returns: name of the temporary text file generated - :rtype: str The Text target stepfile data accepts either a file or a text string and we test with both modes. For the first mode we need a real file. @@ -292,7 +288,7 @@ def _create_temp_text_file(self, filename): self.non_existing_files.append(basename) return basename - def test_stepsfile_lookup(self): + def test_stepsfile_lookup(self) -> None: """Test that the stepsfile will be searched using :py:class:`guibot.fileresolver.FileResolver`.""" tmp_dir = mkdtemp() tmp_steps_file = os.path.join(tmp_dir, self.stepsfile_missing) + ".steps" @@ -309,7 +305,7 @@ def test_stepsfile_lookup(self): os.unlink(tmp_steps_file) os.rmdir(tmp_dir) - def test_match_file_loading(self): + def test_match_file_loading(self) -> None: """Test that all match files in the steps file are correctly loaded.""" # actually create files as mocking os.open() would be too cumbersome text_file = self._create_temp_text_file("item_for_text") @@ -331,7 +327,7 @@ def test_match_file_loading(self): # we need to have a finder created for each .match file (inside Chain itself) self.mock_match_read.assert_any_call(match) - def test_steps_list(self): + def test_steps_list(self) -> None: """Test that the resulting step chain contains all the items from the stepsfile.""" # actually create files as mocking os.open() would be too cumbersome text_file = self._create_temp_text_file("item_for_text") @@ -350,7 +346,7 @@ def test_steps_list(self): expected_types = [Image, Image, Image, Pattern, Pattern, Image, Image, Text] self.assertEqual([type(s) for s in chain], expected_types) - def test_step_save(self): + def test_step_save(self) -> None: """Test that dumping a chain to a file works and that the content is preserved.""" # actually create files as mocking os.open() would be too cumbersome text_file = self._create_temp_text_file("some_text_file") @@ -416,7 +412,7 @@ def test_step_save(self): # and for the steps file itself self.assertEqual(generated_match_names, expected_match_names) - def test_malformed_stepsfile(self): + def test_malformed_stepsfile(self) -> None: """Test that the malformed stepsfiles are correctly handled.""" stepsfile_contents = [ "item_for_contour.png some_contour_matchfile.match", @@ -431,7 +427,7 @@ def test_malformed_stepsfile(self): ] self.assertRaises(IOError, self._build_chain, os.linesep.join(stepsfile_contents)) - def test_invalid_backends(self): + def test_invalid_backends(self) -> None: """Test that unsupported backends are detected when loading and saving.""" # test on load stepsfile_contents = [ @@ -447,7 +443,7 @@ def test_invalid_backends(self): chain._steps.append(Text("", match_settings=finder)) self.assertRaises(UnsupportedBackendError, chain.save, "foobar") - def test_nested_stepsfiles(self): + def test_nested_stepsfiles(self) -> None: """Test that stepsfiles within stepsfiles are correctly handled.""" # actually create files as mocking os.open() would be too cumbersome text_file = self._create_temp_text_file("item_for_text") @@ -472,7 +468,7 @@ def test_nested_stepsfiles(self): expected_types = [Image, Pattern, Text] self.assertEqual([type(s) for s in chain._steps], expected_types) - def test_nested_stepsfiles_order(self): + def test_nested_stepsfiles_order(self) -> None: """Test that stepsfiles within stepsfiles are loaded in order.""" # actually create files as mocking os.open() would be too cumbersome text_file = self._create_temp_text_file("item_for_text") From b960005f013c246cc94bedcd41d8120a43faa52d Mon Sep 17 00:00:00 2001 From: Kimberly Lara Date: Fri, 2 Aug 2024 14:08:13 +0200 Subject: [PATCH 2/2] Add automatic type checking workflow for all future changes --- .github/workflows/lint.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..d08e15e7 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,24 @@ +name: Lint Check + +on: [push, pull_request] + +jobs: + mypy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install mypy + + - name: Run mypy + run: | + mypy guibot