GeezerVison is showing github.com — [SPT-1668] Add proxy, custom CA, and connection pool options · massive-com/client-python@98e11ff
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: README.md +20Lines changed: 20 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -135,6 +135,26 @@ print(len(options_chain)) | 135 | 135 |
| 136 | 136 | Also, please refer to the API documentation to get a full understanding of how the API works and the supported arguments. All required arguments are annotated with red asterisks " * " and argument examples are set. | 137 | 137 |
| | 138 | +## Proxies, Certificates, and Connections | | 139 | + | | 140 | +Behind a corporate proxy or TLS inspection, point the clients at your CA bundle and proxy: | | 141 | + | | 142 | +```python | | 143 | +from massive import RESTClient, WebSocketClient | | 144 | + | | 145 | +client = RESTClient( | | 146 | + | | 147 | + ca_certs="/path/to/corporate-ca.pem", # or set SSL_CERT_FILE / REQUESTS_CA_BUNDLE | | 148 | +) | | 149 | + | | 150 | +ws_client = WebSocketClient(ca_certs="/path/to/corporate-ca.pem") | | 151 | +ws_client.run(handle_msg, proxy="http://user:[email protected]:8080") # defaults to the system proxy | | 152 | +``` | | 153 | + | | 154 | +Both clients verify TLS with `ca_certs` if given, then the `SSL_CERT_FILE` or `REQUESTS_CA_BUNDLE` environment variable, then the bundled certifi certificates. | | 155 | + | | 156 | +`RESTClient` keeps up to `maxsize=10` connections open for reuse across threads. Other urllib3 pool options can be passed with `connection_pool_kw`. | | 157 | + | 138 | 158 | ## Debugging with RESTClient | 139 | 159 |
| 140 | 160 | Sometimes you may find it useful to see the actual request and response details while working with the API. The `RESTClient` allows for this through its `trace=True` option. | |
Collapse file Copy file name to clipboard +17Lines changed: 17 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 1 | +import os | | 2 | +import certifi | | 3 | + | | 4 | + | | 5 | +def ca_bundle_path() -> str: | | 6 | + """ | | 7 | + Path of the CA bundle used to verify TLS connections. | | 8 | +
| | 9 | + Honors the standard ``SSL_CERT_FILE`` and ``REQUESTS_CA_BUNDLE`` environment | | 10 | + variables (used for example behind corporate TLS inspection), falling back to | | 11 | + the certifi bundle. | | 12 | + """ | | 13 | + return ( | | 14 | + os.environ.get("SSL_CERT_FILE") | | 15 | + or os.environ.get("REQUESTS_CA_BUNDLE") | | 16 | + or certifi.where() | | 17 | + ) |
Collapse file Copy file name to clipboardExpand all lines: massive/rest/__init__.py +22-1Lines changed: 22 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
| 20 | 20 | ContractsClient, | 21 | 21 | ) | 22 | 22 | from .vX import VXClient | 23 | | -from typing import Optional, Any | | 23 | +from typing import Optional, Any, Dict | 24 | 24 | import os | 25 | 25 |
| 26 | 26 | BASE = "https://api.massive.com" | @@ -60,7 +60,20 @@ def __init__( | 60 | 60 | verbose: bool = False, | 61 | 61 | trace: bool = False, | 62 | 62 | custom_json: Optional[Any] = None, | | 63 | + maxsize: int = 10, | | 64 | + ca_certs: Optional[str] = None, | | 65 | + proxy: Optional[str] = None, | | 66 | + connection_pool_kw: Optional[Dict[str, Any]] = None, | 63 | 67 | ): | | 68 | + """ | | 69 | + :param maxsize: Connections kept open per host, for reuse across threads. | | 70 | + :param ca_certs: Path to a CA bundle to verify TLS with. Defaults to the | | 71 | + SSL_CERT_FILE or REQUESTS_CA_BUNDLE environment variable, then certifi. | | 72 | + :param proxy: Proxy URL to send requests through, e.g. "http://user:pass@proxy:8080". | | 73 | + Environment proxy variables (HTTPS_PROXY etc.) are not used. | | 74 | + :param connection_pool_kw: Other urllib3 PoolManager options. They override | | 75 | + the client's defaults. | | 76 | + """ | 64 | 77 | super().__init__( | 65 | 78 | api_key=api_key, | 66 | 79 | connect_timeout=connect_timeout, | @@ -72,6 +85,10 @@ def __init__( | 72 | 85 | verbose=verbose, | 73 | 86 | trace=trace, | 74 | 87 | custom_json=custom_json, | | 88 | + maxsize=maxsize, | | 89 | + ca_certs=ca_certs, | | 90 | + proxy=proxy, | | 91 | + connection_pool_kw=connection_pool_kw, | 75 | 92 | ) | 76 | 93 | self.vx = VXClient( | 77 | 94 | api_key=api_key, | @@ -84,4 +101,8 @@ def __init__( | 84 | 101 | verbose=verbose, | 85 | 102 | trace=trace, | 86 | 103 | custom_json=custom_json, | | 104 | + maxsize=maxsize, | | 105 | + ca_certs=ca_certs, | | 106 | + proxy=proxy, | | 107 | + connection_pool_kw=connection_pool_kw, | 87 | 108 | ) |
Collapse file Copy file name to clipboardExpand all lines: massive/rest/base.py +29-9Lines changed: 29 additions & 9 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | | -import certifi | 2 | 1 | import json | 3 | 2 | import urllib3 | 4 | 3 | import inspect | | 8 | 7 | from datetime import datetime | 9 | 8 | from importlib.metadata import version, PackageNotFoundError | 10 | 9 | from .models.request import RequestOptionBuilder | | 10 | +from ..certs import ca_bundle_path | 11 | 11 | from ..logging import get_logger | 12 | 12 | import logging | 13 | 13 | from urllib.parse import urlencode, urlparse | @@ -34,6 +34,10 @@ def __init__( | 34 | 34 | verbose: bool, | 35 | 35 | trace: bool, | 36 | 36 | custom_json: Optional[Any] = None, | | 37 | + maxsize: int = 10, | | 38 | + ca_certs: Optional[str] = None, | | 39 | + proxy: Optional[str] = None, | | 40 | + connection_pool_kw: Optional[Dict[str, Any]] = None, | 37 | 41 | ): | 38 | 42 | if api_key is None: | 39 | 43 | raise AuthError( | @@ -73,14 +77,30 @@ def __init__( | 73 | 77 |
| 74 | 78 | # https://urllib3.readthedocs.io/en/stable/reference/urllib3.poolmanager.html | 75 | 79 | # https://urllib3.readthedocs.io/en/stable/reference/urllib3.connectionpool.html#urllib3.HTTPConnectionPool | 76 | | - self.client = urllib3.PoolManager( | 77 | | - num_pools=num_pools, | 78 | | - headers=self.headers, # default headers sent with each request. | 79 | | - ca_certs=certifi.where(), | 80 | | - cert_reqs="CERT_REQUIRED", | 81 | | - retries=retry_strategy, # use the customized Retry instance | 82 | | - timeout=self.timeout, # set timeout for each request | 83 | | - ) | | 80 | + pool_kw: Dict[str, Any] = { | | 81 | + "num_pools": num_pools, | | 82 | + # connections kept per host; urllib3 defaults to 1, which makes threaded | | 83 | + # clients reconnect (and redo the TLS handshake) for most requests | | 84 | + "maxsize": maxsize, | | 85 | + "headers": self.headers, # default headers sent with each request. | | 86 | + "ca_certs": ca_certs or ca_bundle_path(), | | 87 | + "cert_reqs": "CERT_REQUIRED", | | 88 | + "retries": retry_strategy, # use the customized Retry instance | | 89 | + "timeout": self.timeout, # set timeout for each request | | 90 | + } | | 91 | + # any other urllib3 pool options, which take precedence over the defaults | | 92 | + pool_kw.update(connection_pool_kw or {}) | | 93 | + self.client: urllib3.PoolManager | | 94 | + if proxy: | | 95 | + # https://urllib3.readthedocs.io/en/stable/advanced-usage.html#proxies | | 96 | + proxy_auth = urllib3.util.parse_url(proxy).auth | | 97 | + if proxy_auth: | | 98 | + pool_kw.setdefault( | | 99 | + "proxy_headers", urllib3.make_headers(proxy_basic_auth=proxy_auth) | | 100 | + ) | | 101 | + self.client = urllib3.ProxyManager(proxy, **pool_kw) | | 102 | + else: | | 103 | + self.client = urllib3.PoolManager(**pool_kw) | 84 | 104 |
| 85 | 105 | if verbose: | 86 | 106 | logger.setLevel(logging.DEBUG) | |
Collapse file Copy file name to clipboardExpand all lines: massive/websocket/__init__.py +13-6Lines changed: 13 additions & 6 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 7 | 7 | import asyncio | 8 | 8 | import ssl | 9 | 9 | import time | 10 | | -import certifi | 11 | 10 | from .models import * | 12 | 11 | from websockets.asyncio.client import connect, ClientConnection | 13 | 12 | from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError | | 13 | +from ..certs import ca_bundle_path | 14 | 14 | from ..logging import get_logger | 15 | 15 | import logging | 16 | 16 | from ..exceptions import AuthError | @@ -35,6 +35,7 @@ def __init__( | 35 | 35 | max_reconnects: Optional[int] = 5, | 36 | 36 | secure: bool = True, | 37 | 37 | custom_json: Optional[Any] = None, | | 38 | + ca_certs: Optional[str] = None, | 38 | 39 | **kwargs, | 39 | 40 | ): | 40 | 41 | """ | @@ -47,6 +48,7 @@ def __init__( | 47 | 48 | :param subscriptions: List of subscription parameters. | 48 | 49 | :param max_reconnects: How many times to reconnect on network outage before ending .connect event loop. | 49 | 50 | :param custom_json: Optional module exposing loads/dumps functions (similar to Python's json module) to be used for JSON conversions. | | 51 | + :param ca_certs: Path to a CA bundle to verify TLS with. Defaults to the SSL_CERT_FILE or REQUESTS_CA_BUNDLE environment variable, then certifi. | 50 | 52 | :return: A client. | 51 | 53 | """ | 52 | 54 | if api_key is None: | @@ -78,6 +80,7 @@ def __init__( | 78 | 80 | self.schedule_resub = True | 79 | 81 | self._server_errors: Counter = Counter() | 80 | 82 | self._server_errors_since = 0.0 | | 83 | + self.ca_certs = ca_certs | 81 | 84 | if custom_json: | 82 | 85 | self.json = custom_json | 83 | 86 | else: | @@ -102,11 +105,7 @@ async def connect( | 102 | 105 | """ | 103 | 106 | reconnects = 0 | 104 | 107 | logger.debug("connect: %s", self.url) | 105 | | - # darwin needs some extra <3 | 106 | | - ssl_context = None | 107 | | - if self.url.startswith("wss://"): | 108 | | - ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) | 109 | | - ssl_context.load_verify_locations(certifi.where()) | | 108 | + ssl_context = self._ssl_context() | 110 | 109 |
| 111 | 110 | last_exc = None | 112 | 111 | async for s in connect( | @@ -190,6 +189,14 @@ async def connect( | 190 | 189 | ): | 191 | 190 | raise last_exc | 192 | 191 |
| | 192 | + def _ssl_context(self) -> Optional[ssl.SSLContext]: | | 193 | + # darwin needs some extra <3 | | 194 | + if not self.url.startswith("wss://"): | | 195 | + return None | | 196 | + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) | | 197 | + context.load_verify_locations(self.ca_certs or ca_bundle_path()) | | 198 | + return context | | 199 | + | 193 | 200 | def run( | 194 | 201 | self, | 195 | 202 | handle_msg: Union[ | |
Collapse file Copy file name to clipboard +65Lines changed: 65 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 1 | +import os | | 2 | +import unittest | | 3 | +from unittest import mock | | 4 | + | | 5 | +import certifi | | 6 | +import urllib3 | | 7 | + | | 8 | +from massive import RESTClient | | 9 | + | | 10 | + | | 11 | +class ConnectionOptionsTest(unittest.TestCase): | | 12 | + def client(self, env=None, **kwargs): | | 13 | + with mock.patch.dict(os.environ, env or {}, clear=True): | | 14 | + return RESTClient(api_key="key", **kwargs) | | 15 | + | | 16 | + def test_ca_bundle_defaults_to_certifi(self): | | 17 | + c = self.client() | | 18 | + self.assertEqual(c.client.connection_pool_kw["ca_certs"], certifi.where()) | | 19 | + | | 20 | + def test_ca_bundle_from_env(self): | | 21 | + c = self.client({"REQUESTS_CA_BUNDLE": "/requests-ca.pem"}) | | 22 | + self.assertEqual(c.client.connection_pool_kw["ca_certs"], "/requests-ca.pem") | | 23 | + c = self.client( | | 24 | + {"SSL_CERT_FILE": "/ssl-ca.pem", "REQUESTS_CA_BUNDLE": "/requests-ca.pem"} | | 25 | + ) | | 26 | + self.assertEqual(c.client.connection_pool_kw["ca_certs"], "/ssl-ca.pem") | | 27 | + | | 28 | + def test_ca_certs_argument_wins_over_env(self): | | 29 | + c = self.client({"SSL_CERT_FILE": "/ssl-ca.pem"}, ca_certs="/explicit-ca.pem") | | 30 | + self.assertEqual(c.client.connection_pool_kw["ca_certs"], "/explicit-ca.pem") | | 31 | + | | 32 | + def test_ca_certs_applies_to_vx(self): | | 33 | + c = self.client(ca_certs="/explicit-ca.pem") | | 34 | + self.assertEqual(c.vx.client.connection_pool_kw["ca_certs"], "/explicit-ca.pem") | | 35 | + | | 36 | + def test_maxsize(self): | | 37 | + self.assertEqual(self.client().client.connection_pool_kw["maxsize"], 10) | | 38 | + c = self.client(maxsize=32) | | 39 | + self.assertEqual(c.client.connection_pool_kw["maxsize"], 32) | | 40 | + | | 41 | + def test_connection_pool_kw_overrides_defaults(self): | | 42 | + timeout = urllib3.Timeout(connect=1, read=2) | | 43 | + c = self.client(connection_pool_kw={"block": True, "timeout": timeout}) | | 44 | + self.assertTrue(c.client.connection_pool_kw["block"]) | | 45 | + self.assertIs(c.client.connection_pool_kw["timeout"], timeout) | | 46 | + # defaults that weren't overridden are kept | | 47 | + self.assertEqual(c.client.connection_pool_kw["cert_reqs"], "CERT_REQUIRED") | | 48 | + | | 49 | + def test_no_proxy_by_default(self): | | 50 | + c = self.client({"HTTPS_PROXY": "http://proxy.example:8080"}) | | 51 | + self.assertNotIsInstance(c.client, urllib3.ProxyManager) | | 52 | + | | 53 | + def test_proxy(self): | | 54 | + | | 55 | + self.assertIsInstance(c.client, urllib3.ProxyManager) | | 56 | + self.assertEqual(c.client.proxy.host, "proxy.example") | | 57 | + self.assertEqual( | | 58 | + c.client.proxy_headers["proxy-authorization"], | | 59 | + urllib3.make_headers(proxy_basic_auth="user:secret")["proxy-authorization"], | | 60 | + ) | | 61 | + self.assertEqual(c.client.connection_pool_kw["maxsize"], 10) | | 62 | + | | 63 | + | | 64 | +if __name__ == "__main__": | | 65 | + unittest.main() |
Collapse file Copy file name to clipboard +39Lines changed: 39 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 1 | +import os | | 2 | +import unittest | | 3 | +from unittest import mock | | 4 | + | | 5 | +import certifi | | 6 | + | | 7 | +from massive import WebSocketClient | | 8 | + | | 9 | + | | 10 | +class WebSocketSSLTest(unittest.TestCase): | | 11 | + def ca_path(self, env=None, **kwargs): | | 12 | + with mock.patch.dict(os.environ, env or {}, clear=True): | | 13 | + c = WebSocketClient(api_key="key", **kwargs) | | 14 | + with mock.patch("massive.websocket.ssl.SSLContext") as context: | | 15 | + c._ssl_context() | | 16 | + return context.return_value.load_verify_locations.call_args.args[0] | | 17 | + | | 18 | + def test_defaults_to_certifi(self): | | 19 | + self.assertEqual(self.ca_path(), certifi.where()) | | 20 | + | | 21 | + def test_env(self): | | 22 | + self.assertEqual(self.ca_path({"SSL_CERT_FILE": "/ssl-ca.pem"}), "/ssl-ca.pem") | | 23 | + self.assertEqual( | | 24 | + self.ca_path({"REQUESTS_CA_BUNDLE": "/requests-ca.pem"}), "/requests-ca.pem" | | 25 | + ) | | 26 | + | | 27 | + def test_ca_certs_argument_wins_over_env(self): | | 28 | + self.assertEqual( | | 29 | + self.ca_path({"SSL_CERT_FILE": "/ssl-ca.pem"}, ca_certs="/explicit-ca.pem"), | | 30 | + "/explicit-ca.pem", | | 31 | + ) | | 32 | + | | 33 | + def test_no_context_without_tls(self): | | 34 | + c = WebSocketClient(api_key="key", feed="localhost:8765", secure=False) | | 35 | + self.assertIsNone(c._ssl_context()) | | 36 | + | | 37 | + | | 38 | +if __name__ == "__main__": | | 39 | + unittest.main() |
|