From 22c3e5722c74b1e3669e591af9e6403058341609 Mon Sep 17 00:00:00 2001
From: Sertonix <sertonix@posteo.net>
Date: Sat, 21 Sep 2024 22:26:57 +0200
Subject: [PATCH] community/py3-trio-websocket: fix build with py3-trio>=0.25

Ref https://github.com/python-trio/trio-websocket/pull/188
---
 community/py3-trio-websocket/APKBUILD        |  23 +-
 community/py3-trio-websocket/trio-0.25.patch | 380 +++++++++++++++++++
 2 files changed, 387 insertions(+), 16 deletions(-)
 create mode 100644 community/py3-trio-websocket/trio-0.25.patch

diff --git a/community/py3-trio-websocket/APKBUILD b/community/py3-trio-websocket/APKBUILD
index f6d363a2a980..e82f3fe2fd20 100644
--- a/community/py3-trio-websocket/APKBUILD
+++ b/community/py3-trio-websocket/APKBUILD
@@ -1,12 +1,10 @@
 # Maintainer: Hoang Nguyen <folliekazetani@protonmail.com>
 pkgname=py3-trio-websocket
 pkgver=0.11.1
-pkgrel=1
+pkgrel=2
 pkgdesc="WebSocket client and server implementation for py3-trio"
 url="https://github.com/python-trio/trio-websocket"
-# disable due to issues with py3-trio>=0.25
-# https://github.com/python-trio/trio-websocket/issues/187
-#arch="noarch"
+arch="noarch"
 license="MIT"
 depends="
 	python3
@@ -25,7 +23,9 @@ checkdepends="
 	py3-trustme
 	"
 subpackages="$pkgname-pyc"
