Python

Python SDK의 모든 것을 다룹니다. 설치, 연결, 명령 호출, 파일, 대기, 에러, 여러 폰과 스레드, 알림, 이벤트, pytest로 하는 테스트.

번역된 페이지입니다. 영어 문서와 내용이 다르면 영어 문서가 기준입니다. English

Python 패키지는 droidline serve를 위한 얇은 클라이언트입니다. 대기, 재시도, 대체 동작은 서버와 폰이 맡고, 패키지는 명령을 보내고, 응답을 Python 값으로 바꾸고, 실패를 예외로 바꿉니다. Python 3.9 이상이 필요하고 의존성은 없습니다.

설치

pip install droidline

가상 환경에 설치하면 다른 프로젝트와 섞이지 않습니다. 시스템별 방법은 설치에 있습니다. 서버를 실행한 상태에서 잘 동작하는지 확인합니다.

python -c "from droidline import connect; print(connect().info())"

연결

from droidline import connect

d = connect()                 # 온라인인 유일한 폰
d = connect("shelf-01")       # 이름이나 ID로 지정한 폰

connect()는 Device를 돌려줍니다. 메서드를 부르면 폰 한 대에 명령을 보내는 객체입니다. droidline serve가 실행 중이 아니면 바로 ServerNotRunningError로 실패합니다.

다른 컴퓨터의 서버에 접속하려면 주소와 클라이언트 토큰을 넘기거나, DROIDLINE_HOST, DROIDLINE_PORT, DROIDLINE_TOKEN, DROIDLINE_DEVICE를 설정합니다.

d = connect("shelf-01", host="192.168.0.12", port=8780, token="dlc_...")

여러 폰을 다루거나 devices, pair 같은 서버 명령을 쓰려면 Droidline 클라이언트를 만들고 거기서 기기를 받아 옵니다.

from droidline import Droidline

dl = Droidline()                    # connect()와 같은 host, port, token 인자
print(dl.devices())
a = dl.device("shelf-01")
b = dl.device("shelf-02")

두 객체 모두 with와 함께 쓸 수 있고, 블록이 끝나면 연결을 닫습니다.

with Droidline() as dl:
    dl.device("shelf-01").home()

명령 호출

명령어 레퍼런스의 모든 명령은 같은 이름의 메서드입니다. 필수 파라미터가 먼저 오고 위치 인자로 넘길 수 있습니다. 선택 파라미터는 위치 인자로도, 키워드 인자로도 넘길 수 있습니다.

d.touch("text", "Log in")
d.touch("text", "Log in", 0, 30)          # 위치 인자로 nth=0, timeout=30
d.touch("text", "Log in", timeout=30)     # 더 명확함: 키워드 인자
d.swipe("up")                             # swipe는 방향을 받거나...
d.swipe(540, 1600, 540, 400, 300)         # ...좌표와 시간을 받음
d.chrome.go("droidline.dev")              # 점이 들어간 명령은 속성으로 접근

가장 많이 쓰는 선택자 속성에는 축약형이 있습니다. d.touchById("login"), d.touchByText("OK"), d.touchByDesc("Search")입니다.

돌려받는 값

명령이 돌려주는 것받는 값예
특별한 것 없음via, ms 같은 세부 정보가 담긴 dictd.touch(...)는 {"via": "node", "ms": 208}을 돌려줌
값 하나그 값 자체d.exists(...)는 True를, d.get_text(...)는 str을 돌려줌
여러 항목dictd.battery()는 {"level": 84, "charging": False, "temperature": 31.5}를 돌려줌

메서드에는 타입 힌트가 있어서 편집기가 파라미터 이름을 자동 완성하고 결과의 형태를 보여 줍니다.

파일: 화면 덤프와 스크린샷

d.dump("screen.json")                     # 화면 트리를 PC의 파일로 저장
tree = d.dump()                           # 또는 dict로 돌려받음

d.screenshot("shot.png")                  # 확장자에 따라 PNG 또는 JPEG
d.screenshot("small.jpg", scale=0.5, quality=70)
png = d.screenshot(format="png")          # 경로가 없으면 이미지를 bytes로 돌려받음

경로는 PC 기준이며, 스크립트를 실행한 폴더에 대한 상대 경로입니다. 이미지를 메모리에서 다루려면 Pillow로 bytes를 엽니다(Image.open(io.BytesIO(png))).

대기와 타임아웃

요소를 다루는 명령은 그 요소를 기다립니다. 기본값은 10초이고, 호출마다 바꿀 수 있습니다.

