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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ permissions:

jobs:
release:
# Releases are cut from the upstream repository only. Without this guard the
# workflow also runs on forks, where semantic-release rewrites the version in
# pyproject.toml, commits to the default branch and attempts a PyPI upload.
if: github.repository == 'appium/python-client'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
Expand Down
89 changes: 81 additions & 8 deletions appium/webdriver/extensions/action_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING, Any, cast, overload

from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.actions import interaction
Expand Down Expand Up @@ -132,35 +132,108 @@ def tap(self, positions: list[tuple[int, int]], duration: int | None = None) ->
actions.perform()
return self

def swipe(self, start_x: int, start_y: int, end_x: int, end_y: int, duration: int = 0) -> Self:
@overload
def swipe(
self,
start_x: tuple[int, int] | list[int],
start_y: tuple[int, int] | list[int],
duration: int = 0,
) -> Self: ...

@overload
def swipe(
self,
start_x: int,
start_y: int,
end_x: int,
end_y: int,
duration: int = 0,
) -> Self: ...

def swipe(
self,
start_x: tuple[int, int] | list[int] | int | None = None,
start_y: tuple[int, int] | list[int] | int | None = None,
*args: Any,
duration: int = 0,
**kwargs: Any,
) -> Self:
"""Swipe from one point to another point, for an optional duration.

Args:
start_x: x-coordinate at which to start
start_y: y-coordinate at which to start
end_x: x-coordinate at which to stop
end_y: y-coordinate at which to stop
start_x: x-coordinate at which to start, or (x, y) tuple/list for start point
start_y: y-coordinate at which to start, or (x, y) tuple/list for end point
*args: Positionally passed end_x, end_y, and optional duration
duration: defines the swipe speed as time taken to swipe from point a to point b, in ms.
**kwargs: Keyword arguments such as start_point, end_point, end_x, end_y

Usage:
driver.swipe((100, 100), (100, 400))
driver.swipe(100, 100, 100, 400)
driver.swipe(start_x=100, start_y=100, end_x=100, end_y=400, duration=500)

Returns:
Union['WebDriver', 'ActionHelpers']: Self instance
"""
sx, sy = self._extract_start_point(start_x, start_y, **kwargs)
ex, ey, duration = self._extract_end_point_and_duration(start_x, start_y, args, duration, **kwargs)

touch_input = PointerInput(interaction.POINTER_TOUCH, 'touch')

actions = ActionChains(cast('WebDriver', self))
actions.w3c_actions = ActionBuilder(self, mouse=touch_input)
actions.w3c_actions.pointer_action.move_to_location(start_x, start_y)
actions.w3c_actions.pointer_action.move_to_location(sx, sy)
actions.w3c_actions.pointer_action.pointer_down()
if duration > 0:
actions.w3c_actions = ActionBuilder(self, mouse=touch_input, duration=duration)
actions.w3c_actions.pointer_action.move_to_location(end_x, end_y)
actions.w3c_actions.pointer_action.move_to_location(ex, ey)
actions.w3c_actions.pointer_action.release()
actions.perform()
return self

def _extract_start_point(
self,
start_x: tuple[int, int] | list[int] | int | None,
start_y: tuple[int, int] | list[int] | int | None,
**kwargs: Any,
) -> tuple[int, int]:
if 'start_point' in kwargs:
sp = kwargs['start_point']
return sp[0], sp[1]
if isinstance(start_x, (tuple, list)):
return start_x[0], start_x[1]
if start_x is not None:
if start_y is None or isinstance(start_y, (tuple, list)):
raise ValueError('start_y must be an integer when start_x is an integer.')
return int(start_x), int(start_y)
raise ValueError('Start coordinates or start point must be provided.')

def _extract_end_point_and_duration(
self,
start_x: tuple[int, int] | list[int] | int | None,
start_y: tuple[int, int] | list[int] | int | None,
args: tuple[Any, ...],
duration: int,
**kwargs: Any,
) -> tuple[int, int, int]:
if 'end_point' in kwargs:
ep = kwargs['end_point']
return ep[0], ep[1], duration
if isinstance(start_y, (tuple, list)) and isinstance(start_x, (tuple, list)):
d = int(args[0]) if args and duration == 0 else duration
return start_y[0], start_y[1], d
if len(args) > 1:
end_x_val, end_y_val, *rest = args
d = int(rest[0]) if rest and duration == 0 else duration
return int(end_x_val), int(end_y_val), d
if 'end_x' in kwargs and 'end_y' in kwargs:
return int(kwargs['end_x']), int(kwargs['end_y']), duration
if args and isinstance(args[0], (tuple, list)):
ep, *rest = args
d = int(rest[0]) if rest and duration == 0 else duration
return ep[0], ep[1], d
raise ValueError('End coordinates or end point must be provided.')

