Skip to content

Commit

Permalink
Add individual Node refresh capability and maximum depth traversal (#14)
Browse files Browse the repository at this point in the history
* Add individual Node refresh capability

* Ability to refresh elements (with/out callback)

* Fix table test assertions

* Inline dependencies with Py3.8 as 1.0.0

* Add isort, black and basic invoke tasks

* Typo in docstring

* Add package installation by default

* Ignore the lines which are too long

* Initial automatic linting

* Fix line length to 120

* Align linting

* Test file TODO typo

* Fix flake8 config comment

* Ability to run specific test

* Add and test maximum depth parameter

* Fix test logging and add changelog

* Update changelog for 1.0.0

* Fix actions retrieval bug

* Fix bug with library callbacks reinitialization

* Refactor test logic
  • Loading branch information
cmin764 committed Oct 9, 2023
1 parent b7a8055 commit 885cf0e
Show file tree
Hide file tree
Showing 11 changed files with 320 additions and 176 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Java Access Bridge Wrapper changelog

## 1.1.0 (date: 03.10.2023)

- Add node/element refresh capability
- Support for limiting the element tree maximum depth

## 1.0.0 (date: 26.09.2023)

- Regular expression support with elements search
- Full project revamp and DX improvement (no breaking user-facing API changes, just started to follow semver)

## 0.12.0 (date: 08.06.2023)

- Make context tree and nodes iterable
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ Update requirements and install the library in development mode

Run tests

inv test
inv test # runs all the tests in all scenarios
inv test -s -t test_jab_wrapper.py # runs all the tests from a file in one simple common scenario
inv test -s -c -t test_jab_wrapper.py::test_depth # as above, but specific test and captures output

## Packaging

Expand Down
12 changes: 6 additions & 6 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "java-access-bridge-wrapper"
version = "1.0.0"
version = "1.1.0"
description = "Python wrapper for the Windows Java Access Bridge"
license = "Apache-2.0"
readme = "README.md"
Expand Down
94 changes: 65 additions & 29 deletions src/JABWrapper/context_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import re
import threading
from dataclasses import dataclass
from typing import List
from typing import List, Optional

from JABWrapper.jab_types import AccessibleContextInfo, JavaObject
from JABWrapper.jab_wrapper import JavaAccessBridgeWrapper
Expand Down Expand Up @@ -35,21 +35,35 @@ class NodeLocator:

class ContextNode:
def __init__(
self, jab_wrapper: JavaAccessBridgeWrapper, context: JavaObject, lock, ancestry: int = 0, parse_children=True
self,
jab_wrapper: JavaAccessBridgeWrapper,
context: JavaObject,
lock: threading.RLock,
ancestry: int = 0,
parse_children: bool = True,
max_depth: Optional[int] = None,
) -> None:
self._jab_wrapper = jab_wrapper
self._lock = lock
self.ancestry = ancestry
self.context: JavaObject = context
self._should_parse_children = parse_children
self._max_depth = max_depth

self.state = None
self.visible_children_count = 0
self.virtual_accessible_name = None
self._parse_context()
self.children: list[ContextNode] = []
if parse_children:
self._parse_children()
self._children: list[ContextNode] = []

# Populate the element with data and its children if enabled.
self.refresh()

def _parse_context(self) -> None:
@property
def children(self):
with self._lock:
return self._children

def parse_context(self) -> None:
logging.debug(f"Parsing element={self.context}")
self._aci: AccessibleContextInfo = self._jab_wrapper.get_context_info(self.context)
logging.debug(f"Parsed element info={self._aci}")
Expand Down Expand Up @@ -108,10 +122,33 @@ def context_info(self, context_info: AccessibleContextInfo) -> None:
self._aci = context_info

def _parse_children(self) -> None:
if self._max_depth is not None and self.ancestry >= self._max_depth:
return

for i in range(0, self._aci.childrenCount):
child_context = self._jab_wrapper.get_child_context(self.context, i)
child_node = ContextNode(self._jab_wrapper, child_context, self._lock, self.ancestry + 1)
self.children.append(child_node)
child_node = ContextNode(
self._jab_wrapper,
child_context,
self._lock,
self.ancestry + 1,
parse_children=self._should_parse_children,
max_depth=self._max_depth,
)
self._children.append(child_node)

def refresh(self):
"""Refresh the current element and its children only.
Useful when you want to refresh just a subtree of elements starting from the
current one as root, instead of the entire app.
"""
with self._lock:
self.state = None
self._children.clear()
self.parse_context()
if self._should_parse_children:
self._parse_children()

def __repr__(self) -> str:
"""
Expand Down Expand Up @@ -166,9 +203,9 @@ def __str__(self) -> str:

def get_search_element_tree(self) -> List[NodeLocator]:
"""
Returns node info for all searcheable elements.
Returns node info for all searchable elements.
"""
nodes = list()
nodes = []
nodes.append(
NodeLocator(
self.context_info.name,
Expand All @@ -185,7 +222,7 @@ def get_search_element_tree(self) -> List[NodeLocator]:
)
)
for child in self.children:
nodes += child.get_search_element_tree()
nodes.extend(child.get_search_element_tree())
return nodes

def traverse(self):
Expand All @@ -200,16 +237,11 @@ def _get_node_by_context(self, context: JavaObject):
if self._jab_wrapper.is_same_object(self.context, context):
return self
else:
for child in self.children:
for child in self._children:
node = child._get_node_by_context(context)
if node:
return node

def _update_node(self) -> None:
self.children = []
self._parse_context()
self._parse_children()

def _match_attrs(self, search_elements: List[SearchElement]) -> bool:
for search_element in search_elements:
attr = getattr(self._aci, search_element.name)
Expand All @@ -223,7 +255,7 @@ def _match_attrs(self, search_elements: List[SearchElement]) -> bool:

def get_by_attrs(self, search_elements: List[SearchElement]) -> List:
"""
Get element with given seach attributes.
Get element with given search attributes.
The SearchElement object takes a name of the field and the field value. For example:
Expand All @@ -237,7 +269,7 @@ def get_by_attrs(self, search_elements: List[SearchElement]) -> List:
found = self._match_attrs(search_elements)
if found:
elements.append(self)
for child in self.children:
for child in self._children:
child_elements = child.get_by_attrs(search_elements)
elements.extend(child_elements)
return elements
Expand Down Expand Up @@ -294,22 +326,26 @@ def get_visible_children(self) -> List:
visible_children = []
logging.debug(f"Expected visible children count={self.visible_children_count}")
if self.visible_children_count > 0:
found_visible_children = self._jab_wrapper.get_visible_children(self.context, 0)
visible_children_info = self._jab_wrapper.get_visible_children(self.context, 0)
logging.debug(f"Found visible children count={self.visible_children_count}")
for i in range(0, found_visible_children.returnedChildrenCount):
for i in range(0, visible_children_info.returnedChildrenCount):
visible_child = ContextNode(
self._jab_wrapper, found_visible_children.children[i], self._lock, self.ancestry + 1, False
self._jab_wrapper,
visible_children_info.children[i],
self._lock,
self.ancestry + 1,
parse_children=False,
)
visible_children.append(visible_child)
return visible_children


class ContextTree:
@log_exec_time("Init context tree")
def __init__(self, jab_wrapper: JavaAccessBridgeWrapper) -> None:
def __init__(self, jab_wrapper: JavaAccessBridgeWrapper, max_depth: Optional[int] = None) -> None:
self._lock = threading.RLock()
self._jab_wrapper = jab_wrapper
self.root = ContextNode(jab_wrapper, jab_wrapper.context, self._lock)
self.root = ContextNode(jab_wrapper, jab_wrapper.context, self._lock, parse_children=True, max_depth=max_depth)
self._register_callbacks()

def __iter__(self):
Expand Down Expand Up @@ -369,15 +405,15 @@ def _property_selection_change_cp(self, source: JavaObject) -> None:
with self._lock:
node: ContextNode = self.root._get_node_by_context(source)
if node:
node._parse_context()
node.parse_context()
logging.debug(f"Selected text changed for node={node}")

@retry_callback
def _property_text_change_cp(self, source: JavaObject) -> None:
with self._lock:
node: ContextNode = self.root._get_node_by_context(source)
if node:
node._parse_context()
node.parse_context()
logging.debug(f"Text changed for node={node}")

@retry_callback
Expand All @@ -390,7 +426,7 @@ def _visible_data_change_cp(self, source: JavaObject) -> None:
with self._lock:
node = self.root._get_node_by_context(source)
if node:
node._update_node()
node.refresh()
logging.debug(f"Visible data changed for node tree={repr(node)}")

@retry_callback
Expand Down Expand Up @@ -486,7 +522,7 @@ def _register_callbacks(self) -> None:
are generated from the Access Bridge
"""
if self._jab_wrapper.ignore_callbacks:
logging.debug("Ignoring callback regitering for Context Node")
logging.debug("Ignoring callback registering for Context Node")
return

self._jab_wrapper.clear_callbacks()
Expand Down
28 changes: 10 additions & 18 deletions src/JABWrapper/jab_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
wintypes,
)
from dataclasses import dataclass
from typing import Callable, List, Tuple
from typing import Callable, List, Optional, Tuple

import win32process

Expand Down Expand Up @@ -165,30 +165,29 @@ def __init__(self, ignore_callbacks=False) -> None:

def _init(self) -> None:
logging.debug("Loading WindowsAccessBridge")

if "RC_JAVA_ACCESS_BRIDGE_DLL" not in os.environ:
raise OSError("Environment variable: RC_JAVA_ACCESS_BRIDGE_DLL not found")
if not os.path.isfile(os.path.normpath(os.environ["RC_JAVA_ACCESS_BRIDGE_DLL"])):
raise FileNotFoundError(f"File not found: {os.environ['RC_JAVA_ACCESS_BRIDGE_DLL']}")
self._wab: cdll = cdll.LoadLibrary(os.path.normpath(os.environ["RC_JAVA_ACCESS_BRIDGE_DLL"]))
logging.debug("WindowsAccessBridge loaded succesfully")

# Any reader can register callbacks here that are executed when `AccessBridge` events are seen.
self._context_callbacks: dict[str, List[Callable[[JavaObject], None]]] = dict()
self._define_functions()
if not self.ignore_callbacks:
self._define_callbacks()
self._set_callbacks()
self._wab.Windows_run()

self._hwnd: wintypes.HWND = None
self._hwnd: Optional[wintypes.HWND] = None
self._vmID = c_long()
self.context = JavaObject()

# Any reader can register callbacks here that are executed when AccessBridge events are seen
self._context_callbacks: dict[str, List[Callable[[JavaObject], None]]] = dict()

def shutdown(self):
self._context_callbacks = dict()
if not self.ignore_callbacks:
self._remove_callbacks()
self._context_callbacks.clear()

def _define_functions(self) -> None:
# void Windows_run()
Expand Down Expand Up @@ -628,7 +627,7 @@ def switch_window_by_title(self, title: str) -> int:
Raises:
Exception: Window not found.
"""
self._context_callbacks = dict()
self._context_callbacks.clear()
self._hwnd: wintypes.HWND = None
self._vmID = c_long()
self.context = JavaObject()
Expand All @@ -654,11 +653,7 @@ def switch_window_by_title(self, title: str) -> int:

logging.info(
"Found Java window text={} pid={} hwnd={} vmID={} context={}\n".format(
java_window.title,
java_window.pid,
self._hwnd,
self._vmID,
self.context,
java_window.title, java_window.pid, self._hwnd, self._vmID, self.context
)
)

Expand All @@ -677,7 +672,7 @@ def switch_window_by_pid(self, pid: int) -> int:
Raises:
Exception: Window not found.
"""
self._context_callbacks = dict()
self._context_callbacks.clear()
self._hwnd: wintypes.HWND = None
self._vmID = c_long()
self.context = JavaObject()
Expand Down Expand Up @@ -1818,10 +1813,7 @@ def register_callback(self, name: str, callback: Callable[[JavaObject], None]) -
* popup_menu_will_become_visible
"""
logging.debug(f"Registering callback={name}")
if name in self._context_callbacks:
self._context_callbacks[name].append(callback)
else:
self._context_callbacks[name] = [callback]
self._context_callbacks.setdefault(name, []).append(callback)

def clear_callbacks(self):
self._context_callbacks.clear()
Expand Down
2 changes: 1 addition & 1 deletion src/JABWrapper/parsers/actions_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def parse(self, jab_wrapper: JavaAccessBridgeWrapper, context: JavaObject) -> No
self._actions[actionInfo.name.lower()] = actionInfo

def list_actions(self) -> List[str]:
return self._actions.keys()
return list(self._actions)

def do_action(self, jab_wrapper: JavaAccessBridgeWrapper, context: JavaObject, action: str) -> None:
if not action.lower() in self._actions:
Expand Down
Loading

0 comments on commit 885cf0e

Please sign in to comment.