# Shadowstep (in development)
**Shadowstep** is a modular UI automation framework for Android applications, built on top of Appium.
It provides:
* Lazy element lookup and interaction (without driver interaction)
* PageObject navigation engine
* Reconnect logic on session failure
* ADB and Appium terminal integration
* DSL-style assertions (`should.have`, `should.be`)
---
## Contents
* [Installation](#installation)
* [Quick Start](#quick-start)
* [Test Setup (Pytest)](#test-setup-pytest)
* [Element API](#element-api)
* [Collections API (`Elements`)](#collections-api-elements)
* [Page Objects and Navigation](#page-objects-and-navigation)
* [ADB and Terminal](#adb-and-terminal)
* [Architecture Notes](#architecture-notes)
* [Limitations](#limitations)
* [License](#license)
---
## Installation
```bash
pip install appium-python-client-shadowstep
```
---
## Quick Start
```python
from shadowstep.shadowstep import Shadowstep
application = Shadowstep()
capabilities = {
"platformName": "android",
"appium:automationName": "uiautomator2",
"appium:UDID": 123456789,
"appium:noReset": True,
"appium:autoGrantPermissions": True,
"appium:newCommandTimeout": 900,
}
application.connect(server_ip='127.0.0.1', server_port=4723, capabilities=capabilities)
```
---
## Test Setup (Pytest)
```python
import pytest
from shadowstep.shadowstep import Shadowstep
@pytest.fixture()
def app():
shadowstep = Shadowstep()
shadowstep.connect(capabilities=Config.APPIUM_CAPABILITIES,
command_executor=Config.APPIUM_COMMAND_EXECUTOR,
server_ip=Config.APPIUM_IP,
server_port=Config.APPIUM_PORT,
ssh_user=Config.SSH_USERNAME,
ssh_password=Config.SSH_PASSWORD, )
yield shadowstep
shadowstep.disconnect()
```
---
## Element API
```python
el = app.get_element({"resource-id": "android:id/title"})
el.tap()
el.text
el.get_attribute("enabled")
```
Lazy DOM tree navigation (declarative)
```python
el = app.get_element({'class': 'android.widget.ImageView'}).
get_parent().get_sibling({'resource-id': 'android:id/summary'}).
from_parent(
ancestor_locator={'text': 'title', 'resource-id': 'android:id/title'},
cousin_locator={'resource-id': 'android:id/summary'}
).get_element(
{"resource-id": "android:id/switch_widget"})
```
**Key features:**
* Lazy evaluation (`find_element` only called on interaction)
* Support for `dict` and XPath locators
* Built-in retry and session reconnect
* Rich API: `tap`, `click`, `scroll_to`, `get_sibling`, `get_parent`, `drag_to`, `send_keys`, `wait_visible`, etc.
---
## ## Element Collections (`Elements`)
Returned by `get_elements()` (generator-based):
```python
elements = app.get_element({'class': 'android.widget.ImageView'}).get_elements({"class": "android.widget.TextView"})
first = elements.first()
all_items = elements.to_list()
filtered = elements.filter(lambda e: "Wi-Fi" in (e.text or ""))
filtered.should.have.count(minimum=1)
```
```python
els = app.get_elements({'class': 'android.widget.TextView'}) # lazy
els.first.get_attributes() # driver interaction with first element only
... # some logic
els.next.get_attributes() # driver interation with second element only
```
**DSL assertions:**
```python
items.should.have.count(minimum=3)
items.should.have.text("Battery")
items.should.be.all_visible()
```
---
## Page Objects and Navigation
### Defining a Page
```python
import logging
from shadowstep.element.element import Element
from shadowstep.page_base import PageBaseShadowstep
class PageAbout(PageBaseShadowstep):
def __init__(self):
super().__init__()
self.logger = logging.getLogger(__name__)
def __repr__(self):
return f"{self.name} ({self.__class__.__name__})"
@property
def edges(self):
return {"PageMain": self.to_main}
def to_main(self):
self.shadowstep.terminal.press_back()
return self.shadowstep.get_page("PageMain")
@property
def name(self) -> str:
return "About"
@property
def title(self) -> Element:
return self.shadowstep.get_element(locator={'text': 'About', 'class': 'android.widget.TextView'})
def is_current_page(self) -> bool:
try:
return self.title.is_visible()
except Exception as e:
self.logger.error(e)
return False
```
```python
import logging
import inspect
import os
import traceback
from typing import Dict, Any, Callable
from shadowstep.element.element import Element
from shadowstep.page_base import PageBaseShadowstep
logger = logging.getLogger(__name__)
class PageEtalon(PageBaseShadowstep):
def __init__(self):
super().__init__()
self.current_path = os.path.dirname(os.path.abspath(inspect.getframeinfo(inspect.currentframe()).filename))
def __repr__(self):
return f"{self.name} ({self.__class__.__name__})"
@property
def edges(self) -> dict[str, Callable[[], None]]:
return {}
@property
def name(self) -> str:
return "PageEtalon"
# --- Title bar ---
@property
def title_locator(self) -> Dict[str, Any]:
return {
"package": "com.android.launcher3",
"class": "android.widget.FrameLayout",
"text": "",
"resource-id": "android:id/content",
}
@property
def title(self) -> Element:
logger.info(f"{inspect.currentframe().f_code.co_name}")
return self.shadowstep.get_element(locator=self.title_locator)
# --- Main scrollable container ---
@property
def recycler_locator(self):
# self.logger.info(f"{inspect.currentframe().f_code.co_name}")
return {"scrollable": "true"}
@property
def recycler(self):
# self.logger.info(f"{inspect.currentframe().f_code.co_name}")
return self.shadowstep.get_element(locator=self.recycler_locator)
def _recycler_get(self, locator):
# self.logger.info(f"{inspect.currentframe().f_code.co_name}")
return self.recycler.scroll_to_element(locator=locator)
# --- Search button (if present) ---
@property
def search_button_locator(self) -> Dict[str, Any]:
return {'text': 'Search'}
@property
def search_button(self) -> Element:
logger.info(f"{inspect.currentframe().f_code.co_name}")
return self.shadowstep.get_element(locator=self.search_button_locator)
# --- Back button button (if present) ---
@property
def back_button_locator(self) -> Dict[str, Any]:
return {'text': 'back'}
@property
def back_button(self) -> Element:
logger.info(f"{inspect.currentframe().f_code.co_name}")
return self.shadowstep.get_element(locator=self.back_button_locator)
# --- Elements in scrollable container ---
@property
def element_text_view_locator(self) -> dict:
return {"text": "Element in scrollable container"}
@property
def element_text_view(self) -> Element:
logger.info(f"{inspect.currentframe().f_code.co_name}")
return self.recycler.scroll_to_element(self.element_text_view_locator)
@property
def summary_element_text_view(self) -> str:
logger.info(f"{inspect.currentframe().f_code.co_name}")
return self._get_summary_text(self.element_text_view)
# --- PRIVATE METHODS ---
def _get_summary_text(self, element: Element) -> str:
try:
summary = element.get_sibling({"resource-id": "android:id/summary"})
return self.recycler.scroll_to_element(summary.locator).get_attribute("text")
except Exception as error:
logger.error(f"Error:\n{error}\n{traceback.format_exc()}")
return ""
# --- is_current_page (always in bottom) ---
def is_current_page(self) -> bool:
try:
if self.title.is_visible():
return True
return False
except Exception as error:
logger.info(f"{inspect.currentframe().f_code.co_name}: {error}")
return False
```
### Auto-discovery Requirements
* File: `pages/page_*.py`
* Class: starts with `Page`, inherits from `PageBase`
* Must define `edges` property
### Navigation Example
```python
self.shadowstep.navigator.navigate(source_page=self.page_main, target_page=self.page_display)
assert self.page_display.is_current_page()
```
---
## ADB and Terminal
### ADB Usage
```python
app.adb.press_home()
app.adb.install_apk("path/to/app.apk")
app.adb.input_text("hello")
```
* Direct ADB via `subprocess`
* Supports input, app install/uninstall, screen record, file transfer, etc.
### Terminal Usage
```python
app.terminal.start_activity(package="com.example", activity=".MainActivity")
app.terminal.tap(x=1345, y=756)
app.terminal.past_text(text='hello')
```
* Uses driver.execute_script(`mobile: shell`) or SSH backend (will separate in future)
* Backend selected based on SSH credentials
---
## Architecture Notes
* All interactions are lazy (nothing fetched before usage)
* Reconnects on session loss (`InvalidSessionIdException`, etc.)
* Supports pytest and CI/CD workflows
* Designed for extensibility and modularity
---
## Limitations
* Android only (no iOS or web support)
---
## License
[MIT License](LICENSE)
Raw data
{
"_id": null,
"home_page": null,
"name": "appium-python-client-shadowstep",
"maintainer": null,
"docs_url": null,
"requires_python": ">=3.8",
"maintainer_email": null,
"keywords": "appium, testing, uiautomator2, android, automation, framework",
"author": null,
"author_email": "Klim Molokov <klim.molokov@yandex.ru>",
"download_url": "https://files.pythonhosted.org/packages/8e/43/1f19d5884adc530237500cbf4634725e57dc3ee3800d036730c60cdf6a04/appium_python_client_shadowstep-0.31.99.tar.gz",
"platform": null,
"description": "# Shadowstep (in development)\n\n**Shadowstep** is a modular UI automation framework for Android applications, built on top of Appium.\n\nIt provides:\n\n* Lazy element lookup and interaction (without driver interaction)\n* PageObject navigation engine\n* Reconnect logic on session failure\n* ADB and Appium terminal integration\n* DSL-style assertions (`should.have`, `should.be`)\n\n---\n\n## Contents\n\n* [Installation](#installation)\n* [Quick Start](#quick-start)\n* [Test Setup (Pytest)](#test-setup-pytest)\n* [Element API](#element-api)\n* [Collections API (`Elements`)](#collections-api-elements)\n* [Page Objects and Navigation](#page-objects-and-navigation)\n* [ADB and Terminal](#adb-and-terminal)\n* [Architecture Notes](#architecture-notes)\n* [Limitations](#limitations)\n* [License](#license)\n\n---\n\n## Installation\n\n```bash\npip install appium-python-client-shadowstep\n```\n\n---\n\n## Quick Start\n\n```python\nfrom shadowstep.shadowstep import Shadowstep\n\napplication = Shadowstep()\ncapabilities = {\n \"platformName\": \"android\",\n \"appium:automationName\": \"uiautomator2\",\n \"appium:UDID\": 123456789,\n \"appium:noReset\": True,\n \"appium:autoGrantPermissions\": True,\n \"appium:newCommandTimeout\": 900,\n}\napplication.connect(server_ip='127.0.0.1', server_port=4723, capabilities=capabilities)\n```\n\n---\n\n## Test Setup (Pytest)\n\n```python\nimport pytest\nfrom shadowstep.shadowstep import Shadowstep\n\n\n@pytest.fixture()\ndef app():\n shadowstep = Shadowstep()\n shadowstep.connect(capabilities=Config.APPIUM_CAPABILITIES,\n command_executor=Config.APPIUM_COMMAND_EXECUTOR,\n server_ip=Config.APPIUM_IP,\n server_port=Config.APPIUM_PORT,\n ssh_user=Config.SSH_USERNAME,\n ssh_password=Config.SSH_PASSWORD, )\n yield shadowstep\n shadowstep.disconnect()\n```\n\n---\n\n## Element API\n\n```python\nel = app.get_element({\"resource-id\": \"android:id/title\"})\nel.tap()\nel.text\nel.get_attribute(\"enabled\")\n```\n\nLazy DOM tree navigation (declarative)\n\n```python\nel = app.get_element({'class': 'android.widget.ImageView'}).\n get_parent().get_sibling({'resource-id': 'android:id/summary'}).\n from_parent(\n ancestor_locator={'text': 'title', 'resource-id': 'android:id/title'},\n cousin_locator={'resource-id': 'android:id/summary'}\n).get_element(\n {\"resource-id\": \"android:id/switch_widget\"})\n\n```\n\n**Key features:**\n\n* Lazy evaluation (`find_element` only called on interaction)\n* Support for `dict` and XPath locators\n* Built-in retry and session reconnect\n* Rich API: `tap`, `click`, `scroll_to`, `get_sibling`, `get_parent`, `drag_to`, `send_keys`, `wait_visible`, etc.\n\n---\n\n## ## Element Collections (`Elements`)\n\nReturned by `get_elements()` (generator-based):\n\n```python\nelements = app.get_element({'class': 'android.widget.ImageView'}).get_elements({\"class\": \"android.widget.TextView\"})\n\nfirst = elements.first()\nall_items = elements.to_list()\n\nfiltered = elements.filter(lambda e: \"Wi-Fi\" in (e.text or \"\"))\nfiltered.should.have.count(minimum=1)\n```\n\n```python\nels = app.get_elements({'class': 'android.widget.TextView'}) # lazy\n\nels.first.get_attributes() # driver interaction with first element only\n... # some logic\nels.next.get_attributes() # driver interation with second element only\n```\n\n\n**DSL assertions:**\n\n```python\nitems.should.have.count(minimum=3)\nitems.should.have.text(\"Battery\")\nitems.should.be.all_visible()\n```\n\n---\n\n## Page Objects and Navigation\n\n### Defining a Page\n\n```python\nimport logging\nfrom shadowstep.element.element import Element\nfrom shadowstep.page_base import PageBaseShadowstep\n\n\nclass PageAbout(PageBaseShadowstep):\n def __init__(self):\n super().__init__()\n self.logger = logging.getLogger(__name__)\n\n def __repr__(self):\n return f\"{self.name} ({self.__class__.__name__})\"\n\n @property\n def edges(self):\n return {\"PageMain\": self.to_main}\n\n def to_main(self):\n self.shadowstep.terminal.press_back()\n return self.shadowstep.get_page(\"PageMain\")\n\n @property\n def name(self) -> str:\n return \"About\"\n\n @property\n def title(self) -> Element:\n return self.shadowstep.get_element(locator={'text': 'About', 'class': 'android.widget.TextView'})\n\n def is_current_page(self) -> bool:\n try:\n return self.title.is_visible()\n except Exception as e:\n self.logger.error(e)\n return False\n```\n\n```python\nimport logging\nimport inspect\nimport os\nimport traceback\nfrom typing import Dict, Any, Callable\nfrom shadowstep.element.element import Element\nfrom shadowstep.page_base import PageBaseShadowstep\n\nlogger = logging.getLogger(__name__)\n\nclass PageEtalon(PageBaseShadowstep):\n def __init__(self):\n super().__init__()\n self.current_path = os.path.dirname(os.path.abspath(inspect.getframeinfo(inspect.currentframe()).filename))\n\n def __repr__(self):\n return f\"{self.name} ({self.__class__.__name__})\"\n\n @property\n def edges(self) -> dict[str, Callable[[], None]]:\n return {}\n\n @property\n def name(self) -> str:\n return \"PageEtalon\"\n\n # --- Title bar ---\n\n @property\n def title_locator(self) -> Dict[str, Any]:\n return {\n \"package\": \"com.android.launcher3\",\n \"class\": \"android.widget.FrameLayout\",\n \"text\": \"\",\n \"resource-id\": \"android:id/content\",\n }\n\n @property\n def title(self) -> Element:\n logger.info(f\"{inspect.currentframe().f_code.co_name}\")\n return self.shadowstep.get_element(locator=self.title_locator)\n\n # --- Main scrollable container ---\n\n @property\n def recycler_locator(self):\n # self.logger.info(f\"{inspect.currentframe().f_code.co_name}\")\n return {\"scrollable\": \"true\"}\n\n @property\n def recycler(self):\n # self.logger.info(f\"{inspect.currentframe().f_code.co_name}\")\n return self.shadowstep.get_element(locator=self.recycler_locator)\n\n def _recycler_get(self, locator):\n # self.logger.info(f\"{inspect.currentframe().f_code.co_name}\")\n return self.recycler.scroll_to_element(locator=locator)\n\n # --- Search button (if present) ---\n\n @property\n def search_button_locator(self) -> Dict[str, Any]:\n return {'text': 'Search'}\n\n @property\n def search_button(self) -> Element:\n logger.info(f\"{inspect.currentframe().f_code.co_name}\")\n return self.shadowstep.get_element(locator=self.search_button_locator)\n\n # --- Back button button (if present) ---\n\n @property\n def back_button_locator(self) -> Dict[str, Any]:\n return {'text': 'back'}\n\n @property\n def back_button(self) -> Element:\n logger.info(f\"{inspect.currentframe().f_code.co_name}\")\n return self.shadowstep.get_element(locator=self.back_button_locator)\n\n # --- Elements in scrollable container ---\n\n @property\n def element_text_view_locator(self) -> dict:\n return {\"text\": \"Element in scrollable container\"}\n\n @property\n def element_text_view(self) -> Element:\n logger.info(f\"{inspect.currentframe().f_code.co_name}\")\n return self.recycler.scroll_to_element(self.element_text_view_locator)\n\n @property\n def summary_element_text_view(self) -> str:\n logger.info(f\"{inspect.currentframe().f_code.co_name}\")\n return self._get_summary_text(self.element_text_view)\n\n # --- PRIVATE METHODS ---\n\n def _get_summary_text(self, element: Element) -> str:\n try:\n summary = element.get_sibling({\"resource-id\": \"android:id/summary\"})\n return self.recycler.scroll_to_element(summary.locator).get_attribute(\"text\")\n except Exception as error:\n logger.error(f\"Error:\\n{error}\\n{traceback.format_exc()}\")\n return \"\"\n\n # --- is_current_page (always in bottom) ---\n\n def is_current_page(self) -> bool:\n try:\n if self.title.is_visible():\n return True\n return False\n except Exception as error:\n logger.info(f\"{inspect.currentframe().f_code.co_name}: {error}\")\n return False\n```\n\n### Auto-discovery Requirements\n\n* File: `pages/page_*.py`\n* Class: starts with `Page`, inherits from `PageBase`\n* Must define `edges` property\n\n### Navigation Example\n\n```python\nself.shadowstep.navigator.navigate(source_page=self.page_main, target_page=self.page_display)\nassert self.page_display.is_current_page()\n```\n\n---\n\n## ADB and Terminal\n\n### ADB Usage\n\n```python\napp.adb.press_home()\napp.adb.install_apk(\"path/to/app.apk\")\napp.adb.input_text(\"hello\")\n```\n\n* Direct ADB via `subprocess`\n* Supports input, app install/uninstall, screen record, file transfer, etc.\n\n### Terminal Usage\n\n```python\napp.terminal.start_activity(package=\"com.example\", activity=\".MainActivity\")\napp.terminal.tap(x=1345, y=756)\napp.terminal.past_text(text='hello')\n```\n\n* Uses driver.execute_script(`mobile: shell`) or SSH backend (will separate in future)\n* Backend selected based on SSH credentials\n\n---\n\n## Architecture Notes\n\n* All interactions are lazy (nothing fetched before usage)\n* Reconnects on session loss (`InvalidSessionIdException`, etc.)\n* Supports pytest and CI/CD workflows\n* Designed for extensibility and modularity\n\n---\n\n## Limitations\n\n* Android only (no iOS or web support)\n\n---\n\n## License\n[MIT License](LICENSE)\n",
"bugtrack_url": null,
"license": null,
"summary": "UI Testing Framework powered by Appium Python Client",
"version": "0.31.99",
"project_urls": {
"Repository": "https://github.com/molokov-klim/Appium-Python-Client-Shadowstep"
},
"split_keywords": [
"appium",
" testing",
" uiautomator2",
" android",
" automation",
" framework"
],
"urls": [
{
"comment_text": null,
"digests": {
"blake2b_256": "4521a9b98b07520d0c8a63596687a44f20547cfab74fc6f4091d86ff375a2eae",
"md5": "e61a448c9319b571a1c264fe9d632b75",
"sha256": "b0f78dc5fcc73db1d602eeed7ea7cf834762c21945fe53778c6242c0fa786569"
},
"downloads": -1,
"filename": "appium_python_client_shadowstep-0.31.99-py3-none-any.whl",
"has_sig": false,
"md5_digest": "e61a448c9319b571a1c264fe9d632b75",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": ">=3.8",
"size": 142040,
"upload_time": "2025-07-23T14:39:15",
"upload_time_iso_8601": "2025-07-23T14:39:15.110570Z",
"url": "https://files.pythonhosted.org/packages/45/21/a9b98b07520d0c8a63596687a44f20547cfab74fc6f4091d86ff375a2eae/appium_python_client_shadowstep-0.31.99-py3-none-any.whl",
"yanked": false,
"yanked_reason": null
},
{
"comment_text": null,
"digests": {
"blake2b_256": "8e431f19d5884adc530237500cbf4634725e57dc3ee3800d036730c60cdf6a04",
"md5": "2df97f3939970c16226f38ed66e35aa9",
"sha256": "7f66b219215eadc8b5287285aef143106cad0712502ac6ddc095b33ca6e1eee8"
},
"downloads": -1,
"filename": "appium_python_client_shadowstep-0.31.99.tar.gz",
"has_sig": false,
"md5_digest": "2df97f3939970c16226f38ed66e35aa9",
"packagetype": "sdist",
"python_version": "source",
"requires_python": ">=3.8",
"size": 131958,
"upload_time": "2025-07-23T14:39:17",
"upload_time_iso_8601": "2025-07-23T14:39:17.278316Z",
"url": "https://files.pythonhosted.org/packages/8e/43/1f19d5884adc530237500cbf4634725e57dc3ee3800d036730c60cdf6a04/appium_python_client_shadowstep-0.31.99.tar.gz",
"yanked": false,
"yanked_reason": null
}
],
"upload_time": "2025-07-23 14:39:17",
"github": true,
"gitlab": false,
"bitbucket": false,
"codeberg": false,
"github_user": "molokov-klim",
"github_project": "Appium-Python-Client-Shadowstep",
"travis_ci": false,
"coveralls": false,
"github_actions": true,
"lcname": "appium-python-client-shadowstep"
}