d.touch("text", "Next", timeout=30)
d.wait("text", "Upload complete", 120)    # 기다리기만 함
d.wait_gone("id", "progress")             # 무언가 사라질 때까지 기다림

명령 앞에 time.sleep()을 넣지 마세요. 스크립트만 느려집니다. 화면에 드러나지 않는 이유로 잠시 멈춰야 할 때, 예를 들어 서버가 메시지를 보낼 시간을 줘야 할 때는 sleep을 씁니다.

조건 확인

조건 확인은 바로 응답하고, 요소가 없어도 예외를 일으키지 않습니다.

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, -1 중 하나

에러

실패한 명령은 DroidlineError의 하위 클래스 예외를 일으킵니다. 모든 예외에는 code, retryable, 그리고 서버가 보낸 추가 항목을 담은 data가 있습니다. 이 추가 항목은 속성으로도 읽을 수 있습니다.

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)      # 그 대신 보이던 화면
except DeviceOfflineError:
    print("the phone is offline")
except DroidlineError as e:
    print(e.code, e.retryable, e)
예외코드보통 뜻하는 것
NotFoundErrorNOT_FOUND타임아웃 안에 요소가 나타나지 않음
AmbiguousErrorAMBIGUOUS여러 요소가 일치했는데 명령에는 하나가 필요함
NotClickableErrorNOT_CLICKABLE요소를 탭하는 모든 방법이 실패함
DroidlineTimeoutErrorTIMEOUT폰에서 명령이 너무 오래 걸림
NoAccessibilityError, NoImeError, NoPermissionErrorNO_ACCESSIBILITY, NO_IME, NO_PERMISSION폰에서 권한이 꺼져 있음
AppNotFoundErrorAPP_NOT_FOUND그 패키지명의 앱이 없음
MacroFailedErrorMACRO_FAILEDclear_data 같은 설정 매크로를 끝내지 못함
DeviceOfflineError, DeviceNotFoundError, DeviceAmbiguousErrorDEVICE_*어느 폰인지, 또는 폰에 닿을 수 있는지에 관한 문제
AgentRestartedErrorAGENT_RESTARTED앱이 재시작됨. 명령이 실행됐을 수도, 안 됐을 수도 있음
BadArgsErrorBAD_ARGS파라미터가 빠졌거나 타입이 틀림
ServerNotRunningErrorSERVER_NOT_RUNNINGdroidline serve가 실행 중이 아님
ConnectionLostErrorCONNECTION_LOST호출 도중 서버와의 연결이 끊김

모두 droidline에서 import할 수 있습니다. 각 코드의 설명은 에러 코드에 있습니다.

재시도할 만한 에러로 표시된 경우에 쓰는 간단한 재시도 함수입니다.

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"))

여러 폰과 스레드

Droidline 클라이언트 하나로 여러 스레드에서 여러 폰을 다룰 수 있습니다. 한 폰에 보낸 명령은 순서대로 실행되고, 서로 다른 폰에 보낸 명령은 동시에 실행됩니다.

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)

선반 가득한 폰을 위한 패턴은 여러 폰에 더 있습니다.

알림과 이벤트

알림 하나를 기다리거나, 조건에 맞는 알림마다 콜백을 받을 수 있습니다. 먼저 폰이 어떤 앱의 알림을 넘길지 notify_filter나 앱의 상태 탭에서 고르세요.

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"]))
# ... 나중에
stop()

콜백은 SDK의 이벤트 스레드에서 실행되므로 짧게 유지하거나 작업을 큐로 넘기세요. 폰이 온라인이나 오프라인이 되는 것 같은 다른 이벤트는 클라이언트를 통해 들어옵니다.

dl = Droidline()
dl.subscribe(["device"])
dl.on("device", lambda e: print(e["name"], e["state"]))

답장, 클릭, 웹훅은 알림에서 다룹니다.

SDK가 아직 모르는 명령

서버가 패키지보다 새 버전이라면 명령을 이름으로 호출합니다.

d.call("some_new_command", value=1)

pytest로 테스트하기

fixture로 각 테스트에 홈 화면에서 시작하는 폰을 줍니다.

# 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"

이 테스트는 고치지 않고도 CI에서는 droidline-fakephone을 상대로, 책상 위에서는 실제 폰을 상대로 실행됩니다.

완성된 스크립트

첫 스크립트의 스크립트에 재시도와 깔끔한 종료 코드를 더한 것입니다.

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)