diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1350315..be854cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,11 +17,12 @@ jobs: - 3.8 - 3.9 - "3.10" - - pypy-3.7 + - "3.11" + - pypy-3.9 steps: - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install tox @@ -37,27 +38,3 @@ jobs: - uses: codecov/codecov-action@v1 with: file: ./coverage.xml - - autobahn: - runs-on: ubuntu-18.04 - strategy: - matrix: - side: [client, server] - - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - with: - python-version: 3.8 - - name: Install tox - run: | - python -m pip install --upgrade pip setuptools - pip install --upgrade tox - - name: Initialize tox envs - run: | - tox --parallel auto --notest -e autobahn - - name: Run autobahn - run: | - tox --parallel 0 -e autobahn - env: - SIDE: ${{ matrix.side }} diff --git a/.gitignore b/.gitignore index 75e1467..f3d487f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,3 @@ coverage.xml docs/build build dist - -compliance/reports -compliance/auto-tests-server-config.json -compliance/auto-tests-client-config.json -compliance/autobahntestsuite-venv/ diff --git a/MANIFEST.in b/MANIFEST.in index 0a17fc7..cf98024 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,13 +1,8 @@ graft src/wsproto -graft compliance graft example graft docs graft test graft bench prune docs/build -prune compliance/reports -prune compliance/auto-tests-server-config.json -prune compliance/auto-tests-client-config.json -prune compliance/autobahntestsuite-venv include README.rst LICENSE CHANGELOG.rst tox.ini global-exclude *.pyc *.pyo *.swo *.swp *.map *.yml *.DS_Store .coverage diff --git a/README.rst b/README.rst index beb9100..39c2ef8 100644 --- a/README.rst +++ b/README.rst @@ -91,37 +91,6 @@ And wsproto will issue events if the data contains any WebSocket messages or sta Take a look at our docs for a `full list of events `! -Testing -======= - -It passes the autobahn test suite completely and strictly in both client and -server modes and using permessage-deflate. - -If you want to run the compliance tests, go into the compliance directory and -then to test client mode, in one shell run the Autobahn test server: - -.. code-block:: console - - $ wstest -m fuzzingserver -s ws-fuzzingserver.json - -And in another shell run the test client: - -.. code-block:: console - - $ python test_client.py - -And to test server mode, run the test server: - -.. code-block:: console - - $ python test_server.py - -And in another shell run the Autobahn test client: - -.. code-block:: console - - $ wstest -m fuzzingclient -s ws-fuzzingclient.json - Documentation ============= diff --git a/compliance/run-autobahn-tests.py b/compliance/run-autobahn-tests.py deleted file mode 100644 index 7d9c57b..0000000 --- a/compliance/run-autobahn-tests.py +++ /dev/null @@ -1,253 +0,0 @@ -# Things that would be nice: -# - less hard-coding of paths here - -import argparse -import copy -import errno -import json -import os.path -import socket -import subprocess -import sys -import time -from typing import Dict, List, Tuple - -PORT = 8642 - -CLIENT_CONFIG = { - "options": {"failByDrop": False}, - "outdir": "./reports/servers", - "servers": [ - { - "agent": "wsproto", - "url": f"ws://localhost:{PORT}", - "options": {"version": 18}, - } - ], - "cases": ["*"], - "exclude-cases": ["13.3.*", "13.5.*", "13.7.*"], - "exclude-agent-cases": {}, -} - -SERVER_CONFIG = { - "url": f"ws://localhost:{PORT}", - "options": {"failByDrop": False}, - "outdir": "./reports/clients", - "webport": 8080, - "cases": ["*"], - "exclude-cases": ["13.3.*", "13.5.*", "13.7.*"], - "exclude-agent-cases": {}, -} - -CASES = { - "all": ["*"], - "fast": [ - # The core functionality tests - *[f"{i}.*" for i in range(1, 12)], - # Compression tests -- in each section, the tests get progressively - # slower until they're taking 10s of seconds apiece. And it's - # mostly stress tests, without much extra coverage to show for - # it. (Weird trick: autobahntestsuite treats these as regexps - # except that . is quoted and * becomes .*) - "12.*.[1234]$", - "13.*.[1234]$", - # At one point these were catching a unique bug that none of the - # above were -- they're relatively quick and involve - # fragmentation. - "12.1.11", - "12.1.12", - "13.1.11", - "13.1.12", - ], -} - - -def say(*args: object) -> None: - print("run-autobahn-tests.py:", *args) - - -def setup_venv() -> None: - if not os.path.exists("autobahntestsuite-venv"): - say("Creating Python 2.7 environment and installing autobahntestsuite") - subprocess.check_call( - ["virtualenv", "-p", "python2.7", "autobahntestsuite-venv"] - ) - subprocess.check_call( - ["autobahntestsuite-venv/bin/pip", "install", "autobahntestsuite>=0.8.0"] - ) - - -def wait_for_listener(port: int) -> None: - while True: - sock = socket.socket() - try: - sock.connect(("localhost", port)) - except OSError as exc: - if exc.errno == errno.ECONNREFUSED: - time.sleep(0.01) - else: - raise - else: - return - finally: - sock.close() - - -def coverage(command: List[str], coverage_settings: Dict[str, str]) -> List[str]: - if not coverage_settings["enabled"]: - return [sys.executable] + command - - return [ - sys.executable, - "-m", - "coverage", - "run", - "--include", - coverage_settings["wsproto-path"], - ] + command - - -def summarize(report_path: str) -> Tuple[int, int]: - with open(os.path.join(report_path, "index.json")) as f: - result_summary = json.load(f)["wsproto"] - failed = 0 - total = 0 - PASS = {"OK", "INFORMATIONAL"} - for test_name, results in sorted(result_summary.items()): - total += 1 - if results["behavior"] not in PASS or results["behaviorClose"] not in PASS: - say("FAIL:", test_name, results) - say("Details:") - with open(os.path.join(report_path, results["reportfile"])) as f: - print(f.read()) - failed += 1 - - speed_ordered = sorted(result_summary.items(), key=lambda kv: -kv[1]["duration"]) - say("Slowest tests:") - for test_name, results in speed_ordered[:5]: - say(" {}: {} seconds".format(test_name, results["duration"] / 1000)) - - return failed, total - - -def run_client_tests( - cases: List[str], coverage_settings: Dict[str, str] -) -> Tuple[int, int]: - say("Starting autobahntestsuite server") - server_config = copy.deepcopy(SERVER_CONFIG) - server_config["cases"] = cases - with open("auto-tests-server-config.json", "w") as f: - json.dump(server_config, f) - server = subprocess.Popen( - [ - "autobahntestsuite-venv/bin/wstest", - "-m", - "fuzzingserver", - "-s", - "auto-tests-server-config.json", - ] - ) - say("Waiting for server to start") - wait_for_listener(PORT) - try: - say("Running wsproto test client") - subprocess.check_call(coverage(["./test_client.py"], coverage_settings)) - # the client doesn't exit until the server closes the connection on the - # /updateReports call, and the server doesn't close the connection until - # after it writes the reports, so there's no race condition here. - finally: - say("Stopping server...") - server.terminate() - server.wait() - - return summarize("reports/clients") - - -def run_server_tests( - cases: List[str], coverage_settings: Dict[str, str] -) -> Tuple[int, int]: - say("Starting wsproto test server") - server = subprocess.Popen(coverage(["./test_server.py"], coverage_settings)) - try: - say("Waiting for server to start") - wait_for_listener(PORT) - - client_config = copy.deepcopy(CLIENT_CONFIG) - client_config["cases"] = cases - with open("auto-tests-client-config.json", "w") as f: - json.dump(client_config, f) - say("Starting autobahntestsuite client") - subprocess.check_call( - [ - "autobahntestsuite-venv/bin/wstest", - "-m", - "fuzzingclient", - "-s", - "auto-tests-client-config.json", - ] - ) - finally: - say("Stopping server...") - # Connection on this port triggers a shutdown - sock = socket.socket() - sock.connect(("localhost", PORT + 1)) - sock.close() - server.wait() - - return summarize("reports/servers") - - -def main() -> None: - if not os.path.exists("test_client.py"): - say("Run me from the compliance/ directory") - sys.exit(2) - coverage_settings = {"coveragerc": "../.coveragerc"} - try: - import wsproto # pylint: disable=import-outside-toplevel - except ImportError: - say("wsproto must be on python path -- set PYTHONPATH or install it") - sys.exit(2) - else: - coverage_settings["wsproto-path"] = os.path.dirname(wsproto.__file__) - - parser = argparse.ArgumentParser() - - parser.add_argument("MODE", help="'client' or 'server'") - # can do e.g. - # --cases='["1.*"]' - parser.add_argument( - "--cases", help="'fast' or 'all' or a JSON list", default="fast" - ) - parser.add_argument("--cov", help="enable coverage", action="store_true") - - args = parser.parse_args() - - coverage_settings["enabled"] = args.cov - cases = args.cases - # pylint: disable=consider-using-get - if cases in CASES: - cases = CASES[cases] - else: - cases = json.loads(cases) - - setup_venv() - - if args.MODE == "client": - failed, total = run_client_tests(cases, coverage_settings) - elif args.MODE == "server": - failed, total = run_server_tests(cases, coverage_settings) - else: - say("Unrecognized mode, try 'client' or 'server'") - sys.exit(2) - - say(f"in {args.MODE.upper()} mode: failed {failed} out of {total} total") - - if failed: - say("Test failed") - sys.exit(1) - else: - say("SUCCESS!") - - -if __name__ == "__main__": - main() diff --git a/compliance/test_client.py b/compliance/test_client.py deleted file mode 100644 index ce43125..0000000 --- a/compliance/test_client.py +++ /dev/null @@ -1,153 +0,0 @@ -import json -import socket -from typing import Optional -from urllib.parse import urlparse - -from wsproto import WSConnection -from wsproto.connection import CLIENT -from wsproto.events import ( - AcceptConnection, - CloseConnection, - Message, - Ping, - Request, - TextMessage, -) -from wsproto.extensions import PerMessageDeflate -from wsproto.frame_protocol import CloseReason - -SERVER = "ws://127.0.0.1:8642" -AGENT = "wsproto" - -CONNECTION_EXCEPTIONS = (ConnectionError, OSError) - - -def get_case_count(server: str) -> int: - uri = urlparse(server + "/getCaseCount") - connection = WSConnection(CLIENT) - sock = socket.socket() - sock.connect((uri.hostname, uri.port or 80)) - - sock.sendall(connection.send(Request(host=uri.netloc, target=uri.path))) - - case_count: Optional[int] = None - while case_count is None: - in_data = sock.recv(65535) - connection.receive_data(in_data) - data = "" - out_data = b"" - for event in connection.events(): - if isinstance(event, TextMessage): - data += event.data - if event.message_finished: - case_count = json.loads(data) - out_data += connection.send( - CloseConnection(code=CloseReason.NORMAL_CLOSURE) - ) - try: - sock.sendall(out_data) - except CONNECTION_EXCEPTIONS: - break - - sock.close() - return case_count - - -def run_case(server: str, case: int, agent: str) -> None: - uri = urlparse(server + "/runCase?case=%d&agent=%s" % (case, agent)) - connection = WSConnection(CLIENT) - sock = socket.socket() - sock.connect((uri.hostname, uri.port or 80)) - - sock.sendall( - connection.send( - Request( - host=uri.netloc, - target=f"{uri.path}?{uri.query}", - extensions=[PerMessageDeflate()], - ) - ) - ) - closed = False - - while not closed: - try: - data: Optional[bytes] = sock.recv(65535) - except CONNECTION_EXCEPTIONS: - data = None - connection.receive_data(data or None) - out_data = b"" - for event in connection.events(): - if isinstance(event, Message): - out_data += connection.send( - Message(data=event.data, message_finished=event.message_finished) - ) - elif isinstance(event, Ping): - out_data += connection.send(event.response()) - elif isinstance(event, CloseConnection): - closed = True - out_data += connection.send(event.response()) - # else: - # print("??", event) - if out_data is None: - break - try: - sock.sendall(out_data) - except CONNECTION_EXCEPTIONS: - closed = True - break - - -def update_reports(server: str, agent: str) -> None: - uri = urlparse(server + "/updateReports?agent=%s" % agent) - connection = WSConnection(CLIENT) - sock = socket.socket() - sock.connect((uri.hostname, uri.port or 80)) - - sock.sendall( - connection.send(Request(host=uri.netloc, target=f"{uri.path}?{uri.query}")) - ) - closed = False - - while not closed: - data = sock.recv(65535) - connection.receive_data(data) - for event in connection.events(): - if isinstance(event, AcceptConnection): - sock.sendall( - connection.send(CloseConnection(code=CloseReason.NORMAL_CLOSURE)) - ) - try: - sock.close() - except CONNECTION_EXCEPTIONS: - pass - finally: - closed = True - - -CASE = None -# 1.1.1 = 1 -# 2.1 = 17 -# 3.1 = 28 -# 4.1.1 = 34 -# 5.1 = 44 -# 6.1.1 = 64 -# 12.1.1 = 304 -# 13.1.1 = 394 - - -def run_tests(server: str, agent: str) -> None: - case_count = get_case_count(server) - if CASE is not None: - print(">>>>> Running test case %d" % CASE) - run_case(server, CASE, agent) - else: - for case in range(1, case_count + 1): - print(">>>>> Running test case %d of %d" % (case, case_count)) - run_case(server, case, agent) - print("\nRan %d cases." % case_count) - update_reports(server, agent) - - -if __name__ == "__main__": - run_tests(SERVER, AGENT) diff --git a/compliance/test_server.py b/compliance/test_server.py deleted file mode 100644 index 5f4b7fe..0000000 --- a/compliance/test_server.py +++ /dev/null @@ -1,84 +0,0 @@ -import select -import socket -from typing import Optional - -from wsproto import WSConnection -from wsproto.connection import ConnectionState, SERVER -from wsproto.events import AcceptConnection, CloseConnection, Message, Ping, Request -from wsproto.extensions import PerMessageDeflate - -count = 0 - - -def new_conn(sock: socket.socket) -> None: - global count - print(f"test_server.py received connection {count}") - count += 1 - ws = WSConnection(SERVER) - closed = False - while not closed: - try: - data: Optional[bytes] = sock.recv(65535) - except OSError: - data = None - - ws.receive_data(data or None) - - outgoing_data = b"" - for event in ws.events(): - if isinstance(event, Request): - outgoing_data += ws.send( - AcceptConnection(extensions=[PerMessageDeflate()]) - ) - elif isinstance(event, Message): - outgoing_data += ws.send( - Message(data=event.data, message_finished=event.message_finished) - ) - elif isinstance(event, Ping): - outgoing_data += ws.send(event.response()) - elif isinstance(event, CloseConnection): - closed = True - if ws.state is not ConnectionState.CLOSED: - outgoing_data += ws.send(event.response()) - - if not data: - closed = True - - try: - sock.sendall(outgoing_data) - except OSError: - closed = True - - sock.close() - - -def start_listener( - host: str = "127.0.0.1", port: int = 8642, shutdown_port: int = 8643 -) -> None: - server = socket.socket() - server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - server.bind((host, port)) - server.listen(1) - shutdown_server = socket.socket() - shutdown_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - shutdown_server.bind((host, shutdown_port)) - shutdown_server.listen(1) - - done = False - filenos = {s.fileno(): s for s in (server, shutdown_server)} - - while not done: - r, _, _ = select.select(list(filenos.keys()), [], [], 0) - - for sock in [filenos[fd] for fd in r]: - if sock is server: - new_conn(server.accept()[0]) - else: - done = True - - -if __name__ == "__main__": - try: - start_listener() - except KeyboardInterrupt: - pass diff --git a/compliance/ws-fuzzingclient.json b/compliance/ws-fuzzingclient.json deleted file mode 100644 index b033382..0000000 --- a/compliance/ws-fuzzingclient.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "options": {"failByDrop": false}, - "outdir": "./reports/servers", - - "servers": [{"agent": "wsproto", "url": "ws://localhost:8642", "options": {"version": 18}}], - - "cases": ["*"], - "exclude-cases": [], - "exclude-agent-cases": {} -} diff --git a/compliance/ws-fuzzingserver.json b/compliance/ws-fuzzingserver.json deleted file mode 100644 index 6dc0ac0..0000000 --- a/compliance/ws-fuzzingserver.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "url": "ws://localhost:8642", - - "options": {"failByDrop": false}, - "outdir": "./reports/clients", - "webport": 8080, - - "cases": ["*"], - "exclude-cases": [], - "exclude-agent-cases": {} -} diff --git a/src/wsproto/extensions.py b/src/wsproto/extensions.py index ea8555d..4f6f4ee 100644 --- a/src/wsproto/extensions.py +++ b/src/wsproto/extensions.py @@ -6,17 +6,19 @@ """ import zlib +from abc import ABC, abstractmethod from typing import Optional, Tuple, Union from .frame_protocol import CloseReason, FrameDecoder, FrameProtocol, Opcode, RsvBits -class Extension: +class Extension(ABC): name: str def enabled(self) -> bool: return False + @abstractmethod def offer(self) -> Union[bool, str]: pass diff --git a/test/test_extensions.py b/test/test_extensions.py index a499dc5..1b5e0c0 100644 --- a/test/test_extensions.py +++ b/test/test_extensions.py @@ -1,41 +1,48 @@ +from typing import Union + from wsproto import extensions as wpext, frame_protocol as fp +class ConcreteExtension(wpext.Extension): + def offer(self) -> Union[bool, str]: + return "myext" + + class TestExtension: def test_enabled(self) -> None: - ext = wpext.Extension() + ext = ConcreteExtension() assert not ext.enabled() def test_offer(self) -> None: - ext = wpext.Extension() - assert ext.offer() is None + ext = ConcreteExtension() + assert ext.offer() == "myext" def test_accept(self) -> None: - ext = wpext.Extension() + ext = ConcreteExtension() offer = "myext" assert ext.accept(offer) is None def test_finalize(self) -> None: - ext = wpext.Extension() + ext = ConcreteExtension() offer = "myext" ext.finalize(offer) def test_frame_inbound_header(self) -> None: - ext = wpext.Extension() + ext = ConcreteExtension() result = ext.frame_inbound_header(None, None, None, None) # type: ignore[arg-type] assert result == fp.RsvBits(False, False, False) def test_frame_inbound_payload_data(self) -> None: - ext = wpext.Extension() + ext = ConcreteExtension() data = b"" assert ext.frame_inbound_payload_data(None, data) == data # type: ignore[arg-type] def test_frame_inbound_complete(self) -> None: - ext = wpext.Extension() + ext = ConcreteExtension() assert ext.frame_inbound_complete(None, None) is None # type: ignore[arg-type] def test_frame_outbound(self) -> None: - ext = wpext.Extension() + ext = ConcreteExtension() rsv = fp.RsvBits(True, True, True) data = b"" assert ext.frame_outbound(None, None, rsv, data, None) == ( # type: ignore[arg-type] diff --git a/test/test_frame_protocol.py b/test/test_frame_protocol.py index 76c40e4..36b5333 100644 --- a/test/test_frame_protocol.py +++ b/test/test_frame_protocol.py @@ -692,6 +692,9 @@ def __init__(self) -> None: def enabled(self) -> bool: return True + def offer(self) -> Union[bool, str]: + return "fake" + def frame_inbound_header( self, proto: Union[fp.FrameDecoder, fp.FrameProtocol], diff --git a/tox.ini b/tox.ini index afcc136..68fed1d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,13 @@ [tox] -envlist = py37, py38, py39, py310, pypy3, lint, docs, packaging +envlist = py37, py38, py39, py310, py311, pypy3, lint, docs, packaging [gh-actions] python = 3.7: py37 3.8: py38 3.9: py39 - 3.10: py310, lint, docs, packaging + 3.10: py310 + 3.11: py311, lint, docs, packaging pypy3: pypy3 [testenv] @@ -32,14 +33,14 @@ deps = {[testenv]deps} commands = flake8 src/ test/ - black --check --diff src/ test/ example/ compliance/ bench/ - isort --check --diff src/ test/ example/ compliance/ bench/ + black --check --diff src/ test/ example/ bench/ + isort --check --diff src/ test/ example/ bench/ mypy src/ test/ example/ bench/ [testenv:docs] deps = sphinx -whitelist_externals = make +allowlist_externals = make changedir = {toxinidir}/docs commands = make clean @@ -51,7 +52,7 @@ deps = check-manifest readme-renderer twine -whitelist_externals = rm +allowlist_externals = rm commands = rm -rf dist/ check-manifest @@ -62,12 +63,7 @@ commands = basepython = {[testenv:packaging]basepython} deps = {[testenv:packaging]deps} -whitelist_externals = {[testenv:packaging]whitelist_externals} +allowlist_externals = {[testenv:packaging]allowlist_externals} commands = {[testenv:packaging]commands} twine upload dist/* - -[testenv:autobahn] -changedir = {toxinidir}/compliance -commands = - python run-autobahn-tests.py {env:SIDE:}