diff --git a/docs/project/changelog.rst b/docs/project/changelog.rst index 7f341d94..2a429b43 100644 --- a/docs/project/changelog.rst +++ b/docs/project/changelog.rst @@ -25,13 +25,28 @@ fixing regressions shortly after a release. Only documented APIs are public. Undocumented, private APIs may change without notice. -.. _14.3: +.. _15.0: -14.3 +15.0 ---- *In development* +Backwards-incompatible changes +.............................. + +.. admonition:: Client connections use SOCKS proxies automatically. + :class: important + + If a proxy is configured in the operating system or with an environment + variable, websockets uses it automatically when connecting to a server. + This feature requires installing the third-party library `python-socks`_. + + If you want to disable the proxy, add ``proxy=None`` when calling + :func:`~asyncio.client.connect`. See :doc:`../topics/proxies` for details. + + .. _python-socks: https://github.com/romis2012/python-socks + New features ............ diff --git a/docs/reference/features.rst b/docs/reference/features.rst index 6ba42f66..eaecd02a 100644 --- a/docs/reference/features.rst +++ b/docs/reference/features.rst @@ -168,11 +168,10 @@ Client +------------------------------------+--------+--------+--------+--------+ | Connect via HTTP proxy (`#364`_) | ❌ | ❌ | — | ❌ | +------------------------------------+--------+--------+--------+--------+ - | Connect via SOCKS5 proxy (`#475`_) | ❌ | ❌ | — | ❌ | + | Connect via SOCKS5 proxy | ✅ | ✅ | — | ❌ | +------------------------------------+--------+--------+--------+--------+ .. _#364: https://github.com/python-websockets/websockets/issues/364 -.. _#475: https://github.com/python-websockets/websockets/issues/475 .. _#784: https://github.com/python-websockets/websockets/issues/784 Known limitations diff --git a/docs/topics/index.rst b/docs/topics/index.rst index 616753c6..a08d487c 100644 --- a/docs/topics/index.rst +++ b/docs/topics/index.rst @@ -15,3 +15,4 @@ Get a deeper understanding of how websockets is built and why. memory security performance + proxies diff --git a/docs/topics/proxies.rst b/docs/topics/proxies.rst new file mode 100644 index 00000000..fd3ae78b --- /dev/null +++ b/docs/topics/proxies.rst @@ -0,0 +1,66 @@ +Proxies +======= + +.. currentmodule:: websockets + +If a proxy is configured in the operating system or with an environment +variable, websockets uses it automatically when connecting to a server. + +Configuration +------------- + +First, if the server is in the proxy bypass list of the operating system or in +the ``no_proxy`` environment variable, websockets connects directly. + +Then, it looks for a proxy in the following locations: + +1. The ``wss_proxy`` or ``ws_proxy`` environment variables for ``wss://`` and + ``ws://`` connections respectively. They allow configuring a specific proxy + for WebSocket connections. +2. A SOCKS proxy configured in the operating system. +3. An HTTP proxy configured in the operating system or in the ``https_proxy`` + environment variable, for both ``wss://`` and ``ws://`` connections. +4. An HTTP proxy configured in the operating system or in the ``http_proxy`` + environment variable, only for ``ws://`` connections. + +Finally, if no proxy is found, websockets connects directly. + +While environment variables are case-insensitive, the lower-case spelling is the +most common, for `historical reasons`_, and recommended. + +.. _historical reasons: https://unix.stackexchange.com/questions/212894/ + +.. admonition:: Any environment variable can configure a SOCKS proxy or an HTTP proxy. + :class: tip + + For example, ``https_proxy=socks5h://proxy:1080/`` configures a SOCKS proxy + for all WebSocket connections. Likewise, ``wss_proxy=http://proxy:8080/`` + configures an HTTP proxy only for ``wss://`` connections. + +.. admonition:: What if websockets doesn't select the right proxy? + :class: hint + + websockets relies on :func:`~urllib.request.getproxies()` to read the proxy + configuration. Check that it returns what you expect. If it doesn't, review + your proxy configuration. + +You can override the default configuration and configure a proxy explicitly with +the ``proxy`` argument of :func:`~asyncio.client.connect`. Set ``proxy=None`` to +disable the proxy. + +SOCKS proxies +------------- + +Connecting through a SOCKS proxy requires installing the third-party library +`python-socks`_:: + + $ pip install python-socks\[asyncio\] + +.. _python-socks: https://github.com/romis2012/python-socks + +python-socks supports SOCKS4, SOCKS4a, SOCKS5, and SOCKS5h. The protocol version +is configured in the address of the proxy e.g. ``socks5h://proxy:1080/``. When a +SOCKS proxy is configured in the operating system, python-socks uses SOCKS5h. + +python-socks supports username/password authentication for SOCKS5 (:rfc:`1929`) +but does not support other authentication methods such as GSSAPI (:rfc:`1961`). diff --git a/src/websockets/asyncio/client.py b/src/websockets/asyncio/client.py index bde0beee..f76095ea 100644 --- a/src/websockets/asyncio/client.py +++ b/src/websockets/asyncio/client.py @@ -7,7 +7,7 @@ import urllib.parse from collections.abc import AsyncIterator, Generator, Sequence from types import TracebackType -from typing import Any, Callable +from typing import Any, Callable, Literal from ..client import ClientProtocol, backoff from ..datastructures import HeadersLike @@ -18,7 +18,7 @@ from ..http11 import USER_AGENT, Response from ..protocol import CONNECTING, Event from ..typing import LoggerLike, Origin, Subprotocol -from ..uri import WebSocketURI, parse_uri +from ..uri import Proxy, WebSocketURI, get_proxy, parse_proxy, parse_uri from .compatibility import TimeoutError, asyncio_timeout from .connection import Connection @@ -208,6 +208,10 @@ class connect: user_agent_header: Value of the ``User-Agent`` request header. It defaults to ``"Python/x.y.z websockets/X.Y"``. Setting it to :obj:`None` removes the header. + proxy: If a proxy is configured, it is used by default. Set ``proxy`` + to :obj:`None` to disable the proxy or to the address of a proxy + to override the system configuration. See the :doc:`proxy docs + <../../topics/proxies>` for details. process_exception: When reconnecting automatically, tell whether an error is transient or fatal. The default behavior is defined by :func:`process_exception`. Refer to its documentation for details. @@ -279,6 +283,7 @@ def __init__( # HTTP additional_headers: HeadersLike | None = None, user_agent_header: str | None = USER_AGENT, + proxy: str | Literal[True] | None = True, process_exception: Callable[[Exception], Exception | None] = process_exception, # Timeouts open_timeout: float | None = 10, @@ -333,6 +338,7 @@ def protocol_factory(uri: WebSocketURI) -> ClientConnection: ) return connection + self.proxy = proxy self.protocol_factory = protocol_factory self.handshake_args = ( additional_headers, @@ -346,9 +352,20 @@ def protocol_factory(uri: WebSocketURI) -> ClientConnection: async def create_connection(self) -> ClientConnection: """Create TCP or Unix connection.""" loop = asyncio.get_running_loop() + kwargs = self.connection_kwargs.copy() ws_uri = parse_uri(self.uri) - kwargs = self.connection_kwargs.copy() + + proxy = self.proxy + proxy_uri: Proxy | None = None + if kwargs.get("unix", False): + proxy = None + if kwargs.get("sock") is not None: + proxy = None + if proxy is True: + proxy = get_proxy(ws_uri) + if proxy is not None: + proxy_uri = parse_proxy(proxy) def factory() -> ClientConnection: return self.protocol_factory(ws_uri) @@ -365,6 +382,47 @@ def factory() -> ClientConnection: if kwargs.pop("unix", False): _, connection = await loop.create_unix_connection(factory, **kwargs) else: + if proxy_uri is not None: + if proxy_uri.scheme[:5] == "socks": + try: + from python_socks import ProxyType + from python_socks.async_.asyncio import Proxy + except ImportError: + raise ImportError( + "python-socks is required to use a SOCKS proxy" + ) + if proxy_uri.scheme == "socks5h": + proxy_type = ProxyType.SOCKS5 + rdns = True + elif proxy_uri.scheme == "socks5": + proxy_type = ProxyType.SOCKS5 + rdns = False + # We use mitmproxy for testing and it doesn't support SOCKS4. + elif proxy_uri.scheme == "socks4a": # pragma: no cover + proxy_type = ProxyType.SOCKS4 + rdns = True + elif proxy_uri.scheme == "socks4": # pragma: no cover + proxy_type = ProxyType.SOCKS4 + rdns = False + # Proxy types are enforced in parse_proxy(). + else: + raise AssertionError("unsupported SOCKS proxy") + socks_proxy = Proxy( + proxy_type, + proxy_uri.host, + proxy_uri.port, + proxy_uri.username, + proxy_uri.password, + rdns, + ) + kwargs["sock"] = await socks_proxy.connect( + ws_uri.host, + ws_uri.port, + local_addr=kwargs.pop("local_addr", None), + ) + # Proxy types are enforced in parse_proxy(). + else: + raise AssertionError("unsupported proxy") if kwargs.get("sock") is None: kwargs.setdefault("host", ws_uri.host) kwargs.setdefault("port", ws_uri.port) diff --git a/src/websockets/sync/client.py b/src/websockets/sync/client.py index da2b8859..96f62eda 100644 --- a/src/websockets/sync/client.py +++ b/src/websockets/sync/client.py @@ -5,7 +5,7 @@ import threading import warnings from collections.abc import Sequence -from typing import Any +from typing import Any, Literal from ..client import ClientProtocol from ..datastructures import HeadersLike @@ -15,7 +15,7 @@ from ..http11 import USER_AGENT, Response from ..protocol import CONNECTING, Event from ..typing import LoggerLike, Origin, Subprotocol -from ..uri import parse_uri +from ..uri import Proxy, get_proxy, parse_proxy, parse_uri from .connection import Connection from .utils import Deadline @@ -139,6 +139,7 @@ def connect( # HTTP additional_headers: HeadersLike | None = None, user_agent_header: str | None = USER_AGENT, + proxy: str | Literal[True] | None = True, # Timeouts open_timeout: float | None = 10, ping_interval: float | None = 20, @@ -189,6 +190,10 @@ def connect( user_agent_header: Value of the ``User-Agent`` request header. It defaults to ``"Python/x.y.z websockets/X.Y"``. Setting it to :obj:`None` removes the header. + proxy: If a proxy is configured, it is used by default. Set ``proxy`` + to :obj:`None` to disable the proxy or to the address of a proxy + to override the system configuration. See the :doc:`proxy docs + <../../topics/proxies>` for details. open_timeout: Timeout for opening the connection in seconds. :obj:`None` disables the timeout. ping_interval: Interval between keepalive pings in seconds. @@ -253,6 +258,16 @@ def connect( elif compression is not None: raise ValueError(f"unsupported compression: {compression}") + proxy_uri: Proxy | None = None + if unix: + proxy = None + if sock is not None: + proxy = None + if proxy is True: + proxy = get_proxy(ws_uri) + if proxy is not None: + proxy_uri = parse_proxy(proxy) + # Calculate timeouts on the TCP, TLS, and WebSocket handshakes. # The TCP and TLS timeouts must be set on the socket, then removed # to avoid conflicting with the WebSocket timeout in handshake(). @@ -271,8 +286,53 @@ def connect( assert path is not None # mypy cannot figure this out sock.connect(path) else: - kwargs.setdefault("timeout", deadline.timeout()) - sock = socket.create_connection((ws_uri.host, ws_uri.port), **kwargs) + if proxy_uri is not None: + if proxy_uri.scheme[:5] == "socks": + try: + from python_socks import ProxyType + from python_socks.sync import Proxy + except ImportError: + raise ImportError( + "python-socks is required to use a SOCKS proxy" + ) + if proxy_uri.scheme == "socks5h": + proxy_type = ProxyType.SOCKS5 + rdns = True + elif proxy_uri.scheme == "socks5": + proxy_type = ProxyType.SOCKS5 + rdns = False + # We use mitmproxy for testing and it doesn't support SOCKS4. + elif proxy_uri.scheme == "socks4a": # pragma: no cover + proxy_type = ProxyType.SOCKS4 + rdns = True + elif proxy_uri.scheme == "socks4": # pragma: no cover + proxy_type = ProxyType.SOCKS4 + rdns = False + # Proxy types are enforced in parse_proxy(). + else: + raise AssertionError("unsupported SOCKS proxy") + socks_proxy = Proxy( + proxy_type, + proxy_uri.host, + proxy_uri.port, + proxy_uri.username, + proxy_uri.password, + rdns, + ) + sock = socks_proxy.connect( + ws_uri.host, + ws_uri.port, + timeout=deadline.timeout(), + local_addr=kwargs.pop("local_addr", None), + ) + # Proxy types are enforced in parse_proxy(). + else: + raise AssertionError("unsupported proxy") + else: + kwargs.setdefault("timeout", deadline.timeout()) + sock = socket.create_connection( + (ws_uri.host, ws_uri.port), **kwargs + ) sock.settimeout(None) # Disable Nagle algorithm diff --git a/src/websockets/version.py b/src/websockets/version.py index ca9a9115..611e7d23 100644 --- a/src/websockets/version.py +++ b/src/websockets/version.py @@ -20,7 +20,7 @@ released = False -tag = version = commit = "14.3" +tag = version = commit = "15.0" if not released: # pragma: no cover diff --git a/tests/asyncio/test_client.py b/tests/asyncio/test_client.py index f05bfc69..cb2b8ede 100644 --- a/tests/asyncio/test_client.py +++ b/tests/asyncio/test_client.py @@ -4,6 +4,7 @@ import logging import socket import ssl +import sys import unittest from websockets.asyncio.client import * @@ -13,13 +14,21 @@ from websockets.exceptions import ( InvalidHandshake, InvalidMessage, + InvalidProxy, InvalidStatus, InvalidURI, SecurityError, ) from websockets.extensions.permessage_deflate import PerMessageDeflate -from ..utils import CLIENT_CONTEXT, MS, SERVER_CONTEXT, temp_unix_socket_path +from ..proxy import async_proxy +from ..utils import ( + CLIENT_CONTEXT, + MS, + SERVER_CONTEXT, + patch_environ, + temp_unix_socket_path, +) from .server import args, get_host_port, get_uri, handler @@ -555,6 +564,78 @@ def redirect(connection, request): ) +@unittest.skipUnless("mitmproxy" in sys.modules, "mitmproxy not installed") +class ProxyClientTests(unittest.IsolatedAsyncioTestCase): + @contextlib.asynccontextmanager + async def socks_proxy(self, auth=None): + if auth: + proxyauth = "hello:iloveyou" + proxy_uri = "http://hello:iloveyou@localhost:1080" + else: + proxyauth = None + proxy_uri = "http://localhost:1080" + async with async_proxy(mode=["socks5"], proxyauth=proxyauth) as record_flows: + with patch_environ({"socks_proxy": proxy_uri}): + yield record_flows + + async def test_socks_proxy(self): + """Client connects to server through a SOCKS5 proxy.""" + async with self.socks_proxy() as proxy: + async with serve(*args) as server: + async with connect(get_uri(server)) as client: + self.assertEqual(client.protocol.state.name, "OPEN") + self.assertEqual(len(proxy.get_flows()), 1) + + async def test_secure_socks_proxy(self): + """Client connects to server securely through a SOCKS5 proxy.""" + async with self.socks_proxy() as proxy: + async with serve(*args, ssl=SERVER_CONTEXT) as server: + async with connect(get_uri(server), ssl=CLIENT_CONTEXT) as client: + self.assertEqual(client.protocol.state.name, "OPEN") + self.assertEqual(len(proxy.get_flows()), 1) + + async def test_authenticated_socks_proxy(self): + """Client connects to server through an authenticated SOCKS5 proxy.""" + async with self.socks_proxy(auth=True) as proxy: + async with serve(*args) as server: + async with connect(get_uri(server)) as client: + self.assertEqual(client.protocol.state.name, "OPEN") + self.assertEqual(len(proxy.get_flows()), 1) + + async def test_explicit_proxy(self): + """Client connects to server through a proxy set explicitly.""" + async with async_proxy(mode=["socks5"]) as proxy: + async with serve(*args) as server: + async with connect( + get_uri(server), + # Take this opportunity to test socks5 instead of socks5h. + proxy="socks5://localhost:1080", + ) as client: + self.assertEqual(client.protocol.state.name, "OPEN") + self.assertEqual(len(proxy.get_flows()), 1) + + async def test_ignore_proxy_with_existing_socket(self): + """Client connects using a pre-existing socket.""" + async with self.socks_proxy() as proxy: + async with serve(*args) as server: + with socket.create_connection(get_host_port(server)) as sock: + # Use a non-existing domain to ensure we connect to sock. + async with connect("ws://invalid/", sock=sock) as client: + self.assertEqual(client.protocol.state.name, "OPEN") + self.assertEqual(len(proxy.get_flows()), 0) + + async def test_unsupported_proxy(self): + """Client connects to server through an unsupported proxy.""" + with patch_environ({"ws_proxy": "other://localhost:1080"}): + with self.assertRaises(InvalidProxy) as raised: + async with connect("ws://example.com/"): + self.fail("did not raise") + self.assertEqual( + str(raised.exception), + "other://localhost:1080 isn't a valid proxy: scheme other isn't supported", + ) + + @unittest.skipUnless(hasattr(socket, "AF_UNIX"), "this test requires Unix sockets") class UnixClientTests(unittest.IsolatedAsyncioTestCase): async def test_connection(self): diff --git a/tests/proxy.py b/tests/proxy.py new file mode 100644 index 00000000..95525a36 --- /dev/null +++ b/tests/proxy.py @@ -0,0 +1,89 @@ +import asyncio +import contextlib +import pathlib +import threading +import warnings + + +warnings.filterwarnings("ignore", category=DeprecationWarning, module="mitmproxy") +warnings.filterwarnings("ignore", category=DeprecationWarning, module="passlib") +warnings.filterwarnings("ignore", category=DeprecationWarning, module="pyasn1") + +try: + from mitmproxy.addons import core, next_layer, proxyauth, proxyserver, tlsconfig + from mitmproxy.master import Master + from mitmproxy.options import Options +except ImportError: + pass + + +class RecordFlows: + def __init__(self): + self.ready = asyncio.get_running_loop().create_future() + self.flows = [] + + def running(self): + self.ready.set_result(None) + + def websocket_start(self, flow): + self.flows.append(flow) + + def get_flows(self): + flows, self.flows[:] = self.flows[:], [] + return flows + + +@contextlib.asynccontextmanager +async def async_proxy(mode, **config): + options = Options(mode=mode) + master = Master(options) + record_flows = RecordFlows() + master.addons.add( + core.Core(), + proxyauth.ProxyAuth(), + proxyserver.Proxyserver(), + next_layer.NextLayer(), + tlsconfig.TlsConfig(), + record_flows, + ) + config.update( + # Use our test certificate for TLS between client and proxy + # and disable TLS verification between proxy and upstream. + certs=[str(pathlib.Path(__file__).with_name("test_localhost.pem"))], + ssl_insecure=True, + ) + options.update(**config) + + asyncio.create_task(master.run()) + try: + await record_flows.ready + yield record_flows + finally: + for server in master.addons.get("proxyserver").servers: + await server.stop() + master.shutdown() + + +@contextlib.contextmanager +def sync_proxy(mode, **config): + loop = None + test_done = None + proxy_ready = threading.Event() + record_flows = None + + async def proxy_coroutine(): + nonlocal loop, test_done, proxy_ready, record_flows + loop = asyncio.get_running_loop() + test_done = loop.create_future() + async with async_proxy(mode, **config) as record_flows: + proxy_ready.set() + await test_done + + proxy_thread = threading.Thread(target=asyncio.run, args=(proxy_coroutine(),)) + proxy_thread.start() + try: + proxy_ready.wait() + yield record_flows + finally: + loop.call_soon_threadsafe(test_done.set_result, None) + proxy_thread.join() diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 00000000..f375e6f6 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,2 @@ +python-socks[asyncio] +mitmproxy diff --git a/tests/sync/test_client.py b/tests/sync/test_client.py index 736a84c9..2f62dd34 100644 --- a/tests/sync/test_client.py +++ b/tests/sync/test_client.py @@ -1,8 +1,10 @@ +import contextlib import http import logging import socket import socketserver import ssl +import sys import threading import time import unittest @@ -10,17 +12,20 @@ from websockets.exceptions import ( InvalidHandshake, InvalidMessage, + InvalidProxy, InvalidStatus, InvalidURI, ) from websockets.extensions.permessage_deflate import PerMessageDeflate from websockets.sync.client import * +from ..proxy import sync_proxy from ..utils import ( CLIENT_CONTEXT, MS, SERVER_CONTEXT, DeprecationTestCase, + patch_environ, temp_unix_socket_path, ) from .server import get_uri, run_server, run_unix_server @@ -37,7 +42,7 @@ def test_existing_socket(self): """Client connects using a pre-existing socket.""" with run_server() as server: with socket.create_connection(server.socket.getsockname()) as sock: - # Use a non-existing domain to ensure we connect to the right socket. + # Use a non-existing domain to ensure we connect to sock. with connect("ws://invalid/", sock=sock) as client: self.assertEqual(client.protocol.state.name, "OPEN") @@ -300,6 +305,79 @@ def test_reject_invalid_server_hostname(self): ) +@unittest.skipUnless("mitmproxy" in sys.modules, "mitmproxy not installed") +class ProxyClientTests(unittest.TestCase): + @contextlib.contextmanager + def socks_proxy(self, auth=None): + if auth: + proxyauth = "hello:iloveyou" + proxy_uri = "http://hello:iloveyou@localhost:1080" + else: + proxyauth = None + proxy_uri = "http://localhost:1080" + + with sync_proxy(mode=["socks5"], proxyauth=proxyauth) as record_flows: + with patch_environ({"socks_proxy": proxy_uri}): + yield record_flows + + def test_socks_proxy(self): + """Client connects to server through a SOCKS5 proxy.""" + with self.socks_proxy() as proxy: + with run_server() as server: + with connect(get_uri(server)) as client: + self.assertEqual(client.protocol.state.name, "OPEN") + self.assertEqual(len(proxy.get_flows()), 1) + + def test_secure_socks_proxy(self): + """Client connects to server securely through a SOCKS5 proxy.""" + with self.socks_proxy() as proxy: + with run_server(ssl=SERVER_CONTEXT) as server: + with connect(get_uri(server), ssl=CLIENT_CONTEXT) as client: + self.assertEqual(client.protocol.state.name, "OPEN") + self.assertEqual(len(proxy.get_flows()), 1) + + def test_authenticated_socks_proxy(self): + """Client connects to server through an authenticated SOCKS5 proxy.""" + with self.socks_proxy(auth=True) as proxy: + with run_server() as server: + with connect(get_uri(server)) as client: + self.assertEqual(client.protocol.state.name, "OPEN") + self.assertEqual(len(proxy.get_flows()), 1) + + def test_explicit_proxy(self): + """Client connects to server through a proxy set explicitly.""" + with sync_proxy(mode=["socks5"]) as proxy: + with run_server() as server: + with connect( + get_uri(server), + # Take this opportunity to test socks5 instead of socks5h. + proxy="socks5://localhost:1080", + ) as client: + self.assertEqual(client.protocol.state.name, "OPEN") + self.assertEqual(len(proxy.get_flows()), 1) + + def test_ignore_proxy_with_existing_socket(self): + """Client connects using a pre-existing socket.""" + with self.socks_proxy() as proxy: + with run_server() as server: + with socket.create_connection(server.socket.getsockname()) as sock: + # Use a non-existing domain to ensure we connect to sock. + with connect("ws://invalid/", sock=sock) as client: + self.assertEqual(client.protocol.state.name, "OPEN") + self.assertEqual(len(proxy.get_flows()), 0) + + def test_unsupported_proxy(self): + """Client connects to server through an unsupported proxy.""" + with patch_environ({"ws_proxy": "other://localhost:1080"}): + with self.assertRaises(InvalidProxy) as raised: + with connect("ws://example.com/"): + self.fail("did not raise") + self.assertEqual( + str(raised.exception), + "other://localhost:1080 isn't a valid proxy: scheme other isn't supported", + ) + + @unittest.skipUnless(hasattr(socket, "AF_UNIX"), "this test requires Unix sockets") class UnixClientTests(unittest.TestCase): def test_connection(self): diff --git a/tox.ini b/tox.ini index 0bcec5de..f5a2f5d3 100644 --- a/tox.ini +++ b/tox.ini @@ -12,27 +12,38 @@ env_list = [testenv] commands = python -W error::DeprecationWarning -W error::PendingDeprecationWarning -m unittest {posargs} -pass_env = WEBSOCKETS_* +pass_env = + WEBSOCKETS_* +deps = + mitmproxy + python-socks[asyncio] [testenv:coverage] commands = python -m coverage run --source {envsitepackagesdir}/websockets,tests -m unittest {posargs} python -m coverage report --show-missing --fail-under=100 -deps = coverage +deps = + coverage + {[testenv]deps} [testenv:maxi_cov] commands = python tests/maxi_cov.py {envsitepackagesdir} python -m coverage report --show-missing --fail-under=100 -deps = coverage +deps = + coverage + {[testenv]deps} [testenv:ruff] commands = ruff format --check src tests ruff check src tests -deps = ruff +deps = + ruff [testenv:mypy] commands = mypy --strict src -deps = mypy +deps = + mypy + python-socks