././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1599201184.7915945 arsenic-21.8/LICENSE0000644000000000000000000000104400000000000010763 0ustar00Copyright 2019 HDE Inc 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. ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1610416158.554483 arsenic-21.8/README.md0000644000000000000000000000410200000000000011233 0ustar00# Async Webdriver [![CircleCI](https://circleci.com/gh/HDE/arsenic/tree/main.svg?style=svg)](https://circleci.com/gh/HDE/arsenic/tree/main) [![Documentation Status](https://readthedocs.org/projects/arsenic/badge/?version=latest)](http://arsenic.readthedocs.io/en/latest/?badge=latest) [![BrowserStack Status](https://automate.browserstack.com/badge.svg?badge_key=QmtNVHFnWWRFSEVUdTBZNWU5NGMraVorWVltazFqRk1VNWRydW5FRXU2dz0tLVhoTlFuK2tZUTJ1UGx0UmZaWjg4R1E9PQ==--35ef3d28fbf8ea24ee7fa2a435f9271fbaaf85d4)](https://automate.browserstack.com/public-build/QmtNVHFnWWRFSEVUdTBZNWU5NGMraVorWVltazFqRk1VNWRydW5FRXU2dz0tLVhoTlFuK2tZUTJ1UGx0UmZaWjg4R1E9PQ==--35ef3d28fbf8ea24ee7fa2a435f9271fbaaf85d4) [![Appveyor status](https://ci.appveyor.com/api/projects/status/8l0koom7h93y1f9q?svg=true)](https://ci.appveyor.com/project/ojii/arsenic) [![PyPI version](https://badge.fury.io/py/arsenic.svg)](https://badge.fury.io/py/arsenic) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) Asynchronous webdriver client built on asyncio. ## Quickstart Let's run a local Firefox instance. ```python from arsenic import get_session from arsenic.browsers import Firefox from arsenic.services import Geckodriver async def example(): # Runs geckodriver and starts a firefox session async with get_session(Geckodriver(), Firefox()) as session: # go to example.com await session.get('http://example.com') # wait up to 5 seconds to get the h1 element from the page h1 = await session.wait_for_element(5, 'h1') # print the text of the h1 element print(await h1.get_text()) ``` For more information, check [the documentation](https://arsenic.readthedocs.io/) ## CI Supported by Browserstack Continuous integration for certain browsers is generously provided by [Browserstack](http://browserstack.com). [![Browserstack](./.circleci/browserstack-logo.png)](http://browserstack.com/) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1629685136.7053194 arsenic-21.8/pyproject.toml0000644000000000000000000000261300000000000012675 0ustar00[tool.poetry] name = "arsenic" version = "21.8" description = "Asynchronous WebDriver client" authors = ["Jonas Obrist "] license = "Apache-2.0" readme = "README.md" homepage = "https://github.com/HDE/arsenic" repository = "https://github.com/HDE/arsenic" documentation = "https://arsenic.readthedocs.io/en/latest/" keywords = ["asyncio"] classifiers = [ "Development Status :: 5 - Production/Stable", "Programming Language :: Python :: 3 :: Only", "Framework :: AsyncIO", "Topic :: Software Development :: Testing", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Operating System :: POSIX :: Linux", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", ] [tool.poetry.dependencies] python = "^3.7" attrs = ">=17.4.0" structlog = "^20.1.0" aiohttp = ">=3" [tool.poetry.dev-dependencies] jinja2 = "^2.11.2" pytest = "^6.0.1" pytest-asyncio = "^0.14.0" sphinx = "^3.2.1" pytest-cov = "^2.10.1" asyncio-extras = "^1.3.2" pre-commit = "^2.7.1" black = "^20.8b1" Pillow = "^8.1.0" [tool.poetry.scripts] arsenic-check-ie11 = "arsenic.helpers:check_ie11_environment_cli" arsenic-configure-ie11 = "arsenic.helpers:configure_ie11_environment_cli" [build-system] requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1547621448.604307 arsenic-21.8/src/arsenic/__init__.py0000644000000000000000000000154100000000000014304 0ustar00import attr from arsenic.browsers import Browser from arsenic.services import Service from arsenic.session import Session @attr.s class SessionContext: service = attr.ib() browser = attr.ib() bind = attr.ib() session = attr.ib(default=None) async def __aenter__(self): self.session = await start_session(self.service, self.browser, self.bind) return self.session async def __aexit__(self, exc_type, exc_val, exc_tb): await stop_session(self.session) def get_session(service, browser, bind=""): return SessionContext(service, browser, bind) async def start_session(service: Service, browser: Browser, bind=""): driver = await service.start() return await driver.new_session(browser, bind=bind) async def stop_session(session: Session): await session.close() await session.driver.close() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1547716427.5616856 arsenic-21.8/src/arsenic/actions.py0000644000000000000000000000750500000000000014213 0ustar00import abc from enum import Enum from typing import Sequence, Any, Iterator, List, Dict, Optional import attr from arsenic import constants from arsenic.session import Element @attr.s class Action: source = attr.ib() payload = attr.ib() class Tick: def __init__(self, *actions: Action): self.actions: Dict["Device", "Action"] = { action.source: action for action in actions } def __and__(self, other: "Tick") -> "Tick": overlap = set(self.actions.keys()) & set(other.actions.keys()) if overlap: raise ValueError( f"Devices {overlap} have more than one action in this tick" ) return Tick(*self.actions.values(), *other.actions.values()) def encode(self, device: "Device") -> Dict[str, Any]: if device not in self.actions: return {"type": "pause", "duration": 0} else: return self.actions[device].payload class DeviceType(Enum): keyboard = "key" pointer = "pointer" class PointerType(Enum): mouse = "mouse" pen = "pen" touch = "touch" class Button(Enum): left = 0 middle = 1 right = 2 class Device(metaclass=abc.ABCMeta): type: DeviceType = abc.abstractproperty() def __init__(self, device_id: Optional[str] = None): self.device_id = device_id def info(self, index: int) -> Dict[str, Any]: device_id = self.device_id or f"{self.type.value}{index}" return {"id": device_id, "type": self.type.value} def _tick(self, **payload: Any) -> Tick: return Tick(Action(self, payload)) def pause(self, duration: int) -> Tick: return self._tick(type="pause", duration=duration) class Pointer(Device, metaclass=abc.ABCMeta): type = DeviceType.pointer pointer_type: PointerType = abc.abstractproperty() def info(self, index: int) -> Dict[str, Any]: return { "parameters": {"pointerType": self.pointer_type.value}, **super().info(index), } def move_to(self, element: Element, duration: int = 250) -> Tick: return self._tick( type="pointerMove", duration=duration, origin={constants.WEB_ELEMENT: element.id}, x=0, y=0, ) def move_by(self, x: int, y: int, duration: int = 250) -> Tick: return self._tick( type="pointerMove", duration=duration, origin="pointer", x=x, y=y ) def down(self) -> Tick: return self._tick(type="pointerDown", duration=0, button=0) def up(self) -> Tick: return self._tick(type="pointerUp", duration=0, button=0) class Mouse(Pointer): pointer_type = PointerType.mouse def down(self, button: Button = Button.left) -> Tick: return self._tick(type="pointerDown", duration=0, button=button.value) def up(self, button: Button = Button.left) -> Tick: return self._tick(type="pointerUp", duration=0, button=button.value) class Pen(Pointer): pointer_type = PointerType.pen class Touch(Pointer): pointer_type = PointerType.touch class Keyboard(Device): type = DeviceType.keyboard def down(self, key: str) -> Tick: return self._tick(type="keyDown", value=key) def up(self, key: str) -> Tick: return self._tick(type="keyUp", value=key) def gather_devices(ticks: Sequence[Tick]) -> Iterator[Device]: found = set() for tick in ticks: devices = set(tick.actions.keys()) for device in devices - found: yield device found.update(devices) def chain(*ticks: Tick) -> Dict[str, List[Dict[str, Any]]]: devices = list(gather_devices(ticks)) return { "actions": [ {**device.info(index), "actions": [tick.encode(device) for tick in ticks]} for index, device in enumerate(devices, start=1) ] } ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1623664285.2968092 arsenic-21.8/src/arsenic/browsers.py0000644000000000000000000000107400000000000014414 0ustar00from arsenic.session import Session class Browser: defaults = {} session_class = Session def __init__(self, **overrides): self.capabilities = {**self.defaults, **overrides} class Firefox(Browser): defaults = {"browserName": "firefox"} class Chrome(Browser): defaults = {"browserName": "chrome"} class InternetExplorer(Browser): session_class = Session defaults = {"browserName": "internet explorer"} IE = InternetExplorer class MicrosoftEdge(Browser): defaults = {"browserName": "MicrosoftEdge"} Edge = MicrosoftEdge ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1611808575.910153 arsenic-21.8/src/arsenic/connection.py0000644000000000000000000001051200000000000014702 0ustar00import asyncio import base64 import json from functools import wraps from io import BytesIO from json import JSONDecodeError from pathlib import Path from typing import Any, Tuple from urllib.parse import urlparse, urlunparse from zipfile import ZIP_DEFLATED, ZipFile from aiohttp import ClientSession from structlog import get_logger from arsenic import errors, constants log = get_logger() def wrap_screen(data): """ Data returned from a webdriver may contain a screen, which is a base64 encoded PNG of the browser screen. This is a massive string and will make logging useless, so we wrap it in a BytesIO. """ if ( isinstance(data, dict) and "value" in data and isinstance(data["value"], dict) and "screen" in data["value"] and data["value"]["screen"] ): data["value"]["screen"] = BytesIO(base64.b64decode(data["value"]["screen"])) def unwrap(value): if isinstance(value, dict) and ( "ELEMENT" in value or constants.WEB_ELEMENT in value ): wrapped_id = value.get("ELEMENT", None) if wrapped_id: return value["ELEMENT"] else: return value[constants.WEB_ELEMENT] elif isinstance(value, list): return list(unwrap(item) for item in value) else: return value def ensure_task(func): @wraps(func) async def wrapper(*args, **kwargs): return await asyncio.get_event_loop().create_task(func(*args, **kwargs)) return wrapper def strip_auth(url: str) -> str: pr = urlparse(url) safe_netloc = pr.hostname if pr.port: safe_netloc = f"{safe_netloc}:{pr.port}" return urlunparse( (pr.scheme, safe_netloc, pr.path, pr.params, pr.query, pr.fragment) ) def check_response_error(*, status: int, data: Any) -> None: if status >= 400: errors.raise_exception(data, status) if not isinstance(data, dict): return data_status = data.get("status", None) if data_status is None: return if data_status == constants.STATUS_SUCCESS: return errors.raise_exception(data, status) class Connection: def __init__(self, session: ClientSession, prefix: str): self.session = session self.prefix = prefix @ensure_task async def request( self, *, url: str, method: str, data=None, timeout=None ) -> Tuple[int, Any]: header = {"Content-Type": "application/json"} if data is None: data = {} if method not in {"POST", "PUT"}: data = None header = None body = json.dumps(data) if data is not None else None full_url = self.prefix + url log.info( "request", url=strip_auth(full_url), method=method, header=header, body=body ) async with self.session.request( url=full_url, method=method, headers=header, data=body, timeout=timeout ) as response: response_body = await response.read() try: data = json.loads(response_body) except JSONDecodeError as exc: log.error("json-decode", body=response_body) data = {"error": "!internal", "message": str(exc), "stacktrace": ""} wrap_screen(data) log.info( "response", url=strip_auth(full_url), method=method, body=body, response=response, data=data, ) check_response_error(data=data, status=response.status) return response.status, data async def upload_file(self, path: Path) -> Path: log.info("upload-file", path=path, resolved_path=path) return path def prefixed(self, prefix: str) -> "Connection": return self.__class__(self.session, self.prefix + prefix) class RemoteConnection(Connection): async def upload_file(self, path: Path) -> Path: fobj = BytesIO() with ZipFile(fobj, "w", ZIP_DEFLATED) as zf: zf.write(path, path.name) content = base64.b64encode(fobj.getvalue()).decode("utf-8") status, data = await self.request( url="/file", method="POST", data={"file": content} ) value = unwrap(data.get("value", None)) log.info("upload-file", path=path, resolved_path=value) return Path(value) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1623664290.795367 arsenic-21.8/src/arsenic/constants.py0000644000000000000000000000052300000000000014560 0ustar00from enum import Enum WEB_ELEMENT = "element-6066-11e4-a52e-4f735466cecf" STATUS_SUCCESS = 0 class SelectorType(Enum): css_selector = "css selector" link_text = "link text" partial_link_text = "partial link text" tag_name = "tag name" xpath = "xpath" class WindowType(Enum): tab = "tab" window = "window" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1629685034.4226122 arsenic-21.8/src/arsenic/errors.py0000644000000000000000000000645000000000000014065 0ustar00from typing import Union, Dict, Type, Any from structlog import get_logger log = get_logger() class ArsenicError(Exception): pass class SessionStartError(ArsenicError): def __init__(self, error, message, response): self.error = error self.message = message self.response = response super().__init__(f"{error}: {message}") class OperationNotSupported(ArsenicError): pass class WebdriverError(ArsenicError): def __init__(self, message, screen, stacktrace): self.message = message self.screen = screen self.stacktrace = stacktrace super().__init__(message) class UnknownArsenicError(ArsenicError): pass class ArsenicTimeout(ArsenicError): pass CODES: Dict[Union[str, int], Type[WebdriverError]] = {} def get(error_code: Union[str, int]) -> Type[WebdriverError]: return CODES.get(error_code, UnknownArsenicError) def create(error_name: str, *error_codes: int) -> Type[WebdriverError]: name = "".join(bit.capitalize() for bit in error_name.split(" ")) cls: Type[WebdriverError] = type(name, (WebdriverError,), {}) CODES[error_name] = cls for code in error_codes: CODES[code] = cls return cls NoSuchElement = create("no such element", 7) NoSuchFrame = create("no such frame", 8) UnknownCommand = create("unknown command", 9) StaleElementReference = create("stale element reference", 10) ElementNotVisible = create("element not visible", 11) InvalidElementState = create("invalid element state", 12) UnknownError = create("unknown error", 13) ElementNotInteractable = create("element not interactable") ElementIsNotSelectable = create("element is not selectable", 15) JavascriptError = create("javascript error", 17) Timeout = create("timeout", 21) NoSuchWindow = create("no such window", 23) InvalidCookieDomain = create("invalid cookie domain", 24) UnableToSetCookie = create("unable to set cookie", 25) UnexpectedAlertOpen = create("unexpected alert open", 26) NoSuchAlert = create("no such alert", 27) ScriptTimeout = create("script timeout", 28) InvalidElementCoordinates = create("invalid element coordinates", 29) IMENotAvailable = create("ime not available", 30) IMEEngineActivationFailed = create("ime engine activation failed", 31) InvalidSelector = create("invalid selector", 32) MoveTargetOutOfBounds = create("move target out of bounds", 34) # exceptions that may indicate errors within arsenic ERROR_CLASSES = (UnknownArsenicError, UnknownCommand, UnknownError) def raise_exception(data: Dict[str, Any], status: int): error = None if "status" in data: error = data["status"] elif "error" in data: error = data["error"] elif "state" in data: error = data["state"] if "value" in data and isinstance(data["value"], dict): data = data["value"] if error is None and "error" in data: error = data["error"] message = data.get("message", None) stacktrace = data.get("stacktrace", None) screen = data.get("screen", None) exception_class = get(error) if exception_class in ERROR_CLASSES: log.error( "error", type=exception_class, message=message, stacktrace=stacktrace, data=data, status=status, ) raise exception_class(message, screen, stacktrace) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1547621448.6065667 arsenic-21.8/src/arsenic/helpers.py0000644000000000000000000001531000000000000014206 0ustar00import sys if sys.platform != "win32": def check_ie11_environment_cli(): pass def configure_ie11_environment_cli(): pass else: import ctypes import winreg from collections import defaultdict from ctypes import wintypes KEYS = [ ( winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Internet Explorer\Main\FeatureControl\FEATURE_BFCACHE", "iexplore.exe", 0, winreg.REG_DWORD, ), ( winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Wow6432Node\Microsoft\Internet Explorer\Main\FeatureControl\FEATURE_BFCACHE", "iexplore.exe", 0, winreg.REG_DWORD, ), ( winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Internet Explorer\Zoom", "ZoomFactor", 100_000, winreg.REG_DWORD, ), ] PROTECTION_MODES = [ ( winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Internet Settings\Lockdown_Zones\0", "CurrentLevel", winreg.REG_DWORD, ), ( winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Internet Settings\Lockdown_Zones\1", "CurrentLevel", winreg.REG_DWORD, ), ( winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Internet Settings\Lockdown_Zones\2", "CurrentLevel", winreg.REG_DWORD, ), ( winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Internet Settings\Lockdown_Zones\3", "CurrentLevel", winreg.REG_DWORD, ), ( winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Internet Settings\Lockdown_Zones\4", "CurrentLevel", winreg.REG_DWORD, ), ] KEY_NAMES = { value: key for key, value in vars(winreg).items() if key.startswith("HKEY_") } TYPE_NAMES = { value: key for key, value in vars(winreg).items() if key.startswith("REG_") } def detect_dpi_setting(): ctypes.windll.shcore.SetProcessDpiAwareness(2) ok = [] def cb(monitor, dc, rect, data): x = wintypes.UINT() y = wintypes.UINT() ctypes.windll.shcore.GetDpiForMonitor( monitor, 0, ctypes.pointer(x), ctypes.pointer(y) ) if (x.value, y.value) != (96, 96): print("DPI Scaling does not seem to be set to 100%") ok.append(False) else: ok.append(True) return 0 ctypes.windll.user32.EnumDisplayMonitors( 0, 0, ctypes.WINFUNCTYPE( ctypes.c_uint, ctypes.c_ulong, ctypes.c_ulong, ctypes.POINTER(wintypes.RECT), ctypes.c_double, )(cb), 0, ) if not ok: print("No Monitors found") return ok and all(ok) def check_ie11_environment(): ok = True for key, subkey, attribute, value, value_type in KEYS: path = f"{KEY_NAMES[key]}\\{subkey}" try: with winreg.OpenKey(key, subkey) as regkey: try: actual_value, actual_type = winreg.QueryValueEx( regkey, attribute ) except OSError: ok = False print(f"Key {path} has no attribute {attribute}") else: if actual_value != value: ok = False print( f"Key {path}\\{attribute} has value {actual_value}, expected {value}" ) if actual_type != value_type: ok = False print( f"Key {path}\\{attribute} has value type {TYPE_NAMES[actual_type]}, expected {TYPE_NAMES[value_type]}" ) except OSError: ok = False print(f"Key {path} does not exist!") modes = defaultdict(list) for key, subkey, attribute, value_type in PROTECTION_MODES: path = f"{KEY_NAMES[key]}\\{subkey}" try: with winreg.OpenKey(key, subkey) as regkey: try: actual_value, actual_type = winreg.QueryValueEx( regkey, attribute ) except OSError: ok = False print(f"Key {path} has no attribute {attribute}") else: modes[actual_value].append(path) if actual_type != value_type: ok = False print( f"Key {path}\\{attribute} has value type {TYPE_NAMES[actual_type]}, expected {TYPE_NAMES[value_type]}" ) except OSError: ok = False print(f"Key {path} does not exist!") if len(modes) > 1: print("Not all zones have same protected mode setting:") for value, zones in modes.items(): print(f'{", ".join(zones)} have value {value}') ok = ok and detect_dpi_setting() if ok: print("Environment looks okay") return ok def check_ie11_environment_cli(): if not check_ie11_environment(): sys.exit(-1) def configure_ie11_environment(protected_mode): for key, subkey, attribute, value, value_type in KEYS: try: regkey = winreg.OpenKey(key, subkey, access=winreg.KEY_SET_VALUE) except OSError: regkey = winreg.CreateKey(key, subkey) try: winreg.SetValueEx(regkey, attribute, 0, value_type, value) finally: winreg.CloseKey(regkey) for key, subkey, attribute, value_type in PROTECTION_MODES: try: regkey = winreg.OpenKey(key, subkey, access=winreg.KEY_SET_VALUE) except OSError: regkey = winreg.CreateKey(key, subkey) try: winreg.SetValueEx(regkey, attribute, 0, value_type, protected_mode) finally: winreg.CloseKey(regkey) def configure_ie11_environment_cli(): configure_ie11_environment(0) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1547716427.5626037 arsenic-21.8/src/arsenic/http.py0000644000000000000000000000076100000000000013527 0ustar00import abc import base64 from typing import Dict import attr class Auth(metaclass=abc.ABCMeta): @abc.abstractmethod def get_headers(self) -> Dict[str, str]: raise NotImplementedError() @attr.s class BasicAuth(Auth): username = attr.ib() password = attr.ib() def get_headers(self): raw_token = f"{self.username}:{self.password}" token = base64.b64encode(raw_token.encode("ascii")).decode("ascii") return {"Authorization": f"Basic {token}"} ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1547621448.6069164 arsenic-21.8/src/arsenic/keys.py0000644000000000000000000000213000000000000013513 0ustar00""" This module contains special keys as defined in the web driver specification. They can be used in the send_keys API using string formatting. """ NULL = "\ue000" CANCEL = "\ue001" HELP = "\ue002" BACKSPACE = "\ue003" TAB = "\ue004" CLEAR = "\ue005" RETURN = "\ue006" ENTER = "\ue007" SHIFT = "\ue008" CONTROL = "\ue009" ALT = "\ue00a" PAUSE = "\ue00b" ESCAPE = "\ue00c" SPACE = "\ue00d" PAGE_UP = "\ue00e" PAGE_DOWN = "\ue00f" END = "\ue010" HOME = "\ue011" LEFT = "\ue012" UP = "\ue013" RIGHT = "\ue014" DOWN = "\ue015" INSERT = "\ue016" DELETE = "\ue017" SEMICOLON = "\ue018" EQUALS = "\ue019" NUMPAD0 = "\ue01a" NUMPAD1 = "\ue01b" NUMPAD2 = "\ue01c" NUMPAD3 = "\ue01d" NUMPAD4 = "\ue01e" NUMPAD5 = "\ue01f" NUMPAD6 = "\ue020" NUMPAD7 = "\ue021" NUMPAD8 = "\ue022" NUMPAD9 = "\ue023" MULTIPLY = "\ue024" ADD = "\ue025" SEPARATOR = "\ue026" SUBTRACT = "\ue027" DECIMAL = "\ue028" DIVIDE = "\ue029" F1 = "\ue031" F2 = "\ue032" F3 = "\ue033" F4 = "\ue034" F5 = "\ue035" F6 = "\ue036" F7 = "\ue037" F8 = "\ue038" F9 = "\ue039" F10 = "\ue03a" F11 = "\ue03b" F12 = "\ue03c" META = "\ue03d" COMMAND = "\ue03d" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1629685034.4239197 arsenic-21.8/src/arsenic/services.py0000644000000000000000000001346500000000000014400 0ustar00import abc import asyncio import re import sys from distutils.version import StrictVersion from functools import partial from typing import List, TextIO, Optional import aiohttp.client_exceptions import attr from aiohttp import ClientSession from arsenic.connection import Connection, RemoteConnection from arsenic.http import Auth, BasicAuth from arsenic.subprocess import get_subprocess_impl from arsenic.utils import free_port from arsenic.webdriver import WebDriver from arsenic.errors import ArsenicError async def tasked(coro): return await asyncio.get_event_loop().create_task(coro) async def check_service_status(session: ClientSession, url: str) -> bool: async with session.get(url + "/status") as response: return 200 <= response.status < 300 async def subprocess_based_service( cmd: List[str], service_url: str, log_file: TextIO, start_timeout: float = 15, ) -> WebDriver: closers = [] try: impl = get_subprocess_impl() process = await impl.start_process(cmd, log_file) closers.append(partial(impl.stop_process, process)) session = ClientSession() closers.append(session.close) count = 0 async def wait_service(): # Wait for service with exponential back-off for i in range(-10, 9999): try: ok = await tasked(check_service_status(session, service_url)) except aiohttp.client_exceptions.ClientConnectorError: # We possibly checked too quickly ok = False if ok: return await asyncio.sleep(start_timeout * 2 ** i) try: await asyncio.wait_for(wait_service(), timeout=start_timeout) except asyncio.TimeoutError: raise ArsenicError("not starting?") return WebDriver(Connection(session, service_url), closers) except: for closer in reversed(closers): await closer() raise class Service(metaclass=abc.ABCMeta): @abc.abstractmethod async def start(self) -> WebDriver: raise NotImplementedError() @attr.s class Geckodriver(Service): log_file = attr.ib(default=sys.stdout) binary = attr.ib(default="geckodriver") version_check = attr.ib(default=True) start_timeout = attr.ib(default=15) _version_re = re.compile(r"geckodriver (\d+\.\d+)") async def _check_version(self): if self.version_check: impl = get_subprocess_impl() output = await impl.run_process([self.binary, "--version"]) match = self._version_re.search(output) if not match: raise ValueError( "Could not determine version of geckodriver. To " "disable version checking, set `version_check` to " "`False`." ) version_str = match.group(1) version = StrictVersion(version_str) if version < StrictVersion("0.16.1"): raise ValueError( f"Geckodriver version {version_str} is too old. 0.16.1 or " f"higher is required. To disable version checking, set " f"`version_check` to `False`." ) async def start(self): port = free_port() await self._check_version() return await subprocess_based_service( [self.binary, "--port", str(port)], f"http://localhost:{port}", self.log_file, start_timeout=self.start_timeout, ) @attr.s class Chromedriver(Service): log_file = attr.ib(default=sys.stdout) binary = attr.ib(default="chromedriver") start_timeout = attr.ib(default=15) async def start(self): port = free_port() return await subprocess_based_service( [self.binary, f"--port={port}"], f"http://localhost:{port}", self.log_file, start_timeout=self.start_timeout, ) @attr.s class MSEdgeDriver(Service): log_file = attr.ib(default=sys.stdout) binary = attr.ib(default="msedgedriver") start_timeout = attr.ib(default=15) async def start(self): port = free_port() return await subprocess_based_service( [self.binary, f"--port={port}"], f"http://localhost:{port}", self.log_file, start_timeout=self.start_timeout, ) def auth_or_string(value): if value is None: return value elif isinstance(value, Auth): return value elif isinstance(value, str) and value.count(":") == 1: username, password = value.split(":") return BasicAuth(username, password) else: raise TypeError() @attr.s class Remote(Service): url: str = attr.ib() auth: Optional[Auth] = attr.ib( default=None, converter=attr.converters.optional(auth_or_string) ) async def start(self): closers = [] headers = {} if self.auth: headers.update(self.auth.get_headers()) try: session = ClientSession(headers=headers) closers.append(session.close) return WebDriver(RemoteConnection(session, self.url), closers) except: for closer in reversed(closers): await closer() raise @attr.s class IEDriverServer(Service): log_file = attr.ib(default=sys.stdout) binary = attr.ib(default="IEDriverServer.exe") log_level = attr.ib(default="FATAL") start_timeout = attr.ib(default=15) async def start(self): port = free_port() return await subprocess_based_service( [self.binary, f"/port={port}", f"/log-level={self.log_level}"], f"http://localhost:{port}", self.log_file, start_timeout=self.start_timeout, ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1629685034.4247928 arsenic-21.8/src/arsenic/session.py0000644000000000000000000003164000000000000014233 0ustar00import base64 from functools import partial from io import BytesIO from pathlib import Path from typing import Awaitable, Callable, Any, List, Dict, Tuple, Iterator import attr from arsenic import constants from arsenic.connection import Connection, unwrap from arsenic.constants import SelectorType, WindowType from arsenic.errors import NoSuchElement, OperationNotSupported from arsenic.utils import Rect UNSET = object() def escape_value(value: str) -> str: if '"' in value and "'" in value: parts = value.split('"') result = ["concat("] for part in parts: result.append(f'"{part}"') result.append(", '\"', ") result = result[0:-1] if value.endswith('"'): return "".join(result) + ")" else: return "".join(result[:-1]) + ")" elif '"' in value: return f"'{value}'" else: return f'"{value}"' class RequestHelpers: async def _request( self, *, url: str, method: str, data=None, raw=False, timeout=None ): status, data = await self.connection.request( url=url, method=method, data=data, timeout=timeout ) if raw: return data if data: return self._unwrap(data.get("value", None)) def _unwrap(self, value): """ Unwrap a value returned from a webdriver. Specifically this means trying to extract the element ID of a web element or a list of web elements. """ return unwrap(value) class Element(RequestHelpers): def __init__(self, id: str, connection: Connection, session: "Session"): self.id = id self.connection = connection self.session = session async def get_text(self) -> str: return await self._request(url="/text", method="GET") async def send_keys(self, keys: str): await self._request( url="/value", method="POST", data={"value": list(keys), "text": keys} ) async def send_file(self, path: Path): path = await self.session.connection.upload_file(path) await self.send_keys(str(path)) async def clear(self): await self._request(url="/clear", method="POST") async def click(self): await self._request(url="/click", method="POST") async def is_displayed(self) -> bool: return await self._request(url="/displayed", method="GET") async def is_enabled(self) -> bool: return await self._request(url="/enabled", method="GET") async def get_attribute(self, name: str) -> str: return await self._request(url=f"/attribute/{name}", method="GET") async def get_property(self, name: str) -> str: return await self._request(url=f"/property/{name}", method="GET") async def get_css_value(self, name: str) -> str: return await self._request(url=f"/css/{name}", method="GET") async def select_by_value(self, value: str): value = escape_value(value) option = await self.get_element(f"option[value={value}]") await option.click() async def get_element( self, selector: str, selector_type: SelectorType = SelectorType.css_selector ) -> "Element": element_id = await self._request( url="/element", method="POST", data={"using": selector_type.value, "value": selector}, ) return self.session.create_element(element_id) async def get_elements( self, selector: str, selector_type: SelectorType = SelectorType.css_selector ) -> List["Element"]: element_ids = await self._request( url="/elements", method="POST", data={"using": selector_type.value, "value": selector}, ) return [self.session.create_element(element_id) for element_id in element_ids] async def get_rect(self): data = await self._request(url="/rect", method="GET") return Rect(data["x"], data["y"], data["width"], data["height"]) async def get_screenshot(self): return BytesIO( base64.b64decode(await self._request(url="/screenshot", method="GET")) ) TCallback = Callable[..., Awaitable[Any]] TWaiter = Callable[[int, TCallback], Awaitable[Any]] class Session(RequestHelpers): element_class = Element def __init__( self, connection: Connection, wait: TWaiter, driver, browser, bind: str = "" ): self.connection = connection self.bind = bind self.wait = wait self.driver = driver self.browser = browser async def request( self, url: str, method: str = "GET", data: Dict[str, Any] = UNSET ): return await self._request(url=url, method=method, data=data) async def get(self, url: str, timeout=None): await self._request( url="/url", method="POST", data={"url": self.bind + url}, timeout=timeout ) async def get_url(self): return await self._request(url="/url", method="GET") async def get_page_source(self) -> str: return await self._request(url="/source", method="GET") async def get_element( self, selector: str, selector_type: SelectorType = SelectorType.css_selector ) -> Element: element_id = await self._request( url="/element", method="POST", data={"using": selector_type.value, "value": selector}, ) return self.create_element(element_id) async def get_elements( self, selector: str, selector_type: SelectorType = SelectorType.css_selector ) -> List[Element]: result = await self._request( url="/elements", method="POST", data={"using": selector_type.value, "value": selector}, ) return [self.create_element(element_id) for element_id in result] async def wait_for_element( self, timeout: int, selector: str, selector_type: SelectorType = SelectorType.css_selector, ) -> Element: return await self.wait( timeout, partial(self.get_element, selector, selector_type), NoSuchElement ) async def wait_for_element_gone( self, timeout: int, selector: str, selector_type: SelectorType = SelectorType.css_selector, ): async def callback(): try: await self.get_element(selector, selector_type) except NoSuchElement: return True else: return False return await self.wait(timeout, callback) async def add_cookie( self, name: str, value: str, *, path: str = UNSET, domain: str = UNSET, secure: bool = UNSET, expiry: int = UNSET, httponly: bool = UNSET, ): cookie = {"name": name, "value": value} if path is not UNSET: cookie["path"] = path if domain is not UNSET: cookie["domain"] = domain if secure is not UNSET: cookie["secure"] = secure if expiry is not UNSET: cookie["expiry"] = expiry if httponly is not UNSET: cookie["HttpOnly"] = httponly await self._request(url="/cookie", method="POST", data={"cookie": cookie}) async def get_cookie(self, name: str): return await self._request(url=f"/cookie/{name}", method="GET") async def get_all_cookies(self): return await self._request(url="/cookie", method="GET") async def delete_cookie(self, name: str): await self._request(url=f"/cookie/{name}", method="DELETE") async def delete_all_cookies(self): await self._request(url="/cookie", method="DELETE") async def execute_script(self, script: str, *args: Any): return await self._request( url="/execute/sync", method="POST", data={"script": script, "args": list(args)}, ) async def execute_async_script(self, script: str, *args: Any): return await self._request( url="/execute/async", method="POST", data={"script": script, "args": list(args)}, ) async def set_window_size(self, width: int, height: int, handle: str = "current"): return await self._request( url="/window/rect", method="POST", data={"width": width, "height": height, "windowHandle": handle}, ) async def get_window_size(self, handle: str = "current") -> Tuple[int, int]: return await self._request( url="/window/rect", method="GET", data={"windowHandle": handle} ) async def set_window_fullscreen(self, handle: str = "current"): return await self._request( url="/window/fullscreen", method="POST", data={"windowHandle": handle} ) async def set_window_maximize(self, handle: str = "current"): return await self._request( url="/window/maximize", method="POST", data={"windowHandle": handle} ) async def get_alert_text(self) -> str: return await self._request(url="/alert/text", method="GET") async def send_alert_text(self, value: str): return await self._request( url="/alert/text", method="POST", data={"text": value} ) async def dismiss_alert(self): return await self._request(url="/alert/dismiss", method="POST") async def accept_alert(self): return await self._request(url="/alert/accept", method="POST") async def perform_actions(self, actions: Dict[str, Any]): return await self._request(url="/actions", method="POST", data=actions) async def get_screenshot(self) -> BytesIO: return BytesIO( base64.b64decode(await self._request(url="/screenshot", method="GET")) ) async def close(self): await self._request(url="", method="DELETE") def create_element(self, element_id): return self.element_class( element_id, self.connection.prefixed(f"/element/{element_id}"), self ) async def get_window_handles(self): return await self._request(url="/window/handles", method="GET") async def get_window_handle(self): return await self._request(url="/window", method="GET") async def switch_to_window(self, handle): return await self._request( url="/window", method="POST", data={"handle": handle, "name": handle} ) async def new_window(self, window_type: WindowType = WindowType.tab): return await self._request( url="/window/new", method="POST", data={"type": window_type.value} ) def _pointer_down(device, action): del action["duration"] url = ( "/buttondown" if device["parameters"]["pointerType"] == "mouse" else "/touch/down" ) return url, "POST", action def _pointer_up(device, action): del action["duration"] url = "/buttonup" if device["parameters"]["pointerType"] == "mouse" else "/touch/up" return url, "POST", action def _pointer_move(device, action): del action["duration"] url = "/moveto" if device["parameters"]["pointerType"] == "mouse" else "/touch/move" origin = action["origin"] if origin == "pointer": data = {"xoffset": action["x"], "yoffset": action["y"]} elif constants.WEB_ELEMENT in origin: data = {"element": origin[constants.WEB_ELEMENT]} else: raise OperationNotSupported(f"Cannot move using origin {origin}") return url, "POST", data def _pause(device, action): return None def key_down(device, action): return "/keydown", "POST", {""} legacy_actions = { ("pointer", "pointerDown"): _pointer_down, ("pointer", "pointerUp"): _pointer_up, ("pointer", "pointerMove"): _pointer_move, ("pointer", "pause"): _pause, ("key", "pause"): _pause, } @attr.s class LegacyAction: device = attr.ib() action = attr.ib() def get_legacy_actions(devices: List[Dict[str, Any]]) -> Iterator[LegacyAction]: i = 0 while devices: for device in devices: action = device["actions"].pop(0) i += 1 yield LegacyAction(device, action) devices = [device for device in devices if device["actions"]] def transform_legacy_actions( devices: List[Dict[str, Any]] ) -> Iterator[Tuple[str, str, Dict[str, Any]]]: for legacy_action in get_legacy_actions(devices): device_type = legacy_action.device["type"] action_type = legacy_action.action["type"] device = { key: value for key, value in legacy_action.device.items() if key != "type" } action = { key: value for key, value in legacy_action.action.items() if key != "type" } try: handler = legacy_actions[(device_type, action_type)] except KeyError: raise OperationNotSupported( f"Unsupported action {action_type} for device_type {device_type}" ) action = handler(device, action) if action is not None: yield action ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1611808575.9122841 arsenic-21.8/src/arsenic/subprocess.py0000644000000000000000000000713500000000000014742 0ustar00import abc import asyncio import os import subprocess import sys from typing import List, TypeVar from asyncio.subprocess import DEVNULL, PIPE from structlog import get_logger log = get_logger() P = TypeVar("P") def check_event_loop(): if sys.platform == "win32" and isinstance( asyncio.get_event_loop(), asyncio.SelectorEventLoop ): raise ValueError( "SelectorEventLoop is not supported on Windows, use asyncio.ProactorEventLoop instead." ) class BaseSubprocessImpl(metaclass=abc.ABCMeta): @abc.abstractmethod async def run_process(self, cmd: List[str]) -> str: raise NotImplementedError() @abc.abstractmethod async def start_process(self, cmd: List[str], log_file) -> P: pass @abc.abstractmethod async def stop_process(self, process: P) -> None: pass class AsyncioSubprocessImpl(BaseSubprocessImpl): async def run_process(self, cmd: List[str]) -> str: check_event_loop() process = await asyncio.create_subprocess_exec( *cmd, stdout=PIPE, stderr=PIPE, stdin=DEVNULL ) out, err = await process.communicate() if process.returncode != 0: raise Exception(err) else: return out.decode("utf-8") async def start_process(self, cmd: List[str], log_file): check_event_loop() if log_file is os.devnull: log_file = DEVNULL return await asyncio.create_subprocess_exec( *cmd, stdout=log_file, stderr=log_file, stdin=DEVNULL ) async def stop_process(self, process): process.terminate() try: await asyncio.wait_for(process.communicate(), 1) except asyncio.futures.TimeoutError: process.kill() try: await asyncio.wait_for(process.communicate(), 1) except asyncio.futures.TimeoutError: log.warn("could not terminate process", process=process, impl=self) class ThreadedSubprocessImpl(BaseSubprocessImpl): async def run_process(self, cmd: List[str]): return await asyncio.get_event_loop().run_in_executor( None, self._run_process, cmd ) def _run_process(self, cmd): return subprocess.check_output( cmd, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL ).decode("utf-8") async def start_process(self, cmd: List[str], log_file): if log_file is os.devnull: log_file = subprocess.DEVNULL return await asyncio.get_event_loop().run_in_executor( None, self._start_process, cmd, log_file ) def _start_process(self, cmd: List[str], log_file): return subprocess.Popen( cmd, stdout=log_file, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL ) async def stop_process(self, process): return await asyncio.get_event_loop().run_in_executor( None, self._stop_process, process ) def _stop_process(self, process: subprocess.Popen): process.terminate() try: process.communicate(timeout=1) except subprocess.TimeoutExpired: process.kill() try: process.communicate(timeout=1) except subprocess.TimeoutExpired: log.warn("could not terminate process", process=process, impl=self) def get_subprocess_impl() -> BaseSubprocessImpl: if sys.platform == "win32": if isinstance(asyncio.get_event_loop(), asyncio.SelectorEventLoop): return ThreadedSubprocessImpl() else: return AsyncioSubprocessImpl() else: return AsyncioSubprocessImpl() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1599540680.8241165 arsenic-21.8/src/arsenic/utils.py0000644000000000000000000000126200000000000013705 0ustar00import socket from typing import Union import attr def free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind(("0.0.0.0", 0)) sock.listen(5) return sock.getsockname()[1] def px_to_number(value: str) -> Union[int, float]: original = value if value.endswith("px"): value = value[:-2] if value.isdigit(): return int(value) try: return float(value) except ValueError: raise ValueError(f"{original!r} is not an number or px value") @attr.s class Rect: x: float = attr.ib() y: float = attr.ib() width: float = attr.ib() height: float = attr.ib() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1610416364.0138726 arsenic-21.8/src/arsenic/webdriver.py0000644000000000000000000000550000000000000014535 0ustar00import asyncio from typing import Awaitable, Callable, List, Any, Union, Type import time from arsenic.browsers import Browser from arsenic.connection import Connection from arsenic.errors import ArsenicError, ArsenicTimeout, SessionStartError from arsenic.session import Session class SessionContext: def __init__(self, driver: "WebDriver", browser: Browser, bind: str): self.driver = driver self.browser = browser self.bind = bind self.session: Session = None async def __aenter__(self) -> Session: self.session = await self.driver.new_session(self.browser, self.bind) return self.session async def __aexit__(self, exc_type, exc_val, exc_tb): await self.session.close() self.session = None TClosers = List[Callable[..., Awaitable[None]]] class WebDriver: def __init__(self, connection: Connection, closers: TClosers): self.connection = connection self.closers = closers def session(self, browser: Browser, bind="") -> SessionContext: return SessionContext(self, browser, bind) async def new_session(self, browser: Browser, bind="") -> Session: status, response = await self.connection.request( url="/session", method="POST", data={"capabilities": {"alwaysMatch": browser.capabilities}}, ) original_response = response if "sessionId" not in response: response = response["value"] if "sessionId" not in response: if "error" in original_response: err_resp = original_response elif "error" in response: err_resp = response else: raise SessionStartError("Unknown", "Unknown", original_response) raise SessionStartError( err_resp["error"], err_resp.get("message", ""), original_response ) session_id = response["sessionId"] return browser.session_class( connection=self.connection.prefixed(f"/session/{session_id}"), bind=bind, wait=self.wait, driver=self, browser=browser, ) async def close(self): for closer in reversed(self.closers): await closer() async def wait( self, timeout: Union[float, int], func: Callable[[], Awaitable[Any]], *exceptions: Exception, ) -> Any: deadline = time.time() + timeout err = None while deadline > time.time(): try: result = await func() if result: return result else: await asyncio.sleep(0.2) except exceptions as exc: err = exc await asyncio.sleep(0.2) raise ArsenicTimeout() from err ././@PaxHeader0000000000000000000000000000003200000000000010210 xustar0026 mtime=1629686752.77384 arsenic-21.8/setup.py0000644000000000000000000000615400000000000011477 0ustar00# -*- coding: utf-8 -*- from setuptools import setup package_dir = \ {'': 'src'} packages = \ ['arsenic'] package_data = \ {'': ['*']} install_requires = \ ['aiohttp>=3', 'attrs>=17.4.0', 'structlog>=20.1.0,<21.0.0'] entry_points = \ {'console_scripts': ['arsenic-check-ie11 = ' 'arsenic.helpers:check_ie11_environment_cli', 'arsenic-configure-ie11 = ' 'arsenic.helpers:configure_ie11_environment_cli']} setup_kwargs = { 'name': 'arsenic', 'version': '21.8', 'description': 'Asynchronous WebDriver client', 'long_description': "# Async Webdriver\n\n[![CircleCI](https://circleci.com/gh/HDE/arsenic/tree/main.svg?style=svg)](https://circleci.com/gh/HDE/arsenic/tree/main) [![Documentation Status](https://readthedocs.org/projects/arsenic/badge/?version=latest)](http://arsenic.readthedocs.io/en/latest/?badge=latest)\n[![BrowserStack Status](https://automate.browserstack.com/badge.svg?badge_key=QmtNVHFnWWRFSEVUdTBZNWU5NGMraVorWVltazFqRk1VNWRydW5FRXU2dz0tLVhoTlFuK2tZUTJ1UGx0UmZaWjg4R1E9PQ==--35ef3d28fbf8ea24ee7fa2a435f9271fbaaf85d4)](https://automate.browserstack.com/public-build/QmtNVHFnWWRFSEVUdTBZNWU5NGMraVorWVltazFqRk1VNWRydW5FRXU2dz0tLVhoTlFuK2tZUTJ1UGx0UmZaWjg4R1E9PQ==--35ef3d28fbf8ea24ee7fa2a435f9271fbaaf85d4)\n[![Appveyor status](https://ci.appveyor.com/api/projects/status/8l0koom7h93y1f9q?svg=true)](https://ci.appveyor.com/project/ojii/arsenic)\n[![PyPI version](https://badge.fury.io/py/arsenic.svg)](https://badge.fury.io/py/arsenic)\n[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black)\n[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)\n\n\nAsynchronous webdriver client built on asyncio.\n\n\n## Quickstart\n\nLet's run a local Firefox instance.\n\n\n```python\n\nfrom arsenic import get_session\nfrom arsenic.browsers import Firefox\nfrom arsenic.services import Geckodriver\n\n\nasync def example():\n # Runs geckodriver and starts a firefox session\n async with get_session(Geckodriver(), Firefox()) as session:\n # go to example.com\n await session.get('http://example.com')\n # wait up to 5 seconds to get the h1 element from the page\n h1 = await session.wait_for_element(5, 'h1')\n # print the text of the h1 element\n print(await h1.get_text())\n```\n\nFor more information, check [the documentation](https://arsenic.readthedocs.io/)\n\n## CI Supported by Browserstack\n\nContinuous integration for certain browsers is generously provided by [Browserstack](http://browserstack.com).\n\n[![Browserstack](./.circleci/browserstack-logo.png)](http://browserstack.com/)\n", 'author': 'Jonas Obrist', 'author_email': 'jonas.obrist@hennge.com', 'maintainer': None, 'maintainer_email': None, 'url': 'https://github.com/HDE/arsenic', 'package_dir': package_dir, 'packages': packages, 'package_data': package_data, 'install_requires': install_requires, 'entry_points': entry_points, 'python_requires': '>=3.7,<4.0', } setup(**setup_kwargs) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1629686752.774524 arsenic-21.8/PKG-INFO0000644000000000000000000000634600000000000011065 0ustar00Metadata-Version: 2.1 Name: arsenic Version: 21.8 Summary: Asynchronous WebDriver client Home-page: https://github.com/HDE/arsenic License: Apache-2.0 Keywords: asyncio Author: Jonas Obrist Author-email: jonas.obrist@hennge.com Requires-Python: >=3.7,<4.0 Classifier: Development Status :: 5 - Production/Stable Classifier: Framework :: AsyncIO Classifier: License :: OSI Approved :: Apache Software License Classifier: Operating System :: MacOS :: MacOS X Classifier: Operating System :: Microsoft :: Windows Classifier: Operating System :: POSIX :: Linux Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Topic :: Software Development :: Testing Requires-Dist: aiohttp (>=3) Requires-Dist: attrs (>=17.4.0) Requires-Dist: structlog (>=20.1.0,<21.0.0) Project-URL: Documentation, https://arsenic.readthedocs.io/en/latest/ Project-URL: Repository, https://github.com/HDE/arsenic Description-Content-Type: text/markdown # Async Webdriver [![CircleCI](https://circleci.com/gh/HDE/arsenic/tree/main.svg?style=svg)](https://circleci.com/gh/HDE/arsenic/tree/main) [![Documentation Status](https://readthedocs.org/projects/arsenic/badge/?version=latest)](http://arsenic.readthedocs.io/en/latest/?badge=latest) [![BrowserStack Status](https://automate.browserstack.com/badge.svg?badge_key=QmtNVHFnWWRFSEVUdTBZNWU5NGMraVorWVltazFqRk1VNWRydW5FRXU2dz0tLVhoTlFuK2tZUTJ1UGx0UmZaWjg4R1E9PQ==--35ef3d28fbf8ea24ee7fa2a435f9271fbaaf85d4)](https://automate.browserstack.com/public-build/QmtNVHFnWWRFSEVUdTBZNWU5NGMraVorWVltazFqRk1VNWRydW5FRXU2dz0tLVhoTlFuK2tZUTJ1UGx0UmZaWjg4R1E9PQ==--35ef3d28fbf8ea24ee7fa2a435f9271fbaaf85d4) [![Appveyor status](https://ci.appveyor.com/api/projects/status/8l0koom7h93y1f9q?svg=true)](https://ci.appveyor.com/project/ojii/arsenic) [![PyPI version](https://badge.fury.io/py/arsenic.svg)](https://badge.fury.io/py/arsenic) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) Asynchronous webdriver client built on asyncio. ## Quickstart Let's run a local Firefox instance. ```python from arsenic import get_session from arsenic.browsers import Firefox from arsenic.services import Geckodriver async def example(): # Runs geckodriver and starts a firefox session async with get_session(Geckodriver(), Firefox()) as session: # go to example.com await session.get('http://example.com') # wait up to 5 seconds to get the h1 element from the page h1 = await session.wait_for_element(5, 'h1') # print the text of the h1 element print(await h1.get_text()) ``` For more information, check [the documentation](https://arsenic.readthedocs.io/) ## CI Supported by Browserstack Continuous integration for certain browsers is generously provided by [Browserstack](http://browserstack.com). [![Browserstack](./.circleci/browserstack-logo.png)](http://browserstack.com/)