Skip to content

Commit

Permalink
handle allocation wait timeout properly and run tests in py3.11 (#31)
Browse files Browse the repository at this point in the history
  • Loading branch information
jupe authored Mar 25, 2024
1 parent 43de787 commit 1df94f6
Show file tree
Hide file tree
Showing 7 changed files with 89 additions and 50 deletions.
9 changes: 5 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,22 @@ jobs:
run-unittests:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest, macos-latest, windows-latest ]
python-version: [ '3.7', '3.8', '3.9', '3.10' ]
python-version: [ '3.7', '3.8', '3.9', '3.10', '3.11' ]
name: ${{ matrix.os }}-Python-${{ matrix.python-version }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Setup Node.js
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.10'
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: '16'

Expand Down
29 changes: 20 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,30 @@
[![PyPI version](https://badge.fury.io/py/stf-appium-client.svg)](https://badge.fury.io/py/stf-appium-client)

Library provides basic functionality for test automation which allows allocating
phone from [OpenSTF](https://github.com/DeviceFarmer/stf) server using [python stf-client](https://pypi.org/project/stf-client/), initialise adb connection to it and
phone from [STF](https://github.com/DeviceFarmer/stf) server using [python stf-client](https://pypi.org/project/stf-client/), initialise adb connection to it and
start [appium][https://github.com/appium/python-client] server for it.

Basic idea is to run tests against remote openstf device farm with minimum
requirements.


### Flow
```mermaid
sequenceDiagram
participant C as User
participant A as stf-appium-client
participant B as STF(device)
C->>A: allocation_context(requirements, wait_timeout, timeout, shuffle)
A->>B: Find suitable device
A->>B: allocate device
A->>B: remoteConnect
A->>B: ADB Connection
A->>A: Start AppiumServer(ADB)
A->>A: Start AppiumClient(AppiumServer)
A->>C: AppiumClient(AppiumServer(ADB))
C->>A: Run Appium Tests
```
stf-appium-client --find/allocate--> OpenSTF(device)
stf-appium-client --remoteConnect--> OpenSTF(device)
stf-appium-client(ADB) <----------------> OpenSTF(ADB)
stf-appium-client(AppiumServer(ADB))
stf-appium-client(AppiumClient(AppiumServer))
..appium tests..
```


### Getting Started

Expand All @@ -36,6 +44,8 @@ These instructions will get you a copy of the project up and running on your loc
* remember to install appium drivers, e.g. `appium driver install uiautomator2`
* appium 1
* note that appium server and client need to be compatible with each other!
* see compatibility matrix from [python-client readme](https://github.com/appium/python-client?tab=readme-ov-file#compatibility-matrix)

### Installing

* `pip install stf-appium-client`
Expand All @@ -56,6 +66,7 @@ CI runs tests against following environments:
| 3.8 ||||
| 3.9 ||||
| 3.10 ||||
| 3.11 ||||

### Deployment

Expand Down Expand Up @@ -140,6 +151,6 @@ optional arguments:

```
License
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details
8 changes: 5 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
:copyright: (c) 2021 by Jussi Vatjus-Anttila
:copyright: (c) 2024 by Jussi Vatjus-Anttila
:license: MIT, see LICENSE for more details.
"""
from setuptools import setup, find_packages
Expand All @@ -21,6 +21,7 @@
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: 3.11
Topic :: Software Development :: Testing
""".strip().splitlines()

Expand Down Expand Up @@ -53,9 +54,10 @@
extras_require={ # Optional
'dev': ['wheel', 'mock', 'pylint', 'pytest', 'pytest-cov', 'pytest-mock', 'pyinstaller', 'coveralls']
},
keywords="OpenSTF appium robot-framework lockable resource android",
keywords="DeviceFarmer STF appium pytest robot-framework lockable resource android",
python_requires=">=3.7",
project_urls={ # Optionaly
project_urls={
'Homepage': 'https://github.com/OpenTMI/stf-appium-python-client',
'Bug Reports': 'https://github.com/OpenTMI/stf-appium-python-client/issues',
'Source': 'https://github.com/OpenTMI/stf-appium-python-client',
}
Expand Down
2 changes: 1 addition & 1 deletion stf_appium_client/AppiumServer.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def get_api_path(self) -> str:
if version.startswith("1."):
return f'http://127.0.0.1:{self.port}/wd/hub'
else:
return f'http://127.0.0.1:{self.port}'
return f'http://127.0.0.1:{self.port}' # Appium >= 2.0

def start(self):
assert not self.service.is_running, 'Appium already running'
Expand Down
65 changes: 36 additions & 29 deletions stf_appium_client/StfClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@
from stf_client.api.user_api import UserApi
from stf_client.api.devices_api import DevicesApi


class StfClient(Logger):
DEFAULT_ALLOCATION_TIMEOUT_SECONDS = 900

def __init__(self, host: str):
"""
OpenSTF Client consructor
STF Client constructor
:param host: Server address of OpenSTF
"""
super().__init__()
Expand Down Expand Up @@ -185,11 +186,15 @@ def find_and_allocate(self, requirements: dict,
timeout_seconds: int = DEFAULT_ALLOCATION_TIMEOUT_SECONDS,
shuffle: bool = True) -> dict:
"""
Find device based on requirements and allocate first
Find device based on requirements and allocate first.
Note that this method doesn't wait for device to be free.
:param requirements: dictionary about requirements, e.g. `dict(platform='android')`
:param timeout_seconds: allocation timeout when idle, see more from allocation api.
:param shuffle: randomize allocation
:return: device dictionary
:raises DeviceNotFound: suitable device not found or all devices are allocated already
"""
NotConnectedError.invariant(self._client, 'Not connected')
suitable_devices = self.list_devices(requirements=requirements)
Expand All @@ -199,22 +204,20 @@ def find_and_allocate(self, requirements: dict,

self.logger.debug(f'Found {len(suitable_devices)} suitable devices, try to allocate one')

def allocate_first():
def try_allocate(device):
try:
return self.allocate(device, timeout_seconds=timeout_seconds)
except (AssertionError, ForbiddenException) as error:
self.logger.warning(f"{device.get('serial')}Allocation fails: {error}")
return None

tasks = map_(suitable_devices, lambda item: wrap(item, try_allocate))
return find(tasks, lambda allocFunc: allocFunc())

result = allocate_first()
if not result:
raise DeviceNotFound()
device = result.args[0]
return device
def try_allocate(device_candidate):
try:
return self.allocate(device_candidate, timeout_seconds=timeout_seconds)
except (AssertionError, ForbiddenException) as error:
self.logger.warning(f"{device_candidate.get('serial')} allocation fails: {error}")
return None

# generate try_allocate tasks for suitable devices
tasks = map_(suitable_devices, lambda item: wrap(item, try_allocate))
# find first successful allocation
result = find(tasks, lambda allocFunc: allocFunc())

DeviceNotFound.invariant(result, 'no suitable devices found')
return result.args[0]

def find_wait_and_allocate(self,
requirements: dict,
Expand All @@ -229,19 +232,24 @@ def find_wait_and_allocate(self,
:param shuffle: allocate suitable device randomly.
:return: device dictionary
"""
device = None
for i in range(wait_timeout): # try to allocate for 1 minute..
wait_until = time.time() + wait_timeout
print(f'wait_until: {wait_until}')
while True:
remaining_time = int(wait_until - time.time())
print(f'remaining_time: {remaining_time}')
try:
device = self.find_and_allocate(requirements=requirements,
timeout_seconds=timeout_seconds,
shuffle=shuffle)
break
return self.find_and_allocate(requirements=requirements,
timeout_seconds=timeout_seconds,
shuffle=shuffle)
except DeviceNotFound:
# Wait a while
time.sleep(1)
pass
DeviceNotFound.invariant(device, 'Suitable device not found')
return device
self.logger.debug(f'Suitable device not available, '
f'wait a while and try again. Timeout in {remaining_time} seconds')
if (wait_until - time.time()) <= 0:
break
# Wait a while to avoid too frequent polling
time.sleep(1)
raise DeviceNotFound(f'Suitable device not found within {wait_timeout}s timeout ({json.dumps(requirements)})')

@contextmanager
def allocation_context(self, requirements: dict,
Expand All @@ -267,4 +275,3 @@ def allocation_context(self, requirements: dict,
self.remote_connect(device)
yield device
self.release(device)

4 changes: 2 additions & 2 deletions stf_appium_client/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ def main():
'DEV1_MODEL\n'
'DEV1_MARKET_NAME\n'
'DEV1_REQUIREMENTS user given requirements\n'
'DEV1_INFO phone details\n'
'\nExample: stf --token 123 -- echo \$DEV1_SERIAL',
'DEV1_INFO phone details\n\n'
'Example: stf --token 123 -- echo $DEV1_SERIAL',
formatter_class=argparse.RawTextHelpFormatter)
parser.add_argument('--token',
required=True,
Expand Down
22 changes: 20 additions & 2 deletions test/test_StfClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
from stf_appium_client.exceptions import *




class TestStfClientBasics(unittest.TestCase):

@classmethod
Expand Down Expand Up @@ -214,4 +212,24 @@ def test_allocation_context_wait_success(self, mock_sleep):
self.assertEqual(device['serial'], '123')
self.assertEqual(device['remote_adb_url'], url)

@patch('time.sleep', side_effect=MagicMock())
def test_allocation_context_timeout(self, mock_sleep):
dev1 = {'serial': '123', 'present': True, 'ready': True, 'using': True, 'owner': "asd", 'status': 3}
self.client.get_devices = MagicMock(return_value=[dev1])

with self.assertRaises(DeviceNotFound) as error:
with self.client.allocation_context({"serial": '123'}, wait_timeout=0) as device:
pass
self.assertEqual(str(error.exception), 'Suitable device not found within 0s timeout ({"serial": "123"})')

@patch('time.sleep', return_value=MagicMock())
@patch('time.time')
def test_allocation_context_timeout_long(self, mock_time, mock_sleep):
dev1 = {'serial': '123', 'present': True, 'ready': True, 'using': True, 'owner': "asd", 'status': 3}
self.client.get_devices = MagicMock(return_value=[dev1])
self.client.stf_find_and_allocate = MagicMock(side_effect=DeviceNotFound)
mock_time.side_effect = [0, 0, 0, 10, 10, 10]
with self.assertRaises(DeviceNotFound) as error:
with self.client.allocation_context({"serial": '123'}, wait_timeout=10):
pass
self.assertEqual(str(error.exception), 'Suitable device not found within 10s timeout ({"serial": "123"})')

0 comments on commit 1df94f6

Please sign in to comment.