def flick(self, start_x: int, start_y: int, end_x: int, end_y: int) -> Self:
"""Flick from one point to another point.

Expand Down
122 changes: 122 additions & 0 deletions test/unit/webdriver/action_helpers_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#!/usr/bin/env python

# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import json

import httpretty
import pytest

from test.unit.helper.test_helper import android_w3c_driver, appium_command, get_httpretty_request_body


class TestActionHelpers:
@httpretty.activate
def test_swipe_with_tuples(self):
driver = android_w3c_driver()
httpretty.register_uri(
httpretty.POST,
appium_command('/session/1234567890/actions'),
body=json.dumps({'value': None}),
)

driver.swipe((100, 200), (300, 400))

req_body = get_httpretty_request_body(httpretty.last_request())
actions = req_body['actions'][0]['actions']
assert actions[0]['type'] == 'pointerMove'
assert actions[0]['x'] == 100
assert actions[0]['y'] == 200
assert actions[1]['type'] == 'pointerDown'
assert actions[2]['type'] == 'pointerMove'
assert actions[2]['x'] == 300
assert actions[2]['y'] == 400
assert actions[3]['type'] == 'pointerUp'

@httpretty.activate
def test_swipe_with_tuples_and_duration(self):
driver = android_w3c_driver()
httpretty.register_uri(
httpretty.POST,
appium_command('/session/1234567890/actions'),
body=json.dumps({'value': None}),
)

driver.swipe((100, 200), (300, 400), 500)

req_body = get_httpretty_request_body(httpretty.last_request())
actions = req_body['actions'][0]['actions']
assert actions[0]['type'] == 'pointerMove'
assert actions[0]['x'] == 100
assert actions[0]['y'] == 200
assert actions[1]['type'] == 'pointerDown'
assert actions[2]['type'] == 'pointerMove'
assert actions[2]['x'] == 300
assert actions[2]['y'] == 400
assert actions[2]['duration'] == 500
assert actions[3]['type'] == 'pointerUp'

@httpretty.activate
def test_swipe_with_integers(self):
driver = android_w3c_driver()
httpretty.register_uri(
httpretty.POST,
appium_command('/session/1234567890/actions'),
body=json.dumps({'value': None}),
)

driver.swipe(100, 200, 300, 400)

req_body = get_httpretty_request_body(httpretty.last_request())
actions = req_body['actions'][0]['actions']
assert actions[0]['type'] == 'pointerMove'
assert actions[0]['x'] == 100
assert actions[0]['y'] == 200
assert actions[1]['type'] == 'pointerDown'
assert actions[2]['type'] == 'pointerMove'
assert actions[2]['x'] == 300
assert actions[2]['y'] == 400
assert actions[3]['type'] == 'pointerUp'

@httpretty.activate
def test_swipe_with_integers_and_duration(self):
driver = android_w3c_driver()
httpretty.register_uri(
httpretty.POST,
appium_command('/session/1234567890/actions'),
body=json.dumps({'value': None}),
)

driver.swipe(100, 200, 300, 400, duration=800)

req_body = get_httpretty_request_body(httpretty.last_request())
actions = req_body['actions'][0]['actions']
assert actions[0]['type'] == 'pointerMove'
assert actions[0]['x'] == 100
assert actions[0]['y'] == 200
assert actions[1]['type'] == 'pointerDown'
assert actions[2]['type'] == 'pointerMove'
assert actions[2]['x'] == 300
assert actions[2]['y'] == 400
assert actions[2]['duration'] == 800
assert actions[3]['type'] == 'pointerUp'

@httpretty.activate
def test_swipe_invalid_args(self):
driver = android_w3c_driver()

with pytest.raises(ValueError):
driver.swipe(100, 200)

with pytest.raises(ValueError):
driver.swipe((100, 200), 300)