-source="$pkgname-$pkgver.tar.gz::https://github.com/python-trio/trio-websocket/archive/refs/tags/$pkgver.tar.gz"
+source="$pkgname-$pkgver.tar.gz::https://github.com/python-trio/trio-websocket/archive/refs/tags/$pkgver.tar.gz
+	trio-0.25.patch
+	"
 builddir="$srcdir/${pkgname#py3-}-$pkgver"
 
 build() {
@@ -35,19 +35,9 @@ build() {
 }
 
 check() {
-	# exception related tests fails with trio >= 0.25
-	# https://github.com/python-trio/trio-websocket/issues/187
-	local k="not test_handshake_exception_before_accept"
-	k="$k and not test_reject_handshake"
-	k="$k and not test_reject_handshake_invalid_info_status"
-	k="$k and not test_client_open_timeout"
-	k="$k and not test_client_close_timeout"
-	k="$k and not test_client_connect_networking_error"
-	k="$k and not test_finalization_dropped_exception"
-
 	python3 -m venv --clear --without-pip --system-site-packages .testenv
 	.testenv/bin/python3 -m installer .dist/*.whl
-	.testenv/bin/python3 -m pytest -k "$k"
+	.testenv/bin/python3 -m pytest
 }
 
 package() {
@@ -57,4 +47,5 @@ package() {
 
 sha512sums="
 4b0eb6f0c012cefedb69b97e9452ba979336fbe9f154799c4c68871b8013e728374e4872a2343ab4d27fa6e25e40c3063e681e80470123d37f13f531be4f6644  py3-trio-websocket-0.11.1.tar.gz
+2b592515dd1e9ca8acf96a6ff654d9de1ae37365cb0a3376838ee9b14e58e4ce268f932974960f5e044bea7ab36cded806ad85878ac0231b2c410709bea54b67  trio-0.25.patch
 "
diff --git a/community/py3-trio-websocket/trio-0.25.patch b/community/py3-trio-websocket/trio-0.25.patch
new file mode 100644
index 000000000000..4217c351f039
--- /dev/null
+++ b/community/py3-trio-websocket/trio-0.25.patch
@@ -0,0 +1,380 @@
+From f5fd6d77db16a7b527d670c4045fa1d53e621c35 Mon Sep 17 00:00:00 2001
+From: John Litborn <11260241+jakkdl@users.noreply.github.com>
+Date: Sat, 21 Sep 2024 16:47:30 +0200
+Subject: [PATCH] Support strict_exception_groups=True (#188)
+
+Fixes #132 and #187
+
+* Changes `open_websocket` to only raise a single exception, even when
+running under `strict_exception_groups=True`
+  * [ ] Should maybe introduce special handling for `KeyboardInterrupt`s
+* If multiple non-Cancelled-exceptions are encountered, then it will
+raise `TrioWebSocketInternalError` with the exceptiongroup as its
+`__cause__`. This should only be possible if the background task and the
+user context both raise exceptions. This would previously raise a
+`MultiError` with both Exceptions.
+* other alternatives could include throwing out the exception from the
+background task, raising an ExceptionGroup with both errors, or trying
+to do something fancy with `__cause__` or `__context__`.
+* `WebSocketServer.run` and `WebSocketServer._handle_connection` are the
+other two places that opens a nursery. I've opted not to change these,
+since I don't think user code should expect any special exceptions from
+it, and it seems less obscure that it might contain an internal nursery.
+  * [ ] Update docstrings to mention existence of internal nursery.
+---
+ .github/workflows/ci.yml  |   2 +-
+ requirements-dev-full.txt |   5 +-
+ requirements-dev.in       |   1 -
+ requirements-dev.txt      |   3 +-
+ tests/test_connection.py  | 117 +++++++++++++++++++++++++++++++++-
+ trio_websocket/_impl.py   | 129 ++++++++++++++++++++++++++++++++++----
+ 6 files changed, 238 insertions(+), 19 deletions(-)
+
+diff --git a/tests/test_connection.py b/tests/test_connection.py
+index 3d45eb7..6cccefa 100644
+--- a/tests/test_connection.py
++++ b/tests/test_connection.py
+@@ -32,7 +32,9 @@
+ from __future__ import annotations
+ 
+ from functools import partial, wraps
++import re
+ import ssl
++import sys
+ from unittest.mock import patch
+ 
+ import attr
+@@ -48,6 +50,13 @@
+ except ImportError:
+     from trio.hazmat import current_task  # type: ignore # pylint: disable=ungrouped-imports
+ 
++
++# only available on trio>=0.25, we don't use it when testing lower versions
++try:
++    from trio.testing import RaisesGroup
++except ImportError:
++    pass
++
+ from trio_websocket import (
+     connect_websocket,
+     connect_websocket_url,
+@@ -60,12 +69,18 @@
+     open_websocket,
+     open_websocket_url,
+     serve_websocket,
++    WebSocketConnection,
+     WebSocketServer,
+     WebSocketRequest,
+     wrap_client_stream,
+     wrap_server_stream
+ )
+ 
++from trio_websocket._impl import _TRIO_EXC_GROUP_TYPE
++
++if sys.version_info < (3, 11):
++    from exceptiongroup import BaseExceptionGroup  # pylint: disable=redefined-builtin
++
+ WS_PROTO_VERSION = tuple(map(int, wsproto.__version__.split('.')))
+ 
+ HOST = '127.0.0.1'
+@@ -428,6 +443,92 @@ async def handler(request):
+         assert header_value == b'My test header'
+ 
+ 
++
++
++@fail_after(5)
++async def test_open_websocket_internal_ki(nursery, monkeypatch, autojump_clock):
++    """_reader_task._handle_ping_event triggers KeyboardInterrupt.
++    user code also raises exception.
++    Make sure that KI is delivered, and the user exception is in the __cause__ exceptiongroup
++    """
++    async def ki_raising_ping_handler(*args, **kwargs) -> None:
++        print("raising ki")
++        raise KeyboardInterrupt
++    monkeypatch.setattr(WebSocketConnection, "_handle_ping_event", ki_raising_ping_handler)
++    async def handler(request):
++        server_ws = await request.accept()
++        await server_ws.ping(b"a")
++
++    server = await nursery.start(serve_websocket, handler, HOST, 0, None)
++    with pytest.raises(KeyboardInterrupt) as exc_info:
++        async with open_websocket(HOST, server.port, RESOURCE, use_ssl=False):
++            with trio.fail_after(1) as cs:
++                cs.shield = True
++                await trio.sleep(2)
++
++    e_cause = exc_info.value.__cause__
++    assert isinstance(e_cause, _TRIO_EXC_GROUP_TYPE)
++    assert any(isinstance(e, trio.TooSlowError) for e in e_cause.exceptions)
++
++@fail_after(5)
++async def test_open_websocket_internal_exc(nursery, monkeypatch, autojump_clock):
++    """_reader_task._handle_ping_event triggers ValueError.
++    user code also raises exception.
++    internal exception is in __cause__ exceptiongroup and user exc is delivered
++    """
++    my_value_error = ValueError()
++    async def raising_ping_event(*args, **kwargs) -> None:
++        raise my_value_error
++
++    monkeypatch.setattr(WebSocketConnection, "_handle_ping_event", raising_ping_event)
++    async def handler(request):
++        server_ws = await request.accept()
++        await server_ws.ping(b"a")
++
++    server = await nursery.start(serve_websocket, handler, HOST, 0, None)
++    with pytest.raises(trio.TooSlowError) as exc_info:
++        async with open_websocket(HOST, server.port, RESOURCE, use_ssl=False):
++            with trio.fail_after(1) as cs:
++                cs.shield = True
++                await trio.sleep(2)
++
++    e_cause = exc_info.value.__cause__
++    assert isinstance(e_cause, _TRIO_EXC_GROUP_TYPE)
++    assert my_value_error in e_cause.exceptions
++
++@fail_after(5)
++async def test_open_websocket_cancellations(nursery, monkeypatch, autojump_clock):
++    """Both user code and _reader_task raise Cancellation.
++    Check that open_websocket reraises the one from user code for traceback reasons.
++    """
++
++
++    async def sleeping_ping_event(*args, **kwargs) -> None:
++        await trio.sleep_forever()
++
++    # We monkeypatch WebSocketConnection._handle_ping_event to ensure it will actually
++    # raise Cancelled upon being cancelled. For some reason it doesn't otherwise.
++    monkeypatch.setattr(WebSocketConnection, "_handle_ping_event", sleeping_ping_event)
++    async def handler(request):
++        server_ws = await request.accept()
++        await server_ws.ping(b"a")
++    user_cancelled = None
++
++    server = await nursery.start(serve_websocket, handler, HOST, 0, None)
++    with trio.move_on_after(2):
++        with pytest.raises(trio.Cancelled) as exc_info:
++            async with open_websocket(HOST, server.port, RESOURCE, use_ssl=False):
++                try:
++                    await trio.sleep_forever()
++                except trio.Cancelled as e:
++                    user_cancelled = e
++                    raise
++    assert exc_info.value is user_cancelled
++
++def _trio_default_non_strict_exception_groups() -> bool:
++    assert re.match(r'^0\.\d\d\.', trio.__version__), "unexpected trio versioning scheme"
++    return int(trio.__version__[2:4]) < 25
++
+ @fail_after(1)
+ async def test_handshake_exception_before_accept() -> None:
+     ''' In #107, a request handler that throws an exception before finishing the
+@@ -436,7 +537,8 @@ async def test_handshake_exception_before_accept() -> None:
+     async def handler(request):
+         raise ValueError()
+ 
+-    with pytest.raises(ValueError):
++    # pylint fails to resolve that BaseExceptionGroup will always be available
++    with pytest.raises((BaseExceptionGroup, ValueError)) as exc:  # pylint: disable=possibly-used-before-assignment
+         async with trio.open_nursery() as nursery:
+             server = await nursery.start(serve_websocket, handler, HOST, 0,
+                 None)
+@@ -444,6 +546,19 @@ async def handler(request):
+                     use_ssl=False):
+                 pass
+ 
++    if _trio_default_non_strict_exception_groups():
++        assert isinstance(exc.value, ValueError)
++    else:
++        # there's 4 levels of nurseries opened, leading to 4 nested groups:
++        # 1. this test
++        # 2. WebSocketServer.run
++        # 3. trio.serve_listeners
++        # 4. WebSocketServer._handle_connection
++        assert RaisesGroup(
++            RaisesGroup(
++                RaisesGroup(
++                    RaisesGroup(ValueError)))).matches(exc.value)
++
+ 
+ @fail_after(1)
+ async def test_reject_handshake(nursery):
+diff --git a/trio_websocket/_impl.py b/trio_websocket/_impl.py
+index b153034..a71e0be 100644
+--- a/trio_websocket/_impl.py
++++ b/trio_websocket/_impl.py
+@@ -13,6 +13,7 @@
+ import urllib.parse
+ from typing import Iterable, List, Optional, Union
+ 
++import outcome
+ import trio
+ import trio.abc
+ from wsproto import ConnectionType, WSConnection
+@@ -35,7 +36,12 @@
+     # pylint doesn't care about the version_info check, so need to ignore the warning
+     from exceptiongroup import BaseExceptionGroup  # pylint: disable=redefined-builtin
+ 
+-_TRIO_MULTI_ERROR = tuple(map(int, trio.__version__.split('.')[:2])) < (0, 22)
++_IS_TRIO_MULTI_ERROR = tuple(map(int, trio.__version__.split('.')[:2])) < (0, 22)
++
++if _IS_TRIO_MULTI_ERROR:
++    _TRIO_EXC_GROUP_TYPE = trio.MultiError  # type: ignore[attr-defined] # pylint: disable=no-member
++else:
++    _TRIO_EXC_GROUP_TYPE = BaseExceptionGroup  # pylint: disable=possibly-used-before-assignment
+ 
+ CONN_TIMEOUT = 60 # default connect & disconnect timeout, in seconds
+ MESSAGE_QUEUE_SIZE = 1
+@@ -44,6 +50,13 @@
+ logger = logging.getLogger('trio-websocket')
+ 
+ 
++class TrioWebsocketInternalError(Exception):
++    """Raised as a fallback when open_websocket is unable to unwind an exceptiongroup
++    into a single preferred exception. This should never happen, if it does then
++    underlying assumptions about the internal code are incorrect.
++    """
++
++
+ def _ignore_cancel(exc):
+     return None if isinstance(exc, trio.Cancelled) else exc
+ 
+@@ -70,7 +83,7 @@ def __exit__(self, ty, value, tb):
+         if value is None or not self._armed:
+             return False
+ 
+-        if _TRIO_MULTI_ERROR:  # pragma: no cover
++        if _IS_TRIO_MULTI_ERROR:  # pragma: no cover
+             filtered_exception = trio.MultiError.filter(_ignore_cancel, value)  # pylint: disable=no-member
+         elif isinstance(value, BaseExceptionGroup):  # pylint: disable=possibly-used-before-assignment
+             filtered_exception = value.subgroup(lambda exc: not isinstance(exc, trio.Cancelled))
+@@ -125,10 +138,33 @@ async def open_websocket(
+         client-side timeout (:exc:`ConnectionTimeout`, :exc:`DisconnectionTimeout`),
+         or server rejection (:exc:`ConnectionRejected`) during handshakes.
+     '''
+-    async with trio.open_nursery() as new_nursery:
++
++    # This context manager tries very very hard not to raise an exceptiongroup
++    # in order to be as transparent as possible for the end user.
++    # In the trivial case, this means that if user code inside the cm raises
++    # we make sure that it doesn't get wrapped.
++
++    # If opening the connection fails, then we will raise that exception. User
++    # code is never executed, so we will never have multiple exceptions.
++
++    # After opening the connection, we spawn _reader_task in the background and
++    # yield to user code. If only one of those raise a non-cancelled exception
++    # we will raise that non-cancelled exception.
++    # If we get multiple cancelled, we raise the user's cancelled.
++    # If both raise exceptions, we raise the user code's exception with the entire
++    # exception group as the __cause__.
++    # If we somehow get multiple exceptions, but no user exception, then we raise
++    # TrioWebsocketInternalError.
++
++    # If closing the connection fails, then that will be raised as the top
++    # exception in the last `finally`. If we encountered exceptions in user code
++    # or in reader task then they will be set as the `__cause__`.
++
++
++    async def _open_connection(nursery: trio.Nursery) -> WebSocketConnection:
+         try:
+             with trio.fail_after(connect_timeout):
+-                connection = await connect_websocket(new_nursery, host, port,
++                return await connect_websocket(nursery, host, port,
+                     resource, use_ssl=use_ssl, subprotocols=subprotocols,
+                     extra_headers=extra_headers,
+                     message_queue_size=message_queue_size,
+@@ -137,14 +173,85 @@ async def open_websocket(
+             raise ConnectionTimeout from None
+         except OSError as e:
+             raise HandshakeError from e
++
++    async def _close_connection(connection: WebSocketConnection) -> None:
+         try:
+-            yield connection
+-        finally:
+-            try:
+-                with trio.fail_after(disconnect_timeout):
+-                    await connection.aclose()
+-            except trio.TooSlowError:
+-                raise DisconnectionTimeout from None
++            with trio.fail_after(disconnect_timeout):
++                await connection.aclose()
++        except trio.TooSlowError:
++            raise DisconnectionTimeout from None
++
++    connection: WebSocketConnection|None=None
++    close_result: outcome.Maybe[None] | None = None
++    user_error = None
++
++    try:
++        async with trio.open_nursery() as new_nursery:
++            result = await outcome.acapture(_open_connection, new_nursery)
++
++            if isinstance(result, outcome.Value):
++                connection = result.unwrap()
++                try:
++                    yield connection
++                except BaseException as e:
++                    user_error = e
++                    raise
++                finally:
++                    close_result = await outcome.acapture(_close_connection, connection)
++    # This exception handler should only be entered if either:
++    # 1. The _reader_task started in connect_websocket raises
++    # 2. User code raises an exception
++    # I.e. open/close_connection are not included
++    except _TRIO_EXC_GROUP_TYPE as e:
++        # user_error, or exception bubbling up from _reader_task
++        if len(e.exceptions) == 1:
++            raise e.exceptions[0]
++
++        # contains at most 1 non-cancelled exceptions
++        exception_to_raise: BaseException|None = None
++        for sub_exc in e.exceptions:
++            if not isinstance(sub_exc, trio.Cancelled):
++                if exception_to_raise is not None:
++                    # multiple non-cancelled
++                    break
++                exception_to_raise = sub_exc
++        else:
++            if exception_to_raise is None:
++                # all exceptions are cancelled
++                # prefer raising the one from the user, for traceback reasons
++                if user_error is not None:
++                    # no reason to raise from e, just to include a bunch of extra
++                    # cancelleds.
++                    raise user_error  # pylint: disable=raise-missing-from
++                # multiple internal Cancelled is not possible afaik
++                raise e.exceptions[0]  # pragma: no cover  # pylint: disable=raise-missing-from
++            raise exception_to_raise
++
++        # if we have any KeyboardInterrupt in the group, make sure to raise it.
++        for sub_exc in e.exceptions:
++            if isinstance(sub_exc, KeyboardInterrupt):
++                raise sub_exc from e
++
++        # Both user code and internal code raised non-cancelled exceptions.
++        # We "hide" the internal exception(s) in the __cause__ and surface
++        # the user_error.
++        if user_error is not None:
++            raise user_error from e
++
++        raise TrioWebsocketInternalError(
++            "The trio-websocket API is not expected to raise multiple exceptions. "
++            "Please report this as a bug to "
++            "https://github.com/python-trio/trio-websocket"
++        ) from e  # pragma: no cover
++
++    finally:
++        if close_result is not None:
++            close_result.unwrap()
++
++
++    # error setting up, unwrap that exception
++    if connection is None:
++        result.unwrap()
+ 
+ 
+ async def connect_websocket(nursery, host, port, resource, *, use_ssl,
-- 
GitLab