An open API service indexing awesome lists of open source software.

https://github.com/molokov-klim/Appium-Python-Client-Shadowstep

UI Testing Framework powered by Appium Python Client
https://github.com/molokov-klim/Appium-Python-Client-Shadowstep

Last synced: 5 months ago
JSON representation

UI Testing Framework powered by Appium Python Client

Awesome Lists containing this project

README

          

# Shadowstep

**Shadowstep** — a modern Python framework for Android test automation. Powered
by Appium.
_Write tests, not boilerplate._

___

[![License][badge-license]][link-license]
[![License Check][badge-license-check]][workflow-license-check]

___

[![Ask DeepWiki][badge-deepwiki]][link-deepwiki]
[![Watch in Action][badge-youtube]][link-youtube]

___

[![PyPI version][badge-pypi]][link-pypi]
[![Downloads][badge-downloads]][link-downloads]
[![Python][badge-python]][link-python]
[![Appium][badge-appium]][link-appium]

___

[![Pyright Type Check][badge-pyright]][workflow-pyright]
[![Ruff Lint][badge-ruff]][workflow-ruff]
[![Unit Tests][badge-unit-tests]][workflow-unit-tests]
[![Integration Tests][badge-integration]][workflow-integration]

___

[badge-license]: https://img.shields.io/badge/license-MIT-blue
[link-license]: LICENSES/MIT.txt
[badge-license-check]: https://github.com/molokov-klim/Appium-Python-Client-Shadowstep/actions/workflows/license-check.yml/badge.svg
[workflow-license-check]: https://github.com/molokov-klim/Appium-Python-Client-Shadowstep/actions/workflows/license-check.yml
[badge-deepwiki]: https://deepwiki.com/badge.svg
[link-deepwiki]: https://deepwiki.com/molokov-klim/Appium-Python-Client-Shadowstep
[badge-youtube]: https://img.shields.io/badge/YouTube-red?logo=youtube
[link-youtube]: https://www.youtube.com/playlist?list=PLGFbKpf3cI31d1TLlQXCszl88dutdruKx
[badge-pypi]: https://badge.fury.io/py/appium-python-client-shadowstep.svg
[link-pypi]: https://badge.fury.io/py/appium-python-client-shadowstep
[badge-downloads]: https://pepy.tech/badge/appium-python-client-shadowstep
[link-downloads]: https://pepy.tech/project/appium-python-client-shadowstep
[badge-python]: https://img.shields.io/badge/python-3.9%2B-blue
[link-python]: https://www.python.org
[badge-appium]: https://img.shields.io/badge/appium-5.2.2%2B-blue
[link-appium]: https://appium.io
[badge-pyright]: https://github.com/molokov-klim/Appium-Python-Client-Shadowstep/actions/workflows/pyright.yml/badge.svg
[workflow-pyright]: https://github.com/molokov-klim/Appium-Python-Client-Shadowstep/actions/workflows/pyright.yml
[badge-ruff]: https://github.com/molokov-klim/Appium-Python-Client-Shadowstep/actions/workflows/ruff.yml/badge.svg
[workflow-ruff]: https://github.com/molokov-klim/Appium-Python-Client-Shadowstep/actions/workflows/ruff.yml
[badge-unit-tests]: https://github.com/molokov-klim/Appium-Python-Client-Shadowstep/actions/workflows/unit_tests.yml/badge.svg
[workflow-unit-tests]: https://github.com/molokov-klim/Appium-Python-Client-Shadowstep/actions/workflows/unit_tests.yml
[badge-integration]: https://github.com/molokov-klim/Appium-Python-Client-Shadowstep/actions/workflows/integration_tests.yml/badge.svg
[workflow-integration]: https://github.com/molokov-klim/Appium-Python-Client-Shadowstep/actions/workflows/integration_tests.yml

## Table of Contents

