Python
Everything about the Python SDK: installing it, connecting, calling commands, files, waiting, errors, several phones and threads, notifications, events and testing with pytest.
The Python package is a thin client for droidline serve. The server and the phone do the waiting, retries and fallbacks; the package sends commands, turns replies into Python values and turns failures into exceptions. It needs Python 3.9 or later and has no dependencies.
Install
pip install droidline
Installing into a virtual environment keeps it apart from your other projects; Installation shows how on each system. Check that it works with the server running:
python -c "from droidline import connect; print(connect().info())"
Connect
from droidline import connect
d = connect() # the only phone that is online
d = connect("shelf-01") # a phone by name or ID
connect() returns a Device: an object whose methods send commands to one phone. It fails right away with ServerNotRunningError if droidline serve is not running.
To reach a server on another machine, pass its address and a client token, or set DROIDLINE_HOST, DROIDLINE_PORT, DROIDLINE_TOKEN and DROIDLINE_DEVICE:
d = connect("shelf-01", host="192.168.0.12", port=8780, token="dlc_...")
For several phones, or for server commands such as devices and pair, create a Droidline client and ask it for devices:
from droidline import Droidline
dl = Droidline() # same host, port and token arguments as connect()
print(dl.devices())
a = dl.device("shelf-01")
b = dl.device("shelf-02")
Both objects can be used with with, which closes the connection at the end:
with Droidline() as dl:
dl.device("shelf-01").home()
Call commands
Every command in the command reference is a method with the same name. Required parameters come first and can be passed by position; optional ones can be passed by position or by keyword:
d.touch("text", "Log in")
d.touch("text", "Log in", 0, 30) # nth=0, timeout=30 by position
d.touch("text", "Log in", timeout=30) # clearer: by keyword
d.swipe("up") # swipe takes a direction...
d.swipe(540, 1600, 540, 400, 300) # ...or coordinates and a duration
d.chrome.go("droidline.dev") # dotted commands are attributes
Shorthands exist for the most common selector fields: d.touchById("login"), d.touchByText("OK"), d.touchByDesc("Search").
What you get back
| The command returns | You get | Example |
|---|---|---|
| nothing interesting | a dict with details such as via and ms | d.touch(...) returns {"via": "node", "ms": 208} |
| one value | the value itself | d.exists(...) returns True, d.get_text(...) returns a str |
| several fields | a dict | d.battery() returns {"level": 84, "charging": False, "temperature": 31.5} |
The methods have type hints, so your editor completes parameter names and shows the shape of each result.
Files: dumps and screenshots
d.dump("screen.json") # writes the screen tree to a file on your PC
tree = d.dump() # or returns it as a dict
d.screenshot("shot.png") # the extension picks PNG or JPEG
d.screenshot("small.jpg", scale=0.5, quality=70)
png = d.screenshot(format="png") # without a path: the image as bytes
Paths are on your PC, relative to the folder you run the script from. To work with the image in memory, open the bytes with Pillow: Image.open(io.BytesIO(png)).
Waiting and timeouts
Commands that act on an element wait for it, 10 seconds by default. Change that per call:
d.touch("text", "Next", timeout=30)
d.wait("text", "Upload complete", 120) # only wait
d.wait_gone("id", "progress") # wait until something disappears
Avoid time.sleep() before a command; it only makes scripts slower. Use sleep when you need a pause for a reason the screen does not show, such as giving a server time to send a message.
Conditions
Conditions answer at once and never raise for a missing element:
if d.exists("text", "Allow"):
d.touch("text", "Allow")
if not d.in_app("com.android.chrome"):
d.launch("com.android.chrome")
state = d.which([("text", "Log in"), ("id", "main_tab")], timeout=15) # 0, 1 or -1
Errors
A failed command raises a subclass of DroidlineError. Each has code, retryable and data, the extra fields the server sent; those fields can also be read as attributes:
from droidline import connect, DroidlineError, NotFoundError, DeviceOfflineError
d = connect()
try:
d.touch("text", "Log in", timeout=5)
except NotFoundError as e:
print("not on screen:", e.screen) # the screen that was showing instead
except DeviceOfflineError:
print("the phone is offline")
except DroidlineError as e:
print(e.code, e.retryable, e)
| Exception | Code | Usually means |
|---|---|---|
NotFoundError | NOT_FOUND | The element did not appear within the timeout |
AmbiguousError | AMBIGUOUS | Several elements matched and the command needs one |
NotClickableError | NOT_CLICKABLE | Every way to tap the element failed |
DroidlineTimeoutError | TIMEOUT | The command took too long on the phone |
NoAccessibilityError, NoImeError, NoPermissionError | NO_ACCESSIBILITY, NO_IME, NO_PERMISSION | A permission is off on the phone |
AppNotFoundError | APP_NOT_FOUND | No app with that package name |
MacroFailedError | MACRO_FAILED | A Settings shortcut such as clear_data could not finish |
DeviceOfflineError, DeviceNotFoundError, DeviceAmbiguousError | DEVICE_* | Which phone, or whether it is reachable |
AgentRestartedError | AGENT_RESTARTED | The app restarted; the command may or may not have run |
BadArgsError | BAD_ARGS | A parameter is missing or has the wrong type |
ServerNotRunningError | SERVER_NOT_RUNNING | droidline serve is not running |
ConnectionLostError | CONNECTION_LOST | The connection to the server dropped during a call |
All of them can be imported from droidline. Error codes explains each code.
A simple retry for errors marked retryable:
import time
from droidline import DroidlineError
def retry(fn, attempts=3):
for i in range(attempts):
try:
return fn()
except DroidlineError as e:
if not e.retryable or i == attempts - 1:
raise
time.sleep(2)
retry(lambda: d.touch("text", "Refresh"))
Several phones and threads
One Droidline client can drive many phones from many threads. Commands to one phone run in order; commands to different phones run at the same time:
from concurrent.futures import ThreadPoolExecutor
from droidline import Droidline
dl = Droidline()
def morning(name):
d = dl.device(name)
d.wake()
d.launch("com.android.chrome")
d.chrome.go("droidline.dev")
return name, d.current()["package"]
names = [p["name"] for p in dl.devices() if p["online"]]
with ThreadPoolExecutor(max_workers=len(names) or 1) as pool:
for name, pkg in pool.map(morning, names):
print(name, pkg)
Many phones has more patterns for a shelf of phones.
Notifications and events
Wait for one notification, or get a callback for every matching one. Choose which apps the phone forwards first, with notify_filter or on the app's Status tab:
d.notify_filter(["com.google.android.gm"])
n = d.wait_notification("textContains", "verification code", 60)
print(n["title"], n["text"])
stop = d.on_notification(package="com.google.android.gm",
callback=lambda n: print(n["title"], n["text"]))
# ... later
stop()
Callbacks run on the SDK's event thread, so keep them short or hand the work to a queue. Other events, such as phones going online or offline, arrive through the client:
dl = Droidline()
dl.subscribe(["device"])
dl.on("device", lambda e: print(e["name"], e["state"]))
Notifications covers replies, clicks and webhooks.
Commands the SDK does not know yet
If the server is newer than your package, call a command by name:
d.call("some_new_command", value=1)
Testing with pytest
A fixture gives each test a phone, starting from the home screen:
# conftest.py
import pytest
from droidline import connect
@pytest.fixture
def phone():
d = connect()
d.home()
yield d
d.screenshot("last-test.png")
# test_login.py
def test_login(phone):
phone.launch("dev.droidline.demo")
phone.input("id", "email", "knife")
phone.touchById("login")
assert phone.which([("text", "Log in"), ("id", "main_tab")]) == 1
assert phone.get_text("id", "greeting") == "Welcome, knife"
The test runs against droidline-fakephone in CI and against a real phone on your desk without changes.
A complete script
The script from Your first script, with a retry and a clean exit code:
import sys
from droidline import connect, DroidlineError
def main() -> int:
d = connect()
d.launch("dev.droidline.demo")
d.wait("text", "Sign in")
if d.exists("text", "Close ad"):
d.touch("text", "Close ad")
d.input("id", "email", "knife")
if not d.checked("id", "auto_login"):
d.touch("id", "auto_login")
d.touch("text", "Log in")
if d.which([("text", "Log in"), ("id", "main_tab")], timeout=15) != 1:
d.screenshot("login-failed.png")
return 1
print(d.get_text("id", "greeting"))
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except DroidlineError as e:
print(f"{e.code}: {e}", file=sys.stderr)
sys.exit(2)