From 10d7472b1f05ca25aa5a32d487f793cab8a26dba Mon Sep 17 00:00:00 2001 From: Sean Hayes Date: Wed, 2 Sep 2026 19:38:10 -0700 Subject: [PATCH 1/4] test: add tox Python version matrix --- .github/workflows/tests.yml | 32 ++++++++++++++++++++++++++++++++ tox.ini | 7 +++++++ 2 files changed, 39 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 tox.ini diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..0db2db7 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: Tests + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - run: python -m pip install tox + - run: python -m tox -e py diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..e0f8433 --- /dev/null +++ b/tox.ini @@ -0,0 +1,7 @@ +[tox] +envlist = py{38,39,310,311,312,313,314} +skip_missing_interpreters = true + +[testenv] +description = run the test suite with {basepython} +commands = python -m unittest discover -s tests -v From 4cabc010027be736f17aaa9f4307d62899abc25b Mon Sep 17 00:00:00 2001 From: Sean Hayes Date: Wed, 2 Sep 2026 19:40:29 -0700 Subject: [PATCH 2/4] test: support protobuf 6 and 7 --- .github/workflows/tests.yml | 18 ++++++++++++++++++ setup.py | 6 +++--- tests/compat/__init__.py | 0 tests/compat/test_protobuf.py | 27 +++++++++++++++++++++++++++ tox.ini | 18 +++++++++++++++--- 5 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 tests/compat/__init__.py create mode 100644 tests/compat/test_protobuf.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0db2db7..d5c922c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,3 +30,21 @@ jobs: python-version: ${{ matrix.python-version }} - run: python -m pip install tox - run: python -m tox -e py + + protobuf: + name: Protobuf ${{ matrix.protobuf-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + protobuf-version: + - "6" + - "7" + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v6 + with: + python-version: "3.14" + - run: python -m pip install tox + - run: python -m tox -e py314-protobuf${{ matrix.protobuf-version }} diff --git a/setup.py b/setup.py index 464f168..85f562a 100644 --- a/setup.py +++ b/setup.py @@ -28,8 +28,8 @@ "cryptography>=43.0,<55; python_version>='3.14'", # protobuf - "protobuf>=4.21.12,<5; python_version<'3.12'", - "protobuf>=4.25.0,<6; python_version>='3.12'", + "protobuf>=4.21.12,<8; python_version<'3.12'", + "protobuf>=4.25.0,<8; python_version>='3.12'", # grpc "grpcio>=1.51.1,<1.60; python_version<'3.12'", @@ -60,4 +60,4 @@ 'install_requires': requires } -setup(name='webull-openapi-python-sdk', **setup_args) \ No newline at end of file +setup(name='webull-openapi-python-sdk', **setup_args) diff --git a/tests/compat/__init__.py b/tests/compat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/compat/test_protobuf.py b/tests/compat/test_protobuf.py new file mode 100644 index 0000000..90d7504 --- /dev/null +++ b/tests/compat/test_protobuf.py @@ -0,0 +1,27 @@ +import os +import unittest + +import google.protobuf + +from webull.data.quotes.subscribe.message_pb2 import Snapshot +from webull.trade.events.events_pb2 import SubscribeRequest + + +class TestProtobufCompatibility(unittest.TestCase): + + def test_expected_protobuf_major_is_installed(self): + expected_major = os.environ["EXPECTED_PROTOBUF_MAJOR"] + + self.assertEqual(google.protobuf.__version__.split(".", 1)[0], expected_major) + + def test_generated_messages_round_trip(self): + messages = ( + Snapshot(price="123.45", volume="10"), + SubscribeRequest(subscribeType=1, accounts=["account-id"]), + ) + + for message in messages: + with self.subTest(message=message.DESCRIPTOR.full_name): + restored = type(message)() + restored.ParseFromString(message.SerializeToString()) + self.assertEqual(restored, message) diff --git a/tox.ini b/tox.ini index e0f8433..85c9e81 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,19 @@ [tox] -envlist = py{38,39,310,311,312,313,314} +envlist = py{38,39,310,311,312,313,314}, py314-protobuf{6,7} skip_missing_interpreters = true [testenv] -description = run the test suite with {basepython} -commands = python -m unittest discover -s tests -v +description = run the offline unit tests with {basepython} +commands = + python -m unittest discover -s tests/core/retry -v + python -m unittest discover -s tests/data/common -v + +[testenv:py314-protobuf{6,7}] +description = test generated messages with a pinned protobuf major +deps = + protobuf6: protobuf>=6,<7 + protobuf7: protobuf>=7,<8 +setenv = + protobuf6: EXPECTED_PROTOBUF_MAJOR=6 + protobuf7: EXPECTED_PROTOBUF_MAJOR=7 +commands = python -m unittest tests.compat.test_protobuf -v From 7c6898a0a64819387c960b02b4eb40bc37687a4d Mon Sep 17 00:00:00 2001 From: Sean Hayes Date: Wed, 14 Oct 2026 13:46:03 -0300 Subject: [PATCH 3/4] Ignore generated development artifacts (maintenance). Keep Python bytecode, packaging outputs, local environments and macOS Finder metadata out of repository status. Source files and compatibility scripts remain visible for review. --- .gitignore | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aec07f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# macOS Finder metadata +.DS_Store + +# Python bytecode and packaging outputs +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ + +# Local development environments +.venv/ +.tox/ From 473bcfc9c9630419800e5007770444008555e7dd Mon Sep 17 00:00:00 2001 From: Sean Hayes Date: Wed, 14 Oct 2026 02:49:14 -0300 Subject: [PATCH 4/4] Add Python 3.15 support (feature). Accept Python 3.15 in package metadata while preserving Python 3.8 through 3.14 dependency constraints. Require gRPC 1.84 on 3.15 for its published wheels and CFFI 2.1.1 to avoid the documented interpreter ABI issue. Extend tox and CI to Python 3.15, including Linux/macOS/Windows jobs and the Protobuf 6/7 compatibility matrix. Add offline HTTP-client, RSA signing, MQTT/TLS, quote decoding and bounded local gRPC behavior/error checks. Validation passed all six Python 3.14/3.15 tox environments, including both Protobuf majors, and verified installation plus behavior of the built wheel under final Python 3.15. Linux and Windows native-wheel resolution succeeded; those runtime CI jobs have not been executed locally. No live Webull endpoints or credentials are required for this compatibility suite. Handoff summary --------------- The package admits standard CPython 3.8 through 3.15. Existing native-library constraints are unchanged for older interpreters; only 3.15 gets the gRPC/CFFI floors. Python 3.16 remains excluded pending compatibility validation. The checked-in generated Protobuf modules require no regeneration for this change. Build a distributable wheel with the intended interpreter before release: ```bash python3.15 -m pip wheel --no-deps . --wheel-dir /tmp/webull-wheel ``` Pure-Python Paho 1.6.1 is distributed as source and is built during dependency installation; the interpreter-sensitive dependencies resolve from wheels. --- .github/workflows/tests.yml | 22 +++- README.md | 23 +++- setup.py | 8 +- tests/compat/test_runtime.py | 242 +++++++++++++++++++++++++++++++++++ tox.ini | 7 +- 5 files changed, 290 insertions(+), 12 deletions(-) create mode 100644 tests/compat/test_runtime.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d5c922c..c091dfa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,11 +9,12 @@ permissions: jobs: test: - name: Python ${{ matrix.python-version }} - runs-on: ubuntu-latest + name: Python ${{ matrix.python-version }} (${{ matrix.os }}) + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: + os: [ubuntu-latest] python-version: - "3.8" - "3.9" @@ -22,6 +23,12 @@ jobs: - "3.12" - "3.13" - "3.14" + - "3.15" + include: + - os: macos-latest + python-version: "3.15" + - os: windows-latest + python-version: "3.15" steps: - uses: actions/checkout@v4 @@ -32,11 +39,16 @@ jobs: - run: python -m tox -e py protobuf: - name: Protobuf ${{ matrix.protobuf-version }} + name: Python ${{ matrix.python.version }} / Protobuf ${{ matrix.protobuf-version }} runs-on: ubuntu-latest strategy: fail-fast: false matrix: + python: + - version: "3.14" + tox: py314 + - version: "3.15" + tox: py315 protobuf-version: - "6" - "7" @@ -45,6 +57,6 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v6 with: - python-version: "3.14" + python-version: ${{ matrix.python.version }} - run: python -m pip install tox - - run: python -m tox -e py314-protobuf${{ matrix.protobuf-version }} + - run: python -m tox -e ${{ matrix.python.tox }}-protobuf${{ matrix.protobuf-version }} diff --git a/README.md b/README.md index 402643c..b7c099c 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Subscription to real-time information: Subscribe to order status changes, market | Webull MX | https://www.webull.com.mx/open-api-management | | Webull ZA | https://www.webull.co.za/open-api-management | | Webull EU | https://www.webull.eu/open-api-management | -- Requires Python 3.8 through 3.14. +- Requires Python 3.8 through 3.15. ## Interface Protocol @@ -59,4 +59,23 @@ The bottom layer of Webull OpenAPI provides three protocols, HTTP / GRPC / MQTT, | Webull BR | https://developer.webull.com.br/apis/docs | | Webull MX | https://developer.webull.com.mx/apis/docs | | Webull ZA | https://developer.webull.co.za/apis/docs | -| Webull EU | https://developer.webull.eu/apis/docs | \ No newline at end of file +| Webull EU | https://developer.webull.eu/apis/docs | + +## Compatibility tests + +The tox suite runs offline retry, quote-category, HTTP-client construction, +signing, MQTT/TLS, Protobuf decoding, and local gRPC streaming checks. Python +3.15 CI covers Linux, macOS, and Windows. Protobuf 6 and 7 are also tested on +Python 3.14 and 3.15. + +```bash +python -m pip install tox +python -m tox -e py315,py315-protobuf6,py315-protobuf7 +``` + +Python 3.15 selects [gRPC 1.84 or newer](https://github.com/grpc/grpc/releases/tag/v1.84.0) +for published interpreter-specific wheels and [CFFI 2.1.1 or newer](https://cryptography.io/en/latest/changelog/) +to avoid an earlier ABI issue. The test suite uses local +sockets and requires no Webull credentials. Other examples under `tests/data` +and `tests/trade` include remote calls with placeholder credentials and are +manual integration examples. diff --git a/setup.py b/setup.py index 85f562a..af5ff08 100644 --- a/setup.py +++ b/setup.py @@ -26,6 +26,8 @@ "cryptography>=3.4,<42; python_version<'3.12'", "cryptography>=41.0,<43; python_version>='3.12' and python_version<'3.14'", "cryptography>=43.0,<55; python_version>='3.14'", + # Earlier CFFI releases have a Python 3.15 ABI issue (see cryptography's 51.0.0 changelog). + "cffi>=2.1.1; python_version>='3.15'", # protobuf "protobuf>=4.21.12,<8; python_version<'3.12'", @@ -34,7 +36,9 @@ # grpc "grpcio>=1.51.1,<1.60; python_version<'3.12'", "grpcio>=1.60.0,<1.70; python_version>='3.12' and python_version<'3.14'", - "grpcio>=1.75.1,<2; python_version>='3.14'" + "grpcio>=1.75.1,<2; python_version>='3.14' and python_version<'3.15'", + # gRPC 1.84 is the first release with public CPython 3.15 wheels. + "grpcio>=1.84.0,<2; python_version>='3.15'" ] extras_require = { @@ -45,7 +49,7 @@ } setup_args = { - 'python_requires':'>=3.8,<3.15', + 'python_requires':'>=3.8,<3.16', 'version': VERSION, 'author': AUTHOR, 'author_email': AUTHOR_EMAIL, diff --git a/tests/compat/test_runtime.py b/tests/compat/test_runtime.py new file mode 100644 index 0000000..972f34d --- /dev/null +++ b/tests/compat/test_runtime.py @@ -0,0 +1,242 @@ +"""Offline checks of the SDK's interpreter and native-library boundaries.""" + +import base64 +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager, redirect_stdout +from datetime import datetime +from decimal import Decimal, InvalidOperation +import io +import json +import ssl +import unittest +from unittest.mock import patch + +import grpc +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from google.protobuf.message import DecodeError +from requests import Response + +import webull.core.headers as headers +from webull.core.auth.algorithm import sha_hmac256 +from webull.core.client import ApiClient +from webull.data.data_client import DataClient +from webull.data.data_streaming_client import DataStreamingClient +from webull.data.quotes.subscribe import message_pb2 +from webull.data.quotes.subscribe.quote_decoder import QuoteDecoder +from webull.data.quotes.subscribe.snapshot_decoder import SnapshotDecoder +from webull.trade.events import events_pb2, events_pb2_grpc +from webull.trade.trade_client import TradeClient +from webull.trade.trade_events_client import TradeEventsClient + + +class TestClientCompatibility(unittest.TestCase): + def test_http_clients_construct_with_token_configuration(self): + api_client = ApiClient("compat-app", "compat-secret", "us") + # Constructor checks should not create the SDK's default log files. + api_client._stream_logger_set = True + response = Response() + response.status_code = 200 + response._content = b'{"token_check_enabled": false}' + + with patch.object( + api_client, "get_response", return_value=response + ) as get_response: + data_client = DataClient(api_client) + trade_client = TradeClient(api_client) + + self.assertIs(data_client.market_data.client, api_client) + self.assertIs(trade_client.account_v2.client, api_client) + self.assertEqual(get_response.call_count, 2) + for call in get_response.call_args_list: + self.assertEqual(call.args[0].get_action_name(), "/openapi/config") + self.assertTrue(api_client.default_user_agent()["python-requests"]) + + def test_mqtt_client_initializes_with_and_without_tls(self): + for tls_enable in (False, True): + with self.subTest(tls_enable=tls_enable): + client = DataStreamingClient( + "compat-app", + "compat-secret", + "us", + "compat-session", + http_host="127.0.0.1", + mqtt_host="127.0.0.1", + tls_enable=tls_enable, + ) + self.addCleanup(client.disconnect) + self.assertEqual(client.quotes_session_id, "compat-session") + self.assertEqual(client.api_client.get_app_key(), "compat-app") + self.assertIsNone(client.socket()) + if tls_enable: + self.assertIsInstance(client._ssl_context, ssl.SSLContext) + self.assertEqual(client._ssl_context.verify_mode, ssl.CERT_REQUIRED) + self.assertTrue(client._ssl_context.check_hostname) + else: + self.assertIsNone(client._ssl_context) + + +class TestSigningCompatibility(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + private_der = cls.private_key.private_bytes( + serialization.Encoding.DER, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + cls.secret = base64.b64encode(private_der).decode("ascii") + + def test_rsa_signature_verifies_and_rejects_changed_payload(self): + payload = "compatibility request \N{EURO SIGN}" + signature = base64.b64decode(sha_hmac256.get_sign_string(payload, self.secret)) + public_key = self.private_key.public_key() + public_key.verify( + signature, payload.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256() + ) + with self.assertRaises(InvalidSignature): + public_key.verify( + signature, b"changed payload", padding.PKCS1v15(), hashes.SHA256() + ) + + def test_rsa_signing_rejects_malformed_private_key(self): + malformed_secret = base64.b64encode(b"not a DER private key").decode("ascii") + with self.assertRaises(ValueError): + sha_hmac256.get_sign_string("compatibility request", malformed_secret) + + +class TestQuoteDecodingCompatibility(unittest.TestCase): + def test_snapshot_decodes_price_and_timestamp(self): + snapshot = message_pb2.Snapshot( + basic=message_pb2.Basic(symbol="TEST", timestamp="1700000000123"), + trade_time="1700000000000", + price="123.45", + volume="10", + ) + result = SnapshotDecoder().parse(snapshot.SerializeToString()) + self.assertEqual(result.get_basic().get_symbol(), "TEST") + self.assertEqual( + result.get_basic().get_timestamp_as_utc(), + datetime(2023, 11, 14, 22, 13, 20, 123000), + ) + self.assertEqual(result.get_last_trade_time(), 1700000000000) + self.assertEqual(result.get_price(), Decimal("123.45")) + self.assertEqual(result.get_volume(), Decimal("10")) + self.assertIsNone(result.get_ext_price()) + + def test_quote_decodes_repeated_order_book_entries(self): + quote = message_pb2.Quote( + basic=message_pb2.Basic(symbol="TEST", timestamp="1700000000123"), + asks=[message_pb2.AskBid(price="123.46", size="2")], + bids=[message_pb2.AskBid(price="123.44", size="3")], + ) + result = QuoteDecoder().parse(quote.SerializeToString()) + self.assertEqual( + [entry.get_price() for entry in result.get_asks()], [Decimal("123.46")] + ) + self.assertEqual([entry.get_size() for entry in result.get_bids()], ["3"]) + + def test_snapshot_rejects_invalid_wire_and_numeric_payloads(self): + malformed_price = message_pb2.Snapshot( + basic=message_pb2.Basic(timestamp="1700000000123"), + price="invalid", + ).SerializeToString() + for payload, exception_type in ( + (b"\x0a\x05x", DecodeError), + (malformed_price, InvalidOperation), + ): + with self.subTest(exception_type=exception_type): + with self.assertRaises(exception_type): + SnapshotDecoder().parse(payload) + + +class TestGrpcCompatibility(unittest.TestCase): + @contextmanager + def _serve(self, servicer): + with ThreadPoolExecutor(max_workers=1) as executor: + server = grpc.server(executor) + events_pb2_grpc.add_EventServiceServicer_to_server(servicer, server) + port = server.add_insecure_port("127.0.0.1:0") + self.assertNotEqual(port, 0) + server.start() + try: + yield "127.0.0.1:{}".format(port) + finally: + self.assertTrue( + server.stop(0).wait(timeout=5), "gRPC server did not stop" + ) + + def test_signed_subscription_stream_dispatches_protobuf_and_json(self): + requests = [] + metadata = [] + + class EventService(events_pb2_grpc.EventServiceServicer): + def Subscribe(self, request, context): + requests.append(request) + metadata.extend(context.invocation_metadata()) + yield events_pb2.SubscribeResponse( + eventType=events_pb2.SubscribeSuccess, payload="connected" + ) + yield events_pb2.SubscribeResponse(eventType=events_pb2.Ping) + yield events_pb2.SubscribeResponse( + eventType=8, + subscribeType=7, + contentType="application/json", + payload='{"status": "filled", "quantity": 2}', + ) + + client = TradeEventsClient("compat-app", "compat-secret", host="127.0.0.1") + connected = [] + messages = [] + client.on_connect = lambda client, payload, response: connected.append(payload) + client.on_events_message = lambda event, subscription, payload, response: ( + messages.append((event, subscription, payload)) + ) + with redirect_stdout(io.StringIO()): + request, sign_metadata = client._build_request( + "compat-app", "compat-secret", ["compat-account"] + ) + with self._serve(EventService()) as target: + with grpc.insecure_channel(target) as channel: + stub = events_pb2_grpc.EventServiceStub(channel) + for response in stub.Subscribe( + request, metadata=sign_metadata, timeout=5 + ): + client._easy_handler(response) + + self.assertEqual(list(requests[0].accounts), ["compat-account"]) + self.assertEqual(dict(metadata)[headers.APP_KEY], "compat-app") + self.assertTrue(dict(metadata)[headers.SIGNATURE]) + self.assertEqual(connected, ["connected"]) + self.assertEqual(messages, [(8, 7, {"status": "filled", "quantity": 2})]) + + def test_event_handler_rejects_malformed_json_before_callback(self): + client = TradeEventsClient("compat-app", "compat-secret", host="127.0.0.1") + messages = [] + client.on_events_message = lambda *args: messages.append(args) + response = events_pb2.SubscribeResponse( + eventType=8, contentType="application/json", payload="{invalid" + ) + with self.assertRaises(json.JSONDecodeError): + client._easy_handler(response) + self.assertEqual(messages, []) + + def test_subscription_surfaces_grpc_error_status(self): + class RejectedService(events_pb2_grpc.EventServiceServicer): + def Subscribe(self, request, context): + context.abort( + grpc.StatusCode.PERMISSION_DENIED, "compatibility rejection" + ) + + with self._serve(RejectedService()) as target: + with grpc.insecure_channel(target) as channel: + stub = events_pb2_grpc.EventServiceStub(channel) + with self.assertRaises(grpc.RpcError) as raised: + list(stub.Subscribe(events_pb2.SubscribeRequest(), timeout=5)) + self.assertEqual(raised.exception.code(), grpc.StatusCode.PERMISSION_DENIED) + self.assertEqual(raised.exception.details(), "compatibility rejection") + + +if __name__ == "__main__": + unittest.main() diff --git a/tox.ini b/tox.ini index 85c9e81..32ee4d0 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{38,39,310,311,312,313,314}, py314-protobuf{6,7} +envlist = py{38,39,310,311,312,313,314,315}, py{314,315}-protobuf{6,7} skip_missing_interpreters = true [testenv] @@ -7,8 +7,9 @@ description = run the offline unit tests with {basepython} commands = python -m unittest discover -s tests/core/retry -v python -m unittest discover -s tests/data/common -v + python -m unittest tests.compat.test_runtime -v -[testenv:py314-protobuf{6,7}] +[testenv:py{314,315}-protobuf{6,7}] description = test generated messages with a pinned protobuf major deps = protobuf6: protobuf>=6,<7 @@ -16,4 +17,4 @@ deps = setenv = protobuf6: EXPECTED_PROTOBUF_MAJOR=6 protobuf7: EXPECTED_PROTOBUF_MAJOR=7 -commands = python -m unittest tests.compat.test_protobuf -v +commands = python -m unittest tests.compat.test_protobuf tests.compat.test_runtime -v