- [Key Features](#key-features)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Architecture](#architecture)
- [Core API](#core-api)
- [Shadowstep (Facade)](#shadowstep-facade)
- [Element (Facade)](#element-facade)
- [PageBase](#pagebase)
- [Additional Modules](#additional-modules)
- [Navigator](#navigator)
- [Locator System](#locator-system)
- [Terminal](#terminal)
- [Logcat](#logcat)
- [Image Recognition](#image-recognition)
- [Page Object Generator](#page-object-generator)
- [Usage Examples](#usage-examples)
- [Quality Tools](#quality-tools)

___

## Key Features

### Architectural Patterns

- **Facade Pattern** — simplified interface for Appium interactions
- **Page Object Pattern** — structured UI representation
- **Singleton Pattern** — single point of access to driver
- **Navigator Pattern** — graph-based page navigation
- **DSL over locator syntax** — type-safe fluent API for UiSelector with IDE autocomplete
- **Flexible locator system** — dict, xpath, UiSelector with auto-conversion

### Functionality

- **Flexible locator system** — dict, xpath, UiSelector with auto-conversion
- **Rich DOM navigation** — parent, sibling, cousin relationships
- **Advanced gestures** — tap, swipe, fling, scroll, pinch, zoom
- **Lazy/Greedy element search** — performance optimization
- **Fail-safe decorators** — automatic error handling and reconnection
- **Built-in logging** — Loguru-style colored output
- **Image Recognition** — find elements by images (OpenCV)
- **Logcat Streaming** — capture logs via WebSocket
- **Page Object Generator** — auto-generate page objects from XML
- **SSH/ADB Support** — remote command execution

___

## Installation

### Requirements

- Python 3.9+
- Appium Server 2.x
- UiAutomator2 Driver
- Android Device/Emulator

### Install via pip

```bash
pip install appium-python-client-shadowstep
```

### Install via uv (recommended)

```bash
# Install uv
pip install uv

# Create virtual environment and install dependencies
uv venv
source .venv/bin/activate # Linux/Mac
# or
.venv\Scripts\activate # Windows

uv pip install appium-python-client-shadowstep
```

### Dependencies

Core:

- `Appium-Python-Client >= 5.2.2`
- `selenium >= 4.36`
- `networkx >= 3.2.1` — navigation
- `opencv-python >= 4.12.0.88` — image recognition
- `paramiko >= 4.0.0` — SSH
- `websocket-client >= 1.8.0` — logcat

Additional:

- `lxml >= 6.0.2` — XML parsing
- `jinja2 >= 3.1.6` — template engine
- `pytesseract >= 0.3.10` — OCR

___

## Quick Start

### 1. Start Appium Server

```bash
appium --use-drivers=uiautomator2
```

### 2. Basic Example

```python
from shadowstep import Shadowstep

# Connect to device
app = Shadowstep()
app.connect(
capabilities={
"platformName": "Android",
"appium:automationName": "UiAutomator2",
"appium:deviceName": "emulator-5554",
"appium:appPackage": "com.android.settings",
"appium:appActivity": ".Settings",
}
)

# Find and interact with element
element = app.get_element({"text": "Network & internet"})
element.tap()

# Wait for element
element.wait_visible(timeout=10)

# Check properties
print(element.text) # "Network & internet"
print(element.is_displayed()) # True

# Disconnect
app.disconnect()
```

### 3. Page Object Example

```python
from shadowstep import PageBaseShadowstep, Element

class PageSettings(PageBaseShadowstep):
@property
def edges(self):
return {
"PageNetworkInternet": self.to_network_internet,
}

@property
def title(self) -> Element:
return self.shadowstep.get_element({
"text": "Settings",
"resource-id": "com.android.settings:id/homepage_title"
})

@property
def network_internet(self) -> Element:
return self.recycler.scroll_to_element({
"text": "Network & internet"
})

@property
def recycler(self) -> Element:
return self.shadowstep.get_element({
"resource-id": "com.android.settings:id/settings_homepage_container"
})

def to_network_internet(self):
self.network_internet.tap()
return self.shadowstep.get_page("PageNetworkInternet")

def is_current_page(self) -> bool:
return self.title.is_visible()

# Usage
app = Shadowstep()
# ... connect ...

page = app.get_page("PageSettings")
assert page.is_current_page()
page.to_network_internet()
```

___

## Architecture

### Facade Pattern

The project implements **Facade Pattern** at two levels:

#### 1. Shadowstep (Main Facade)

`Shadowstep` — the main facade that hides the complexity of Appium WebDriver
interactions and provides a simple API.

```python
class Shadowstep(ShadowstepBase):
"""Main Facade for mobile automation."""

def __init__(self):
super().__init__()
self.navigator = PageNavigator(self)
self.converter = LocatorConverter()
self.mobile_commands = MobileCommands()
```

**Hidden subsystems:**

- `ShadowstepBase` — WebDriver management, connections
- `PageNavigator` — page navigation
- `LocatorConverter` — locator conversion
- `MobileCommands` — UiAutomator2 commands
- `Terminal/Transport` — ADB and SSH
- `ShadowstepLogcat` — logging

#### 2. Element (Element Facade)

`Element` — facade for working with mobile elements, combining multiple
specialized classes.

```python
class Element(ElementBase):
"""Public API for Element."""

def __init__(self, locator, shadowstep, ...):
super().__init__(...)
self.utilities = ElementUtilities(self)
self.properties = ElementProperties(self)
self.dom = ElementDOM(self)
self.actions = ElementActions(self)
self.gestures = ElementGestures(self)
self.coordinates = ElementCoordinates(self)
self.screenshots = ElementScreenshots(self)
self.waiting = ElementWaiting(self)
```

**Hidden subsystems:**

- `ElementDOM` — finding related elements (parent, sibling, cousin)
- `ElementActions` — text input, clearing
- `ElementGestures` — tap, swipe, scroll, fling
- `ElementProperties` — attributes, states
- `ElementCoordinates` — coordinates, center
- `ElementScreenshots` — screenshots
- `ElementWaiting` — waits
- `ElementUtilities` — helper functions

### Architecture Diagram

```text
┌─────────────────────────────────────────────────────────────┐
│ User/Test Code │
└──────────────────────┬──────────────────────────────────────┘

┌─────────────┴─────────────┐
│ │
▼ ▼
┌────────────────────┐ ┌──────────────────┐
│ Shadowstep │◄─────┤ PageBase │
│ (Main Facade) │ │ (Page Objects) │
└────────┬───────────┘ └──────────────────┘

├─► Navigator (Page Graph)
├─► LocatorConverter
├─► MobileCommands
├─► Terminal/Transport
└─► ShadowstepLogcat


┌────────────────────┐
│ Element (Facade) │
└────────┬───────────┘

├─► ElementDOM
├─► ElementActions
├─► ElementGestures
├─► ElementProperties
├─► ElementCoordinates
├─► ElementScreenshots
└─► ElementWaiting


┌────────────────────┐
│ Appium/Selenium │
│ (WebDriver) │
└────────────────────┘
```

___

## Core API

### Shadowstep (Facade)

Main facade class for managing mobile testing.

#### Device Connection

```python
# Via capabilities
app.connect(
capabilities={
"platformName": "Android",
"appium:automationName": "UiAutomator2",
"appium:deviceName": "emulator-5554",
"appium:appPackage": "com.android.settings",
"appium:appActivity": ".Settings",
},
server_ip="127.0.0.1",
server_port=4723
)

# Via options
from appium.options.android import UiAutomator2Options

options = UiAutomator2Options()
options.platform_name = "Android"
options.device_name = "emulator-5554"
options.app_package = "com.android.settings"

app.connect(
capabilities={},
options=options
)

# With SSH for remote server
app.connect(
capabilities={...},
server_ip="192.168.1.100",
ssh_user="user",
ssh_password="password"
)

# Check connection
if app.is_connected():
print("Connected successfully")

# Reconnect
app.reconnect()

# Disconnect
app.disconnect()
```

#### Finding Elements

```python
# Via dict
element = app.get_element({
"text": "Network & internet",
"resource-id": "android:id/title"
})

# Via xpath
element = app.get_element(("xpath", '//android.widget.TextView[@text="Settings"]'))

# Via UiSelector
from shadowstep.locator import UiSelector

element = app.get_element(UiSelector().text("Settings"))

# Multiple search (greedy)
elements = app.get_elements({"class": "android.widget.TextView"})
for el in elements:
print(el.text)

# With timeout and polling
element = app.get_element(
locator={"text": "Network"},
timeout=30,
poll_frequency=0.5
)
```

#### Screen-Level Gestures

```python
# Tap by coordinates
app.tap(x=500, y=1000, duration=100)

# Click
app.click(x=500, y=1000)

# Double click
app.double_click(x=500, y=1000)

# Long click
app.long_click(x=500, y=1000, duration=1000)

# Swipe
app.swipe(
left=100, top=500,
width=800, height=400,
direction="up",
percent=0.75,
speed=5000
)

# Swipe shortcuts
app.swipe_up(percent=0.75, speed=5000)
app.swipe_down(percent=0.75)
app.swipe_left()
app.swipe_right()

# Scroll
app.scroll(
left=100, top=500,
width=800, height=400,
direction="down",
percent=0.5,
speed=2000
)

# Drag
app.drag(start_x=500, start_y=1000, end_x=500, end_y=500, speed=2500)

# Fling
app.fling(
left=100, top=500,
width=800, height=400,
direction="up",
speed=7500
)

# Pinch (zoom)
app.pinch_open(left=100, top=500, width=800, height=600, percent=0.5)
app.pinch_close(left=100, top=500, width=800, height=600, percent=0.5)
```

#### Screenshots and Page Source

```python
# Get screenshot
screenshot = app.get_screenshot() # bytes

# Save screenshot
app.save_screenshot(path="/tmp", filename="screen.png")

# Save page source
app.save_source(path="/tmp", filename="page.xml")
```

#### Application Management

```python
# Start activity
app.start_activity(
intent="com.android.settings/.Settings",
component="com.android.settings/.Settings"
)

# Get current application
package = app.get_current_package() # "com.android.settings"
activity = app.get_current_activity() # ".Settings"

# Background/Foreground
app.background_app(seconds=2)
app.activate_app(app_id="com.android.settings")

# Check installation
is_installed = app.is_app_installed(app_id="com.android.settings")

# Application state
state = app.query_app_state(app_id="com.android.settings")
# 0=not installed, 1=not running, 2=background, 3=background+suspended, 4=foreground

# Terminate application
app.terminate_app(app_id="com.android.settings")

# Clear data
app.clear_app(app_id="com.android.settings")
```

#### System Commands

```python
# Press keys
app.press_key(keycode=3) # HOME
app.press_key(keycode=4) # BACK

# Open notifications
app.open_notifications()

# Lock/unlock
app.lock()
app.unlock(key="1234", unlock_type="pin")
is_locked = app.is_locked()

# Shell commands
result = app.shell("echo test")

# Type text
app.type(text="test input")

# Keyboard
is_shown = app.is_keyboard_shown()
app.hide_keyboard()
```

#### File Operations

```python
import base64

# Push file
content = base64.b64encode(b"test content").decode()
app.push_file(remote_path="/sdcard/test.txt", payload=content)

# Pull file
content = app.pull_file(remote_path="/sdcard/test.txt")
decoded = base64.b64decode(content)

# Pull folder
folder_data = app.pull_folder(remote_path="/sdcard/Android")

# Delete file
app.delete_file(remote_path="/sdcard/test.txt")

# Via ADB wrapper
app.push(source_file_path="local.txt", destination_file_path="/sdcard/test.txt")
```

#### Clipboard

```python
import base64

# Set clipboard text
text = "test clipboard"
encoded = base64.b64encode(text.encode()).decode()
app.set_clipboard(content=encoded)

# Get clipboard text
clipboard = app.get_clipboard()
decoded = base64.b64decode(clipboard).decode()
```

#### Screen Recording

```python
# Start recording
app.start_recording_screen()

# Stop recording
video_bytes = app.stop_recording_screen()

# Save video
with open("recording.mp4", "wb") as f:
f.write(video_bytes)
```

#### Network Settings

```python
# Get network state
connectivity = app.get_connectivity(services=["wifi", "data"])

# Set state
app.set_connectivity(wifi=True, data=False)

# Bluetooth
app.bluetooth(action="enable")
app.bluetooth(action="disable")

# GPS
app.toggle_gps()
is_enabled = app.is_gps_enabled()

# NFC
app.nfc(action="enable")
app.nfc(action="disable")

# Geolocation
app.set_geolocation(latitude=37.7749, longitude=-122.4194, altitude=10.0)
location = app.get_geolocation(latitude=37.7749, longitude=-122.4194, altitude=10.0)
app.reset_geolocation()
app.refresh_gps_cache(timeout_ms=5000)
```

#### Device Information

```python
# Battery
battery = app.battery_info()
# {"level": 80, "state": 2, "temperature": 25, ...}

# Device
device = app.device_info()

# Display density
density = app.get_display_density() # 420

# System bars
bars = app.get_system_bars()
# {"statusBar": {...}, "navigationBar": {...}}

# Device time
time_str = app.get_device_time()

# Performance data
types = app.get_performance_data_types()
perf_data = app.get_performance_data(
package_name="com.android.settings",
data_type="cpuinfo"
)
```

#### Page Navigation

```python
# Get page instance
settings_page = app.get_page("PageSettings")

# Navigate via graph
settings_page = app.get_page("PageSettings")
network_page = settings_page.to_network_internet()

# Resolve page
page = app.resolve_page("PageNetworkInternet")
```

___

### Element (Facade)

Facade class for interacting with UI elements.

#### Creating Element

```python
# Via Shadowstep
element = app.get_element({"text": "Settings"})

# Directly
from shadowstep.element import Element

element = Element(
locator={"text": "Settings"},
shadowstep=app,
timeout=30,
poll_frequency=0.5
)

# With native WebElement
from appium.webdriver.webelement import WebElement

native_el = driver.find_element(...)
element = Element(
locator={"text": "Settings"},
shadowstep=app,
native=native_el
)
```

#### DOM Navigation

```python
element = app.get_element({"text": "Network & internet"})

# Search within element (lazy)
inner = element.get_element({"class": "android.widget.TextView"})

# Multiple search (greedy)
children = element.get_elements({"class": "android.widget.TextView"})

# Parent
parent = element.get_parent()
all_parents = element.get_parents()

# Sibling
sibling = element.get_sibling({"resource-id": "android:id/summary"})
all_siblings = element.get_siblings({"class": "android.widget.TextView"})

# Cousin (sibling of parent)
cousin = element.get_cousin(
cousin_locator={"text": "Apps"},
depth_to_parent=1 # go up 1 level
)
cousins = element.get_cousins(
cousin_locator={"class": "android.widget.TextView"},
depth_to_parent=2
)
```

#### Actions (input)

```python
element = app.get_element({"resource-id": "search_field"})

# Send keys
element.send_keys("test query")

# Clear
element.clear()

# Set value (not supported in UiAutomator2)
element.set_value("new value")

# Submit (not supported in UiAutomator2)
element.submit()
```

#### Gestures

```python
element = app.get_element({"text": "Settings"})

# Tap
element.tap()
element.tap(duration=3000) # long tap

# Tap and move
element.tap_and_move(x=100, y=500)
element.tap_and_move(locator={"text": "Apps"})
element.tap_and_move(direction=0, distance=1000) # up

# Click
element.click()
element.click(duration=3000)
element.double_click()

# Drag
element.drag(end_x=500, end_y=1000, speed=2500)

# Fling
element.fling(speed=2500, direction="up")
element.fling_up(speed=2500)
element.fling_down()
element.fling_left()
element.fling_right()

# Scroll
recycler = app.get_element({"resource-id": "recycler_view"})
recycler.scroll(direction="down", percent=0.7, speed=2000)
recycler.scroll_down(percent=0.7)
recycler.scroll_up()
recycler.scroll_left()
recycler.scroll_right()

# Scroll to top/bottom
recycler.scroll_to_top(percent=0.7, speed=8000)
recycler.scroll_to_bottom()

# Scroll to element
target = recycler.scroll_to_element(
locator={"text": "About phone"},
max_swipes=30
)

# Swipe
element.swipe(direction="up", percent=0.75, speed=5000)
element.swipe_up()
element.swipe_down()
element.swipe_left()
element.swipe_right()

# Zoom
element.zoom(percent=0.75, speed=2500)
element.unzoom(percent=0.75, speed=2500)
```

#### Properties

```python
element = app.get_element({"text": "Network & internet"})

# Attributes
text = element.get_attribute("text")
attrs = element.get_attributes() # all attributes from XML

# DOM attribute
content_desc = element.get_dom_attribute("content-desc")

# Property (not supported in UiAutomator2)
prop = element.get_property("checked")

# States
is_displayed = element.is_displayed()
is_visible = element.is_visible()
is_enabled = element.is_enabled()
is_selected = element.is_selected()

# Check containment
has_child = element.is_contains({"class": "android.widget.TextView"})

# Properties via property
tag = element.tag_name
all_attrs = element.attributes
text = element.text
resource_id = element.resource_id
class_name = element.class_name
class_ = element.class_ # alternative
index = element.index
package = element.package
bounds = element.bounds

# Boolean properties
checked = element.checked
checkable = element.checkable
enabled = element.enabled
focusable = element.focusable
focused = element.focused
long_clickable = element.long_clickable
password = element.password
scrollable = element.scrollable
selected = element.selected
displayed = element.displayed

# Size and position
size = element.size # {"width": 800, "height": 100}
location = element.location # {"x": 100, "y": 500}
rect = element.rect # {"x": 100, "y": 500, "width": 800, "height": 100}
location_in_view = element.location_in_view

# Shadow root (not supported in UiAutomator2)
shadow_root = element.shadow_root

# CSS (not supported in UiAutomator2)
css_value = element.value_of_css_property("color")

# ARIA (not supported in UiAutomator2)
aria_role = element.aria_role
accessible_name = element.accessible_name
```

#### Coordinates

```python
element = app.get_element({"text": "Settings"})

# Coordinates (x, y, width, height)
x, y, width, height = element.get_coordinates()

# Element center
center_x, center_y = element.get_center()

# Location in view
loc = element.location_in_view # {"x": 100, "y": 500}

# Location once scrolled (not supported in UiAutomator2)
loc = element.location_once_scrolled_into_view
```

#### Screenshots

```python
element = app.get_element({"text": "Settings"})

# Base64
screenshot_b64 = element.screenshot_as_base64

# PNG bytes
screenshot_png = element.screenshot_as_png

# Save to file
success = element.save_screenshot("/tmp/element.png")
```

#### Waiting

```python
element = app.get_element({"text": "Network & internet"})

# Wait until present
element.wait(timeout=10, poll_frequency=0.5)
# or return bool
success = element.wait(timeout=10, return_bool=True)

# Wait until visible
element.wait_visible(timeout=10)

# Wait until clickable
element.wait_clickable(timeout=10)

# Wait until NOT present
element.wait_for_not(timeout=10)

# Wait until NOT visible
element.wait_for_not_visible(timeout=10)

# Wait until NOT clickable
element.wait_for_not_clickable(timeout=10)
```

#### Should (DSL assertions)

```python
element = app.get_element({"text": "Settings"})

# Fluent assertions
element.should.be_visible()
element.should.be_enabled()
element.should.have_text("Settings")
element.should.have_attribute("text", "Settings")
element.should.be_displayed()
element.should.be_clickable()

# Negative checks
element.should.not_be_visible()
element.should.not_have_text("Other")
```

#### Native WebElement

```python
element = app.get_element({"text": "Settings"})

# Get native WebElement
native = element.get_native()
native.click()
```

___

### PageBase

Abstract base class for Page Object pattern with automatic navigation.

#### Creating Page Object

```python
from shadowstep import PageBaseShadowstep, Element

class PageSettings(PageBaseShadowstep):
"""Settings page representation."""

# Required: define relationships with other pages
@property
def edges(self):
return {
"PageNetworkInternet": self.to_network_internet,
"PageAboutPhone": self.to_about_phone,
}

# Page name
@property
def name(self) -> str:
return "Settings"

# Title element for page verification
@property
def title(self) -> Element:
return self.shadowstep.get_element({
"text": "Settings",
"resource-id": "com.android.settings:id/homepage_title"
})

# Recycler (scrollable container)
@property
def recycler(self) -> Element:
return self.shadowstep.get_element({
"resource-id": "com.android.settings:id/settings_homepage_container"
})

# Page elements
@property
def network_internet(self) -> Element:
return self.recycler.scroll_to_element({
"text": "Network & internet",
"resource-id": "android:id/title"
})

@property
def network_internet_summary(self) -> Element:
return self.network_internet.get_sibling({
"resource-id": "android:id/summary"
})

@property
def about_phone(self) -> Element:
return self.recycler.scroll_to_element({
"text": "About phone"
})

# Navigation methods
def to_network_internet(self):
"""Navigate to Network & Internet page."""
self.network_internet.tap()
return self.shadowstep.get_page("PageNetworkInternet")

def to_about_phone(self):
"""Navigate to About Phone page."""
self.about_phone.tap()
return self.shadowstep.get_page("PageAboutPhone")

# Required: check current page
def is_current_page(self) -> bool:
"""Check if Settings page is currently displayed."""
try:
return self.title.is_visible()
except Exception:
return False
```

#### Using Page Objects

```python
# Get instance (singleton)
settings = app.get_page("PageSettings")

# Check current page
assert settings.is_current_page()

# Interact with elements
print(settings.network_internet.text)
print(settings.network_internet_summary.text)

# Navigate
network_page = settings.to_network_internet()
assert network_page.is_current_page()

# Clear singleton
PageSettings.clear_instance()
```

#### Automatic Navigation (Navigator)

Navigator automatically finds paths between pages through the graph.

```python
from shadowstep.navigator import PageNavigator

# Navigator is created automatically in Shadowstep
# app.navigator = PageNavigator(app)

# List registered pages
app.navigator.list_registered_pages()

# Navigate with automatic pathfinding
current_page = app.get_page("PageSettings")
target_page = app.get_page("PageAboutPhone")

# Navigator will find shortest path through graph
success = app.navigator.navigate(
from_page=current_page,
to_page=target_page,
timeout=10
)
```

___

## Additional Modules

### Navigator

Graph-based navigation system between pages.

#### How it Works

1. Each page defines `edges` — relationships with other pages
2. Navigator builds a graph from all pages
3. During navigation, uses shortest path algorithm (NetworkX or BFS fallback)

```python
from shadowstep.navigator import PageNavigator

navigator = PageNavigator(app)

# Auto-discover pages in sys.path
navigator.auto_discover_pages()

# Add page manually
page = PageSettings()
navigator.add_page(page, edges=page.edges)

# Find path
path = navigator.find_path(
start=PageSettings(),
target=PageAboutPhone()
)
# ["PageSettings", "PageNetworkInternet", "PageAboutPhone"]

# Navigate through path
navigator.perform_navigation(path, timeout=10)

# Direct navigation
success = navigator.navigate(
from_page=PageSettings(),
to_page=PageAboutPhone(),
timeout=10
)
```

___

### Locator System

Flexible locator system supporting three formats: dict, xpath, UiSelector.

#### Locator Types

##### 1. Dictionary (Shadowstep Dict)

```python
# Simple locator
locator = {"text": "Settings"}

# Compound locator
locator = {
"text": "Network & internet",
"resource-id": "android:id/title",
"class": "android.widget.TextView"
}

# With contains
locator = {"textContains": "Network"}

# With starts-with
locator = {"textStartsWith": "Net"}

# With matches (regex)
locator = {"textMatches": "Net.*"}

# All UiSelector attributes supported
locator = {
"text": "Settings",
"clickable": True,
"index": 0,
"instance": 0
}
```

##### 2. XPath

```python
# Simple xpath
locator = ("xpath", '//android.widget.TextView[@text="Settings"]')

# With functions
locator = ("xpath", '//android.widget.TextView[contains(@text, "Network")]')
locator = ("xpath", '//android.widget.TextView[starts-with(@text, "Net")]')

# With attributes
locator = ("xpath", '//*[@resource-id="android:id/title" and @text="Settings"]')

# With indices
locator = ("xpath", '(//android.widget.TextView)[1]')

# Parent/child
locator = ("xpath", '//android.widget.ScrollView//android.widget.TextView')
```

##### 3. UiSelector

```python
from shadowstep.locator import UiSelector

# Simple selector
locator = UiSelector().text("Settings")

# Chaining
locator = (UiSelector()
.text("Network & internet")
.resourceId("android:id/title")
.className("android.widget.TextView"))

# Contains
locator = UiSelector().textContains("Network")

# Starts with
locator = UiSelector().textStartsWith("Net")

# Matches (regex)
locator = UiSelector().textMatches("Net.*")

# Boolean properties
locator = UiSelector().clickable(True).enabled(True)

# Index and instance
locator = UiSelector().className("android.widget.TextView").index(0)
locator = UiSelector().className("android.widget.TextView").instance(2)

# Description
locator = UiSelector().description("Phone")
locator = UiSelector().descriptionContains("Pho")

# Package
locator = UiSelector().packageName("com.android.settings")

# Child selector
parent = UiSelector().className("android.widget.ScrollView")
child = UiSelector().text("Settings")
locator = parent.childSelector(child)

# From parent
locator = UiSelector().text("Settings").fromParent(UiSelector().className("android.widget.LinearLayout"))
```

#### Locator Conversion

```python
from shadowstep.locator import LocatorConverter

converter = LocatorConverter()

# Dict -> XPath
dict_loc = {"text": "Settings", "class": "android.widget.TextView"}
xpath = converter.dict_to_xpath(dict_loc)
# '//*[@text="Settings" and @class="android.widget.TextView"]'

# Dict -> UiSelector
ui_selector = converter.dict_to_ui_selector(dict_loc)
# 'new UiSelector().text("Settings").className("android.widget.TextView")'

# UiSelector -> Dict
ui_loc = UiSelector().text("Settings").clickable(True)
dict_loc = converter.ui_selector_to_dict(str(ui_loc))
# {"text": "Settings", "clickable": True}

# UiSelector -> XPath
xpath = converter.ui_selector_to_xpath(str(ui_loc))

# XPath -> Dict
xpath = '//android.widget.TextView[@text="Settings"]'
dict_loc = converter.xpath_to_dict(xpath)
# {"text": "Settings", "class": "android.widget.TextView"}

# XPath -> UiSelector
ui_selector = converter.xpath_to_ui_selector(xpath)
```

___

### Terminal

Two options for command execution: via Appium (Terminal) and via SSH (Transport).

#### Terminal (via Appium)

```python
# Terminal is created automatically on connect()
terminal = app.terminal

# Shell commands
result = terminal.adb_shell(command="dumpsys", args="window windows")
result = terminal.adb_shell(command="pm", args="list packages")

# Application management
terminal.start_activity(package="com.android.settings", activity=".Settings")
terminal.close_app(package="com.android.settings")
terminal.reboot_app(package="com.android.settings", activity=".Settings")

package = terminal.get_current_app_package()

# Check installation
is_installed = terminal.is_app_installed(package="com.android.settings")
terminal.uninstall_app(package="com.android.settings")

# Buttons
terminal.press_home()
terminal.press_back()
terminal.press_menu()

# Input
terminal.input_keycode(keycode="KEYCODE_ENTER")
terminal.input_keycode_num_(num=5)
terminal.input_text(text="hello")

# Gestures
terminal.tap(x=500, y=1000)
terminal.swipe(start_x=500, start_y=1000, end_x=500, end_y=500, duration=300)
terminal.swipe_right_to_left(duration=300)
terminal.swipe_left_to_right()
terminal.swipe_top_to_bottom()
terminal.swipe_bottom_to_top()

# VPN
is_connected = terminal.check_vpn(ip_address="192.168.1.1")

# Processes
pid = terminal.know_pid(name="logcat")
exists = terminal.is_process_exist(name="logcat")
terminal.kill_by_pid(pid=1234)
terminal.kill_by_name(name="logcat")
terminal.kill_all(name="logcat")
terminal.run_background_process(command="logcat", args="-v time", process="logcat")

# Files
terminal.delete_file_from_internal_storage(path="/sdcard", filename="test.txt")
terminal.delete_files_from_internal_storage(path="/sdcard/Download")

# Video
terminal.record_video(time_limit=180000)
video_bytes = terminal.stop_video()

# System information
terminal.reboot()
width, height = terminal.get_screen_resolution()
properties = terminal.get_prop()
hardware = terminal.get_prop_hardware()
model = terminal.get_prop_model()
serial = terminal.get_prop_serial()
build = terminal.get_prop_build()
device = terminal.get_prop_device()

# Packages
packages = terminal.get_packages()

# WiFi IP
wifi_ip = terminal.get_wifi_ip()

# Paste text (via clipboard)
terminal.past_text(text="Hello World", tries=3)
```

#### Transport (via SSH)

**IMPORTANT:** SSH was removed from Terminal and is now only available via Transport.

```python
# Transport is created when connect() is called with SSH credentials
app.connect(
capabilities={...},
server_ip="192.168.1.100",
ssh_user="user",
ssh_password="password"
)

# Access SSH client (paramiko)
ssh_client = app.transport.ssh

# Execute command
stdin, stdout, stderr = ssh_client.exec_command("adb devices")
output = stdout.read().decode()

# Access SCP client
scp_client = app.transport.scp

# Upload file to server
scp_client.put("local_file.txt", remote_path="/tmp/remote_file.txt")

# Download file from server
scp_client.get("/tmp/remote_file.txt", local_path="downloaded_file.txt")

# Recursive folder upload
scp_client.put("local_folder", remote_path="/tmp/remote_folder", recursive=True)
```

#### ADB (local)

```python
# ADB is created automatically on connect()
adb = app.adb

# Get device list
devices = adb.get_devices() # ["emulator-5554", "192.168.1.100:5555"]

# Device model
model = adb.get_device_model(udid="emulator-5554")

# Push/Pull files
adb.push(source="local.txt", destination="/sdcard/file.txt", udid="emulator-5554")
adb.pull(source="/sdcard/file.txt", destination="local.txt", udid="emulator-5554")

# Install APK
adb.install_app(source="app.apk", udid="emulator-5554")
adb.is_app_installed(package="com.example.app")
adb.uninstall_app(package="com.example.app")

# Application management
adb.start_activity(package="com.android.settings", activity=".Settings")
adb.get_current_activity()
adb.get_current_package()
adb.close_app(package="com.android.settings")
adb.reboot_app(package="com.android.settings", activity=".Settings")

# Buttons
adb.press_home()
adb.press_back()
adb.press_menu()

# Input
adb.input_keycode(keycode="KEYCODE_ENTER")
adb.input_keycode_num_(num=5)
adb.input_text(text="hello")

# Gestures
adb.tap(x=500, y=1000)
adb.swipe(start_x=500, start_y=1000, end_x=500, end_y=500, duration=300)

# VPN
adb.check_vpn(ip_address="192.168.1.1")

# Processes
adb.stop_logcat()
adb.is_process_exist(name="logcat")
adb.run_background_process(command="logcat -v time &", process="logcat")
pid = adb.know_pid(name="logcat")
adb.kill_by_pid(pid=1234)
adb.kill_by_name(name="logcat")
adb.kill_all(name="logcat")

# ADB server
adb.reload_adb()

# Files
adb.delete_files_from_internal_storage(path="/sdcard/Download")

# Video
process = adb.record_video(path="/sdcard/Movies", filename="recording.mp4")
# ... wait ...
adb.stop_video()
adb.pull_video(source="/sdcard/Movies", destination="./videos", delete=True)

# System information
adb.reboot()
width, height = adb.get_screen_resolution()
packages = adb.get_packages_list()

# Execute arbitrary command
output = adb.execute(command="shell getprop ro.build.version.release")
```

___

### Logcat

Android log capture via WebSocket with filtering and automatic reconnection.

```python
# Start log capture
app.start_logcat(filename="logcat.log")

# With tag filtering
app._logcat.filters = ["ActivityManager", "System.out"]
app.start_logcat(filename="filtered_logcat.log")

# Stop capture
app.stop_logcat()

# Context manager
with app._logcat:
app._logcat.start(filename="logcat.log")
# ... run tests ...
# automatically stops on exit

# Configuration
logcat = app._logcat
logcat.filters = ["MyApp", "Firebase"] # filter by tags
# logcat works in background thread with auto-reconnection
```

**Features:**

- Works via WebSocket to Appium server
- Automatic reconnection on connection drops
- Buffered file writing (buffering=1)
- Tag filtering with regex
- Graceful shutdown with proper file closing

___

### Image Recognition

Find elements by images using OpenCV.

```python
# Get ShadowstepImage
image_path = "tests/_test_data/connected_devices.png"
image = app.get_image(
image=image_path,
threshold=0.5, # match accuracy [0-1]
timeout=5.0 # search timeout
)

# Can pass bytes, ndarray, PIL.Image or file path
from PIL import Image

pil_image = Image.open("icon.png")
image = app.get_image(image=pil_image, threshold=0.8)

# Tap on image
image.tap()

# Wait for appearance
image.wait(timeout=10)

# Check visibility
if image.is_visible():
print("Image found on screen")

# Coordinates
x, y = image.get_center()
coords = image.get_coordinates()

# Multiple search
images = app.get_images(image=image_path, threshold=0.7)
for img in images:
img.tap()

# Screenshot + matching
screenshot = app.get_screenshot() # bytes
# image.match(screenshot) - internal method
```

___

### Page Object Generator

Automatic generation of Page Object classes from UI XML dump.

```python
from shadowstep.page_object import (
PageObjectGenerator,
PageObjectParser,
UiElementNode
)

# 1. Get XML page source
xml_source = app.driver.page_source

# 2. Parse XML into element tree
parser = PageObjectParser()
ui_tree: UiElementNode = parser.parse(xml_source)

# 3. Generate Page Object
generator = PageObjectGenerator()
output_path, class_name = generator.generate(
ui_element_tree=ui_tree,
output_dir="./generated_pages",
filename_prefix="page_"
)

print(f"Generated: {output_path}")
print(f"Class: {class_name}")

# Result: page_settings.py
# class PageSettings(PageBaseShadowstep):
# @property
# def title(self) -> Element: ...
# @property
# def network_internet(self) -> Element: ...
# ...
```

**Capabilities:**

- Auto-detection of title, recycler
- Recognition of anchor-switcher pairs (for switch elements)
- Recognition of anchor-summary pairs
- Filtering structural containers
- Generation of navigation methods
- Uses Jinja2 templates
- Supports translator (optional)

**Page Object Merger:**

```python
from shadowstep.page_object import PageObjectMerger

# Merge multiple dumps of same screen
merger = PageObjectMerger()

# Add dumps
merger.add_dump(xml_source_1)
merger.add_dump(xml_source_2)
merger.add_dump(xml_source_3)

# Get merged tree
merged_tree = merger.merge()

# Generate from merged tree
generator.generate(
ui_element_tree=merged_tree,
output_dir="./pages"
)
```

**Page Object Test Generator:**

```python
from shadowstep.page_object import PageObjectTestGenerator

# Generate tests for Page Object
test_generator = PageObjectTestGenerator()
test_path = test_generator.generate(
page_class_name="PageSettings",
output_dir="./tests",
page_module="pages.page_settings"
)
```

___

## Usage Examples

### Basic Testing

```python
from shadowstep import Shadowstep

def test_settings_navigation():
app = Shadowstep()
app.connect(
capabilities={
"platformName": "Android",
"appium:automationName": "UiAutomator2",
"appium:deviceName": "emulator-5554",
"appium:appPackage": "com.android.settings",
"appium:appActivity": ".Settings",
}
)

# Find element
network = app.get_element({
"text": "Network & internet",
"resource-id": "android:id/title"
})

# Check visibility
assert network.is_visible()

# Interact
network.tap()

# Verify navigation
title = app.get_element({"text": "Network & internet"})
assert title.wait_visible(timeout=5)

app.disconnect()
```

### Working with Forms

```python
def test_search_form():
app = Shadowstep()
# ... connect ...

# Find search field
search_field = app.get_element({
"resource-id": "com.android.quicksearchbox:id/search_widget_text"
})
search_field.tap()

# Wait for input to appear
search_input = app.get_element({
"resource-id": "com.android.quicksearchbox:id/search_src_text"
})
search_input.wait_visible(timeout=3)

# Enter text
search_input.send_keys("test query")

# Check value
assert "test query" in search_input.text

# Clear
search_input.clear()
assert search_input.text == ""
```

### Scrolling and Search

```python
def test_scroll_to_element():
app = Shadowstep()
# ... connect to Settings ...

# Get scrollable container
recycler = app.get_element({
"resource-id": "com.android.settings:id/settings_homepage_container"
})

# Scroll to element
about_phone = recycler.scroll_to_element(
locator={"text": "About phone"},
max_swipes=30
)

# Check element found
assert about_phone.is_visible()

# Interact
about_phone.tap()
```

### DOM Navigation Example

```python
def test_dom_navigation():
app = Shadowstep()
# ... connect to Settings ...

# Find anchor element
network = app.get_element({
"text": "Network & internet",
"resource-id": "android:id/title"
})

# Find sibling (summary)
summary = network.get_sibling({
"resource-id": "android:id/summary"
})
print(f"Summary: {summary.text}")

# Get parent
parent = network.get_parent()
print(f"Parent class: {parent.class_name}")

# Find cousin (same level, different parent)
cousin = network.get_cousin(
cousin_locator={"resource-id": "android:id/summary"},
depth_to_parent=1
)
```

### Multiple Elements

```python
def test_multiple_elements():
app = Shadowstep()
# ... connect to Settings ...

# Find all TextView
textviews = app.get_elements({
"class": "android.widget.TextView"
})

# Process each
for tv in textviews:
text = tv.text
if text and "Settings" not in text:
print(f"Found: {text}")
```

### Gestures and Animations

```python
def test_gestures():
app = Shadowstep()
# ... connect ...

# Get element
icon = app.get_element({"content-desc": "Gallery"})

# Remember position
x1, y1 = icon.get_center()

# Drag
icon.drag(end_x=x1 + 200, end_y=y1, speed=2500)

# Check new position
x2, y2 = icon.get_center()
assert x2 > x1

# Drag back
icon.drag(end_x=x1, end_y=y1, speed=2500)

# Fling gesture
recycler = app.get_element({"resource-id": "recycler_view"})
recycler.fling_up(speed=5000)
```

### Page Object with Navigation

```python
from shadowstep import PageBaseShadowstep, Element

class PageSettings(PageBaseShadowstep):
@property
def edges(self):
return {
"PageNetwork": self.to_network,
"PageApps": self.to_apps,
}

@property
def recycler(self) -> Element:
return self.shadowstep.get_element({
"resource-id": "com.android.settings:id/settings_homepage_container"
})

@property
def network(self) -> Element:
return self.recycler.scroll_to_element({"text": "Network & internet"})

@property
def apps(self) -> Element:
return self.recycler.scroll_to_element({"text": "Apps"})

def to_network(self):
self.network.tap()
return self.shadowstep.get_page("PageNetwork")

def to_apps(self):
self.apps.tap()
return self.shadowstep.get_page("PageApps")

def is_current_page(self) -> bool:
title = self.shadowstep.get_element({"text": "Settings"})
return title.is_visible()

# Test
def test_page_navigation():
app = Shadowstep()
# ... connect ...

settings = app.get_page("PageSettings")
assert settings.is_current_page()

# Automatic navigation via Navigator
network = settings.to_network()
assert network.is_current_page()
```

### Screenshots and Logs

```python
def test_with_logs_and_screenshots():
app = Shadowstep()
# ... connect ...

# Start logcat
app.start_logcat(filename="test_logs.log")

try:
# Perform actions
element = app.get_element({"text": "Settings"})
element.tap()

# Take screenshot
app.save_screenshot(path="./screenshots", filename="settings.png")

# Element screenshot
element.save_screenshot("./screenshots/element.png")

finally:
# Stop logcat
app.stop_logcat()
app.disconnect()
```

### Working with Images

```python
def test_image_recognition():
app = Shadowstep()
# ... connect ...

# Search by image
icon = app.get_image(
image="icons/settings_icon.png",
threshold=0.8,
timeout=10
)

# Check visibility
if icon.is_visible():
# Tap on image
icon.tap()

# Coordinates
x, y = icon.get_center()
print(f"Icon center: {x}, {y}")
```

### Working with ADB and SSH

```python
def test_adb_commands():
app = Shadowstep()
# ... connect ...

# Via Terminal (Appium)
app.terminal.start_activity(
package="com.android.settings",
activity=".Settings"
)

# Check current application
package = app.terminal.get_current_app_package()
assert "settings" in package.lower()

# Via local ADB
devices = app.adb.get_devices()
print(f"Connected devices: {devices}")

model = app.adb.get_device_model(udid="emulator-5554")
print(f"Device model: {model}")

def test_ssh_commands():
app = Shadowstep()
app.connect(
capabilities={...},
server_ip="192.168.1.100",
ssh_user="user",
ssh_password="password"
)

# SSH commands via transport
stdin, stdout, stderr = app.transport.ssh.exec_command("adb devices")
output = stdout.read().decode()
print(output)

# SCP files
app.transport.scp.put("local.txt", remote_path="/tmp/remote.txt")
app.transport.scp.get("/tmp/remote.txt", local_path="downloaded.txt")
```

___

## Quality Tools

The project uses modern tools to ensure code quality:

### Linters and Formatters

```bash
# Ruff - fast linter and formatter
uv run ruff check .
uv run ruff format .

# Pyright - strict typing
uv run pyright
```

### Testing

```bash
# Run all tests
uv run pytest

# Only unit tests
uv run pytest tests/test_unit

# Only integration tests
uv run pytest tests/test_integro

# With coverage
uv run pytest --cov=shadowstep --cov-report=html

# With rerun failed
uv run pytest --reruns 3 --reruns-delay 1
```

### Pre-commit Hooks

```bash
# Install
uv run pre-commit install

# Manual run
uv run pre-commit run --all-files
```

### Configuration

Tool settings are in `pyproject.toml`:

- **Ruff:** `select = ["ALL"]` with docstring style conflict ignoring
- **Pyright:** `typeCheckingMode = "strict"` for maximum type safety
- **Pytest:** logging, short traceback, setup show

___

## Additional Information

### Supported Python Versions

- Python 3.9+
- Python 3.10
- Python 3.11
- Python 3.12
- Python 3.13

### Links

- [GitHub Repository](https://github.com/molokov-klim/Appium-Python-Client-Shadowstep)
- [Appium Documentation](https://appium.io/docs/en/latest/)
- [UiAutomator2 Driver](https://github.com/appium/appium-uiautomator2-driver)

### License

MIT License

___

## Contributing

The project follows:

- **Clean Architecture** — separation of concerns
- **Clean Code** — readability and maintainability
- **Best Practices** — design patterns
- **Type Safety** — strict typing (Pyright strict mode)
- **PEP 8** — Python coding style

When developing, use:

- Strict typing with `typing`
- Docstrings in English
- Comments in English
- Type hints for all functions and methods
- Pyright strict mode
- Ruff for linting

___

**Author:** Molokov Klim
**Email:** [ultrakawaii9654449192@gmail.com](mailto:ultrakawaii9654449192@gmail.com)