diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..c091dfa --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,62 @@ +name: Tests + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + 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" + - "3.10" + - "3.11" + - "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 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - run: python -m pip install tox + - run: python -m tox -e py + + protobuf: + 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" + + 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 ${{ matrix.python.tox }}-protobuf${{ matrix.protobuf-version }} 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/ 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 464f168..af5ff08 100644 --- a/setup.py +++ b/setup.py @@ -26,15 +26,19 @@ "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,<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'", "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, @@ -60,4 +64,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/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 new file mode 100644 index 0000000..32ee4d0 --- /dev/null +++ b/tox.ini @@ -0,0 +1,20 @@ +[tox] +envlist = py{38,39,310,311,312,313,314,315}, py{314,315}-protobuf{6,7} +skip_missing_interpreters = true + +[testenv] +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:py{314,315}-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 tests.compat.test_runtime -v