update: pivot to http protocol
This commit is contained in:
@@ -4,16 +4,12 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "common"
|
||||
description = "Reusable code stubs"
|
||||
version = "0.1.14"
|
||||
description = "Commonly reusable code"
|
||||
version = "0.1.15"
|
||||
requires-python = ">=3.13"
|
||||
authors = [
|
||||
{ name="BreakerBear", email="breakerbear@autistic.men" },
|
||||
]
|
||||
dependencies = [
|
||||
"trio",
|
||||
"trio-websocket",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
selenium = ["selenium"]
|
||||
|
||||
@@ -31,6 +31,7 @@ class ActionFlow:
|
||||
self.on[index] = bool(self.actions[index].prepare())
|
||||
|
||||
def react(self):
|
||||
while any(self.on):
|
||||
for index in self.indices.values():
|
||||
if (self.on[index]):
|
||||
self.on[index] = None
|
||||
|
||||
@@ -1,30 +1,11 @@
|
||||
from common.jsonrpc2.server import (
|
||||
Request,
|
||||
Response,
|
||||
Error,
|
||||
History,
|
||||
Options,
|
||||
define,
|
||||
remove,
|
||||
run,
|
||||
RequestHandler,
|
||||
ServiceProvider,
|
||||
)
|
||||
|
||||
def prelude() -> str:
|
||||
with open(__file__.replace('__init__.py', 'client.js'), 'r') as file:
|
||||
from common.jsonrpc2.server import server
|
||||
assert server is not None
|
||||
endpoint = server.listeners[0]
|
||||
url = f'ws://{endpoint.address}:{endpoint.port}'
|
||||
return file.read() % url
|
||||
|
||||
__all__ = [
|
||||
'Request',
|
||||
'Response',
|
||||
'Error',
|
||||
'History',
|
||||
'Options',
|
||||
'define',
|
||||
'remove',
|
||||
'run',
|
||||
'prelude',
|
||||
'RequestHandler',
|
||||
'ServiceProvider',
|
||||
]
|
||||
|
||||
@@ -1,55 +1,45 @@
|
||||
const CONNECTION = new WebSocket("%s");
|
||||
const QUEUE = new Array();
|
||||
export default new class extends Function {
|
||||
constructor() {
|
||||
super('...args', 'return this.get(...args)');
|
||||
return this.bind(this);
|
||||
}
|
||||
|
||||
CONNECTION.addEventListener('message', (e) => {
|
||||
let message = JSON.parse(e.data);
|
||||
let index = QUEUE.findIndex(item => item.id === message.id);
|
||||
all(selectors) { return document.querySelectorAll(selectors) }
|
||||
get(selectors) { return document.querySelector(selectors) }
|
||||
set(selectors, event, listener) { return this.get(selectors).addEventListener(event, listener) }
|
||||
};
|
||||
|
||||
if (index === -1) throw Error('Invalid message ID: ' + message.id);
|
||||
let promise = QUEUE.splice(index, 1)[0];
|
||||
|
||||
if (message.error) promise.reject(message.error);
|
||||
else promise.resolve(message.result);
|
||||
});
|
||||
|
||||
while (CONNECTION.readyState !== CONNECTION.OPEN) {
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
const LogRecord = {
|
||||
format: (record) => {
|
||||
let ms = record.created * 1000 - new Date().getTimezoneOffset() * 60000;
|
||||
export const LogRecord = {
|
||||
format: (log) => {
|
||||
let ms = (log.created - new Date().getTimezoneOffset() * 60) * 1000;
|
||||
let date = new Date(ms).toISOString().slice(0, 16).replace("T", " ");
|
||||
let line = `[${date}] [${record.levelname}] [${record.name}] ${record.msg}`;
|
||||
let message = [line, record.exc_text, record.stack_info].filter(Boolean).join('\n');
|
||||
let line = `[${date}] [${log.levelname}] [${log.name}] ${log.msg}`;
|
||||
let message = [line, log.exc_text, log.stack_info].filter(Boolean).join('\n');
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
const RemoteProceduralCall = {
|
||||
export const Rpc2 = {
|
||||
notify: async (method, ...args) => {
|
||||
let request = { method };
|
||||
if (args.length > 0) request.params = args;
|
||||
let data = JSON.stringify(request);
|
||||
CONNECTION.send(data);
|
||||
let body = JSON.stringify(request);
|
||||
await fetch('/', { method: 'POST', body });
|
||||
},
|
||||
invoke: async (method, ...args) => {
|
||||
let id = Math.floor(Math.random() * 1000000000);
|
||||
let request = { method, id };
|
||||
if (args.length > 0) request.params = args;
|
||||
|
||||
let data = JSON.stringify(request);
|
||||
CONNECTION.send(data);
|
||||
let body = JSON.stringify(request);
|
||||
let response = await fetch('/', { method: 'POST', body });
|
||||
let json = await response.json();
|
||||
|
||||
let [resolve, reject] = [null, null];
|
||||
let promise = new Promise((a, b) => { resolve = a; reject = b; });
|
||||
QUEUE.push({ resolve, reject, id });
|
||||
|
||||
let result = await promise;
|
||||
return result;
|
||||
if (json.error) {
|
||||
let message = `(${json.error.code}) ${json.error.message}`;
|
||||
if (json.error.data) message += `: ${json.error.data}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return json.result;
|
||||
},
|
||||
};
|
||||
|
||||
window.LogRecord = LogRecord;
|
||||
window.Rpc2 = RemoteProceduralCall;
|
||||
main(...arguments); // user-defined code
|
||||
@@ -1,53 +1,50 @@
|
||||
import json
|
||||
import logging
|
||||
import trio
|
||||
import trio_websocket as ws
|
||||
import dataclasses
|
||||
from enum import Enum
|
||||
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
||||
from typing import Self, Callable, Any
|
||||
from itertools import repeat
|
||||
from threading import Thread
|
||||
|
||||
handlers: dict[str, Callable] = dict()
|
||||
connection, server = None, None
|
||||
|
||||
class Request[T]:
|
||||
def __init__(self, method: str, params = None, id = None):
|
||||
if not isinstance(method, str): raise TypeError()
|
||||
if not isinstance(id, (str, int)) and id is not None: raise TypeError()
|
||||
if not isinstance(method, str): raise TypeError('method')
|
||||
if not isinstance(id, (str, int)) and id is not None: raise TypeError('id')
|
||||
self.id = id
|
||||
self.method = method
|
||||
self.params = params
|
||||
|
||||
class ParamsError(Exception):
|
||||
pass
|
||||
class ParamsError(Exception): pass
|
||||
class NotFound(Exception): pass
|
||||
class Invalid(Exception): pass
|
||||
|
||||
@classmethod
|
||||
def load(cls, raw: bytes) -> Self:
|
||||
data = json.loads(raw)
|
||||
return cls(**data)
|
||||
def load(cls, data: str) -> Self:
|
||||
result = json.loads(data)
|
||||
return cls(**result)
|
||||
|
||||
def fulfill(self) -> T:
|
||||
def handle(self, handlers: dict[str, Any]) -> T:
|
||||
args = self.params
|
||||
handler: Callable[..., T] = handlers[self.method]
|
||||
handler: Callable[..., T] = handlers.get(self.method)
|
||||
|
||||
if handler is None: raise self.NotFound(self.method)
|
||||
argcount = handler.__code__.co_argcount
|
||||
varnames = handler.__code__.co_varnames
|
||||
argnames = handler.__code__.co_varnames[:argcount]
|
||||
|
||||
if self.params is None:
|
||||
if argcount > 0: raise self.ParamsError(None)
|
||||
if argcount > 0: raise self.ParamsError('Too less')
|
||||
return handler()
|
||||
|
||||
if isinstance(args, list):
|
||||
if len(args) != argcount: raise self.ParamsError(args)
|
||||
return handler(*args)
|
||||
|
||||
if isinstance(args, dict):
|
||||
if args.keys() != set(varnames): raise self.ParamsError(args)
|
||||
if args.keys() != set(argnames): raise self.ParamsError(args)
|
||||
return handler(**args)
|
||||
|
||||
raise TypeError(repr(args))
|
||||
raise self.Invalid('Arguments unacceptable')
|
||||
|
||||
class Response[T]:
|
||||
def __init__(self, id: str|int, inner: T):
|
||||
def __init__(self, id: str|int|None, inner: T):
|
||||
self.id = id
|
||||
self.inner = inner
|
||||
|
||||
@@ -80,7 +77,7 @@ class Error:
|
||||
result['code'] = self.code.value
|
||||
result['message'] = self.message()
|
||||
if self.data is not None:
|
||||
result['data'] = self.data
|
||||
result['data'] = str(self.data)
|
||||
return result
|
||||
|
||||
class History(logging.Handler):
|
||||
@@ -100,65 +97,98 @@ class History(logging.Handler):
|
||||
self.records.clear()
|
||||
return copy
|
||||
|
||||
class Options:
|
||||
def __init__(self):
|
||||
self.listen = '127.0.0.1'
|
||||
self.port = 0
|
||||
self.max_message_size = 125_000_000
|
||||
class Server(ThreadingHTTPServer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.encoding: str
|
||||
self.handlers = dict()
|
||||
|
||||
async def handler(request: ws.WebSocketRequest):
|
||||
global connection
|
||||
logger = logging.getLogger('jsonrpc2')
|
||||
websocket = await request.accept()
|
||||
class RequestHandler(BaseHTTPRequestHandler):
|
||||
server: Server
|
||||
protocol_version = 'HTTP/1.1'
|
||||
|
||||
if connection is None:
|
||||
connection = websocket
|
||||
else:
|
||||
await websocket.aclose(code=1000, reason="Engaged")
|
||||
return
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
for mid, res, err, exc in repeat((None, None, None, None)):
|
||||
def do_GET(self):
|
||||
try:
|
||||
message = await connection.get_message()
|
||||
inbound = Request.load(message)
|
||||
mid = inbound.id
|
||||
res = inbound.fulfill()
|
||||
except json.decoder.JSONDecodeError as e:
|
||||
err = Error(Error.Code.PARSE_ERROR)
|
||||
exc = e
|
||||
except Request.ParamsError as e:
|
||||
err = Error(Error.Code.INVALID_PARAMS)
|
||||
exc = e
|
||||
except TypeError as e:
|
||||
err = Error(Error.Code.INVALID_REQUEST)
|
||||
exc = e
|
||||
except KeyError as e:
|
||||
err = Error(Error.Code.METHOD_NOT_FOUND)
|
||||
err.data = str(e).strip("'")
|
||||
exc = e
|
||||
except ws.ConnectionClosed as e:
|
||||
logger.critical('Going away', exc_info=e)
|
||||
return
|
||||
match self.headers.get('Sec-Fetch-Dest'):
|
||||
case 'script':
|
||||
file, mime = __file__[:__file__.rfind('server.py')] + 'client.js', 'text/javascript'
|
||||
case _:
|
||||
file, mime = './index.html', 'text/html'
|
||||
response = open(file, mode='rb').read()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', '%s; charset=%s' % (mime, self.server.encoding))
|
||||
except FileNotFoundError:
|
||||
response = b'File not found'
|
||||
self.send_response(404)
|
||||
except Exception as e:
|
||||
err = Error(Error.Code.INTERNAL_ERROR)
|
||||
exc = e
|
||||
response = b'Internal error: %b' % str(e).encode(self.server.encoding)
|
||||
self.send_response(500)
|
||||
|
||||
if err is not None: logger.error(err.message(), exc_info=exc)
|
||||
if mid is not None: await connection.send_message(str(Response(mid, err or res)))
|
||||
self.send_header('Content-Length', str(len(response)))
|
||||
self.end_headers()
|
||||
self.wfile.write(response)
|
||||
|
||||
async def backend(options: Options):
|
||||
global server
|
||||
listeners = await trio.open_tcp_listeners(port=options.port, host=options.listen)
|
||||
server = ws.WebSocketServer(handler, listeners, max_message_size=options.max_message_size)
|
||||
await server.run()
|
||||
def do_POST(self, request=None):
|
||||
try:
|
||||
size = int(self.headers.get('Content-Length', '0'))
|
||||
data = self.rfile.read(size)
|
||||
request = Request.load(data)
|
||||
message = request.handle(self.server.handlers)
|
||||
except json.JSONDecodeError as e:
|
||||
message = Error(Error.Code.PARSE_ERROR, e)
|
||||
except Request.ParamsError as e:
|
||||
message = Error(Error.Code.INVALID_PARAMS, e)
|
||||
except Request.Invalid as e:
|
||||
message = Error(Error.Code.INVALID_REQUEST, e)
|
||||
except Request.NotFound as e:
|
||||
message = Error(Error.Code.METHOD_NOT_FOUND, e)
|
||||
except Exception as e:
|
||||
message = Error(Error.Code.INTERNAL_ERROR, e)
|
||||
|
||||
def define(method: str, handler: Callable[..., Any]):
|
||||
if method in handlers: raise KeyError(method)
|
||||
handlers[method] = handler
|
||||
response = Response(request.id if request is not None else None, message)
|
||||
buffer = bytes(str(response), encoding=self.server.encoding)
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Length', str(len(buffer)))
|
||||
self.end_headers()
|
||||
self.wfile.write(buffer)
|
||||
|
||||
def remove(method: str) -> Callable[..., Any]:
|
||||
return handlers.pop(method)
|
||||
class ServiceProvider:
|
||||
def __init__(self, options):
|
||||
self.options = dataclasses.asdict(options)
|
||||
self.server = Server((self.options['host'], self.options['port']), self.options['handler'])
|
||||
self.server.encoding = self.options['encoding']
|
||||
|
||||
def run(options: Options):
|
||||
thread = Thread(target=lambda: trio.run(backend, options), daemon=True)
|
||||
@classmethod
|
||||
def default(cls):
|
||||
import sys, _thread as t
|
||||
logger = logging.getLogger()
|
||||
history = History()
|
||||
logger.addHandler(history)
|
||||
opts = cls.Options()
|
||||
self = cls(opts)
|
||||
self.set('history', lambda: history.truncate())
|
||||
self.set('exit', lambda: t.interrupt_main() or sys.exit(0))
|
||||
return self
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Options:
|
||||
host : str = '127.0.0.1'
|
||||
port : int = 0
|
||||
handler : type = RequestHandler
|
||||
encoding : str = 'UTF-8'
|
||||
interval : float = 0.2
|
||||
|
||||
def set(self, method: str, handler: Callable[..., Any]):
|
||||
if method in self.server.handlers: raise KeyError(method)
|
||||
self.server.handlers[method] = handler
|
||||
|
||||
def pop(self, method: str) -> Callable[..., Any]:
|
||||
return self.server.handlers.pop(method)
|
||||
|
||||
def run(self) -> str:
|
||||
thread = Thread(target=lambda: self.server.serve_forever(self.options['interval']), daemon=True)
|
||||
thread.start()
|
||||
return 'http://%s:%d' % self.server.server_address
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
from common.utils.timer import Timer
|
||||
from common.utils.selenium import until, sleep, locate, click, setup
|
||||
from common.utils.selenium import (
|
||||
until,
|
||||
sleep,
|
||||
locate,
|
||||
click,
|
||||
setup,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'Timer',
|
||||
'until',
|
||||
'sleep',
|
||||
'locate',
|
||||
|
||||
@@ -7,24 +7,25 @@ from selenium.webdriver.remote.webelement import WebElement
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.support.wait import WebDriverWait
|
||||
from selenium.common.exceptions import StaleElementReferenceException, TimeoutException
|
||||
from selenium.common.exceptions import StaleElementReferenceException, NoAlertPresentException, TimeoutException
|
||||
|
||||
driver: WebDriver|None = None
|
||||
attempts, timeout, interval, identity = int(), float(), float(), None
|
||||
identity, parameters = None, dict()
|
||||
|
||||
def until(condition: typing.Callable[[WebDriver], bool], watch=True):
|
||||
try:
|
||||
WebDriverWait(driver, timeout).until(condition)
|
||||
WebDriverWait(driver, parameters['timeout']).until(condition)
|
||||
except (TimeoutException, StaleElementReferenceException):
|
||||
pass
|
||||
if watch: WebDriverWait(driver, timeout).until_not(condition)
|
||||
if watch: WebDriverWait(driver, parameters['timeout']).until_not(condition)
|
||||
|
||||
def sleep(seconds: float):
|
||||
from common.jsonrpc2.server import connection
|
||||
assert connection is not None and connection.closed is None
|
||||
try: driver.switch_to.alert
|
||||
except NoAlertPresentException: pass
|
||||
except: raise KeyboardInterrupt()
|
||||
|
||||
try: WebDriverWait(driver, seconds, seconds).until(lambda _: False)
|
||||
except: pass
|
||||
except TimeoutException: pass
|
||||
|
||||
def locate(selector: str, wait=True, condition=True) -> WebElement:
|
||||
while True:
|
||||
@@ -33,12 +34,12 @@ def locate(selector: str, wait=True, condition=True) -> WebElement:
|
||||
if not wait: return driver.find_element(*locator)
|
||||
|
||||
presence = EC.presence_of_element_located(locator)
|
||||
element = WebDriverWait(driver, timeout).until(presence, 'Timeout')
|
||||
element = WebDriverWait(driver, parameters['timeout']).until(presence, 'Timeout')
|
||||
driver.execute_script("arguments[0].scrollIntoView({ block: 'center', inline: 'nearest' });", element)
|
||||
|
||||
if condition is not None and condition != False:
|
||||
predicate = condition if callable(condition) else EC.visibility_of_element_located
|
||||
element = WebDriverWait(driver, timeout).until(predicate(locator), 'Timeout')
|
||||
element = WebDriverWait(driver, parameters['timeout']).until(predicate(locator), 'Timeout')
|
||||
|
||||
return element
|
||||
except StaleElementReferenceException:
|
||||
@@ -49,11 +50,12 @@ def click(selector: str|WebElement, wait=True, condition=False):
|
||||
error = False
|
||||
|
||||
element = locate(selector, wait, predicate) if isinstance(selector, str) else selector
|
||||
counter = lambda: int(element.get_attribute(identity) or 0)
|
||||
counter = lambda: int(element.get_attribute(identity) or '0')
|
||||
value = counter()
|
||||
driver.execute_script("arguments[0].addEventListener('click', () => arguments[0].setAttribute(arguments[1], arguments[2] + 1));", element, identity, value)
|
||||
driver.execute_script("window.__%s__ = () => { arguments[0].setAttribute('%s', arguments[1] + 1) }" % ((identity,) * 2), element, value)
|
||||
driver.execute_script("arguments[0].addEventListener('click', __%s__);" % identity, element)
|
||||
|
||||
for _ in range(attempts):
|
||||
for _ in range(parameters['attempts']):
|
||||
try:
|
||||
if not error: element.click()
|
||||
else: driver.execute_script("arguments[0].click();", element)
|
||||
@@ -63,15 +65,16 @@ def click(selector: str|WebElement, wait=True, condition=False):
|
||||
error = True
|
||||
continue
|
||||
try:
|
||||
WebDriverWait(driver, interval).until(lambda _: counter() > value)
|
||||
WebDriverWait(driver, parameters['interval']).until(lambda _: counter() > value)
|
||||
break
|
||||
except TimeoutException: continue
|
||||
except: break
|
||||
|
||||
def setup(a: WebDriver, b: int, c: float, d: float):
|
||||
global identity, driver, attempts, timeout, interval
|
||||
try: driver.execute_script("arguments[0].removeEventListener('click', __%s__);" % identity, element)
|
||||
except: pass
|
||||
|
||||
def setup(a: WebDriver, b: dict):
|
||||
global identity, driver
|
||||
identity = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8))
|
||||
driver = a
|
||||
attempts = b
|
||||
timeout = c
|
||||
interval = d
|
||||
parameters.update(b)
|
||||
|
||||
Reference in New Issue
Block a user