diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9436b95..ec98fbf 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,9 +2,20 @@ version: 2 updates: - package-ecosystem: "gradle" directory: "/client-samples/java/rest" - schedule: - interval: "weekly" - day: "monday" - time: "08:00" commit-message: - prefix: "gradle" \ No newline at end of file + prefix: "gradle" + + - package-ecosystem: "pip" + directory: "/client-samples/python/rest" + commit-message: + prefix: "pip-rest" + + - package-ecosystem: "pip" + directory: "/client-samples/python/websockets" + commit-message: + prefix: "pip-websocket" + +schedule: + interval: "monthly" + day: "monday" + time: "08:00" \ No newline at end of file diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml new file mode 100644 index 0000000..93877c3 --- /dev/null +++ b/.github/workflows/python-ci.yml @@ -0,0 +1,33 @@ +name: Python Tests + +on: + pull_request: + branches: + - main +jobs: + python-checkout-and-test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + working-directory: + - ./client-samples/python/rest + - ./client-samples/python/websockets + defaults: + run: + working-directory: ${{ matrix.working-directory }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Upgrade pip + run: python -m pip install --upgrade pip + - name: Install dependencies + run: pip install -r requirements.txt + - name: Run pytest + run: pytest tests/ + - name: Run black check (linting) + run: black --check . \ No newline at end of file diff --git a/client-samples/python/rest/.gitignore b/client-samples/python/rest/.gitignore new file mode 100644 index 0000000..fafa371 --- /dev/null +++ b/client-samples/python/rest/.gitignore @@ -0,0 +1,172 @@ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# PyPI configuration file +.pypirc diff --git a/client-samples/python/rest/README.md b/client-samples/python/rest/README.md index 72f0253..316565a 100644 --- a/client-samples/python/rest/README.md +++ b/client-samples/python/rest/README.md @@ -45,19 +45,19 @@ Once Python is installed you can run following to launch the application. Please Windows: ```cmd -python -m venv virtualenv -.\virtualenv\Scripts\activate +python -m venv venv +.\venv\Scripts\activate python -m pip install --upgrade pip -python -m pip install -r requirements.txt +pip install -r requirements.txt python client-application.py ``` Mac/Linux: ```bash -python -m venv virtualenv -. virtualenv/bin/activate +python -m venv venv +. venv/bin/activate python -m pip install --upgrade pip pip install -r requirements.txt @@ -66,6 +66,12 @@ python client-application.py The application will launch and connect to the Morgan Stanley API offering and output the result. +## Testing +The tests have been built using the `unittest` framework, but can be run using the `pytest` command. +```bash +pytest tests +``` + ## Linting This project uses `black` to lint its source code for readability. To lint the code from inside the virtual env please run the following: diff --git a/client-samples/python/rest/client-application.py b/client-samples/python/rest/client_application.py old mode 100755 new mode 100644 similarity index 86% rename from client-samples/python/rest/client-application.py rename to client-samples/python/rest/client_application.py index 50d3ed2..014d5ad --- a/client-samples/python/rest/client-application.py +++ b/client-samples/python/rest/client_application.py @@ -1,19 +1,17 @@ -# Morgan Stanley makes this available to you under the Apache License, Version 2.0 (the "License"). You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +# Morgan Stanley makes this available to you under the Apache License, Version 2.0 (the "License"). You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. # See the NOTICE file distributed with this work for additional information regarding copyright ownership. -# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and limitations under the License. from msal import ConfidentialClientApplication -import sys import json -import logging import requests -import time -from typing import List +from typing import List, Union # uncomment this line for DEBUG level logging in case of errors # logging.basicConfig(level=logging.DEBUG) + def load_config(config_file: str): """ Load the config map from a JSON file with the given path. @@ -40,7 +38,7 @@ def load_private_key(private_key_file: str): return f.read() -def get_proxies(config: dict) -> dict | None: +def get_proxies(config: dict) -> Union[dict, None]: """ Returns proxy config from the config dictionary if the correct config has been provided. Otherwise returns None. @@ -63,7 +61,7 @@ def get_proxies(config: dict) -> dict | None: return proxies -def get_requests_ca_bundle(config: dict) -> str | bool: +def get_requests_ca_bundle(config: dict) -> Union[str, bool]: """ Get the system CA bundle, if it's set. This is only necessary if your environment uses a proxy, since the bundled certificates will not work. This returns True if no CA bundle is set; this tells requests to use the default, bundled certificates. @@ -109,7 +107,7 @@ def get_client_app(config: dict): authority=authority, client_credential={"thumbprint": thumbprint, "private_key": private_key}, proxies=proxies, - verify=requests_ca_bundle + verify=requests_ca_bundle, ) @@ -141,25 +139,31 @@ def acquire_token(app: ConfidentialClientApplication, scopes: List[str]): return result["access_token"] -if __name__ == "__main__": - print("Starting Client application") - config = load_config("config.json") +def call_api(config: dict): + """ + Call API using access token. + Parameters + ---------- + config: dict + The config map to use. + """ app = get_client_app(config) access_token = acquire_token(app, config["scopes"]) - proxies = get_proxies(config) - url = config["url"] + print("Calling API...") + # call API using access token + return requests.get( + config["url"], + headers={"Authorization": "Bearer " + access_token}, + proxies=get_proxies(config), + verify=get_requests_ca_bundle(config), + ) - requests_ca_bundle = get_requests_ca_bundle(config) - print("Calling API.") - # Call API using the access token - response = requests.get( # Use token to call downstream service - url, - headers={"Authorization": "Bearer " + access_token}, - proxies=proxies, - verify=requests_ca_bundle - ).json() +if __name__ == "__main__": + print("Starting Client application") + config = load_config("config.json") + response = call_api(config) print("API call result: %s" % json.dumps(response, indent=2)) diff --git a/client-samples/python/rest/requirements.txt b/client-samples/python/rest/requirements.txt index bf0b632..ee481d8 100644 Binary files a/client-samples/python/rest/requirements.txt and b/client-samples/python/rest/requirements.txt differ diff --git a/client-samples/python/rest/tests/test-config.json b/client-samples/python/rest/tests/test-config.json new file mode 100644 index 0000000..36bbfe1 --- /dev/null +++ b/client-samples/python/rest/tests/test-config.json @@ -0,0 +1,12 @@ +{ + "client_id": "CLIENT-ID", + "scopes": [ + "API-SCOPE" + ], + "thumbprint": "CERT-THUMBPRINT", + "private_key_file": "PRIVATE-KEY-PATH", + "tenant": "TENANT", + "proxy_host": "PROXY-HOST", + "proxy_port": "PROXY-PORT", + "url": "HTTP://URL/" +} \ No newline at end of file diff --git a/client-samples/python/rest/tests/test_config.py b/client-samples/python/rest/tests/test_config.py new file mode 100644 index 0000000..b1b82df --- /dev/null +++ b/client-samples/python/rest/tests/test_config.py @@ -0,0 +1,45 @@ +import os +import sys +import unittest + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from client_application import load_config, get_proxies +from test_consts import MOCK_CONFIG + +TEST_PROXY_HOST = MOCK_CONFIG["proxy_host"] +TEST_PROXY_PORT = MOCK_CONFIG["proxy_port"] +TEST_PROXY_CONFIG = { + "http": f"{TEST_PROXY_HOST}:{TEST_PROXY_PORT}", + "https": f"{TEST_PROXY_HOST}:{TEST_PROXY_PORT}", +} + + +class TestConfigSetup(unittest.TestCase): + def test_config(self): + """ + Test that the correct config is loaded. + """ + config = load_config("./tests/test-config.json") + assert config == MOCK_CONFIG, "Config loaded correctly" + + def test_proxies(self): + """ + Test that the correct proxies are loaded. + """ + proxies = get_proxies(MOCK_CONFIG) + assert proxies == TEST_PROXY_CONFIG, "Proxies loaded correctly" + + def test_null_proxy_config(self): + """ + Test that no proxies are loaded when the proxy_host and proxy_port are not provided. + """ + config = MOCK_CONFIG.copy() + config.pop("proxy_host") + config.pop("proxy_port") + proxies = get_proxies(config) + assert proxies == None, "Proxies not loaded" + + +if __name__ == "__main__": + unittest.main() diff --git a/client-samples/python/rest/tests/test_consts.py b/client-samples/python/rest/tests/test_consts.py new file mode 100644 index 0000000..0f6f245 --- /dev/null +++ b/client-samples/python/rest/tests/test_consts.py @@ -0,0 +1,10 @@ +MOCK_CONFIG = { + "client_id": "CLIENT-ID", + "scopes": ["API-SCOPE"], + "thumbprint": "CERT-THUMBPRINT", + "private_key_file": "PRIVATE-KEY-PATH", + "tenant": "TENANT", + "proxy_host": "PROXY-HOST", + "proxy_port": "PROXY-PORT", + "url": "HTTP://URL/", +} diff --git a/client-samples/python/rest/tests/test_request.py b/client-samples/python/rest/tests/test_request.py new file mode 100644 index 0000000..f2c73d0 --- /dev/null +++ b/client-samples/python/rest/tests/test_request.py @@ -0,0 +1,81 @@ +import sys +import os +import unittest +from unittest.mock import patch +import requests_mock +from parameterized import parameterized + + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from client_application import call_api +from test_consts import MOCK_CONFIG + +URL = "HTTP://URL" +MOCK_TOKEN = "MOCK_TOKEN" +MOCK_BODY_RESPONSE = {"status": "success", "response": "Hello World!"} + + +class TestApiCall(unittest.TestCase): + def setUp(self): + """ + setUp function runs before each unit test. + Parameters + ---------- + acquire_token: Mock + Mock return for the acquire_token method. + get_client_app_mock: Mock + Mock return for the get_client_app method. + """ + # create patches of the acquire_token and get_client_app methods + self.acquire_token_patch = patch( + "client_application.acquire_token", return_value=MOCK_TOKEN + ) + self.get_client_app_patch = patch( + "client_application.get_client_app", return_value=None + ) + + # Start the patches + self.acquire_token_patch.start() + self.get_client_app_patch.start() + + def tearDown(self): + """ + tearDown function runs after each unit test. + """ + self.acquire_token_patch.stop() + self.get_client_app_patch.stop() + return super().tearDown() + + @parameterized.expand( + [ + [200, MOCK_BODY_RESPONSE], + [401, {}], + [404, {}], + [500, {}], + ] + ) + @requests_mock.Mocker() + def test_call_api( + self, status_code: int, json: dict, mock_request: requests_mock.Mocker + ): + """ + Mock calling API with different responses. + Parameters + ---------- + status_code: int + Status code for the response. + json: dict + JSON response for the response. + mock_request: requests_mock.Mocker + Mock return for the requests get function. + """ + mock_request.get(URL, status_code=status_code, json=json) + + response = call_api(MOCK_CONFIG) + self.assertEqual(response.status_code, status_code) + self.assertEqual(response.json(), json) + + +if __name__ == "__main__": + unittest.main() diff --git a/client-samples/python/websockets/.gitignore b/client-samples/python/websockets/.gitignore new file mode 100644 index 0000000..fafa371 --- /dev/null +++ b/client-samples/python/websockets/.gitignore @@ -0,0 +1,172 @@ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# PyPI configuration file +.pypirc diff --git a/client-samples/python/websockets/README.md b/client-samples/python/websockets/README.md index 9fcfff2..abe6531 100644 --- a/client-samples/python/websockets/README.md +++ b/client-samples/python/websockets/README.md @@ -16,10 +16,10 @@ It uses the following libraries: ```bash # create a virtual environment in the 'virtualenv' folder # NOTE: this is optional but recommended -python -m venv virtualenv +python -m venv venv source virtualenv/bin/activate # on Linux, using bash -.\virtualenv\Scripts\activate # on Windows +.\venv\Scripts\activate # on Windows python -m pip install --upgrade pip pip install -r requirements.txt @@ -52,6 +52,12 @@ If you want to reduce the amount of logs output, please remove this line from th websocket.enableTrace(True) ``` +## Testing +The tests have been built using the `unittest` framework, but can be run using the `pytest` command. +```bash +pytest tests +``` + ## Linting This project uses `black` to lint its source code for readability. To lint the code please run the following: diff --git a/client-samples/python/websockets/client.py b/client-samples/python/websockets/client.py index 869555a..7ac4292 100644 --- a/client-samples/python/websockets/client.py +++ b/client-samples/python/websockets/client.py @@ -1,10 +1,10 @@ -# Morgan Stanley makes this available to you under the Apache License, Version 2.0 (the "License"). You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. +# Morgan Stanley makes this available to you under the Apache License, Version 2.0 (the "License"). You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. # See the NOTICE file distributed with this work for additional information regarding copyright ownership. -# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and limitations under the License. from msal import ConfidentialClientApplication -from typing import List, Optional +from typing import List, Optional, Union import socket import sys import json @@ -45,7 +45,7 @@ def load_private_key(private_key_file: str): return f.read() -def get_requests_ca_bundle(config: dict) -> str | bool: +def get_requests_ca_bundle(config: dict) -> Union[str, bool]: """ Get the system CA bundle, if it's set. This is only necessary if your environment uses a proxy, since the bundled certificates will not work. This returns True if no CA bundle is set; this tells requests to use the default, bundled certificates. @@ -68,20 +68,16 @@ def get_requests_ca_bundle(config: dict) -> str | bool: return config.get("requests_ca_bundle") or True -def get_client_app(config: dict): +def get_proxies(config: dict) -> Union[dict, None]: """ - Configures an MSAL client application, that can later be used to request an access token. + Returns proxy config from the config dictionary if the correct config has been provided. + Otherwise returns None. Parameters ---------- config: dict The config map to use. """ - client_id = config["client_id"] - thumbprint = config["thumbprint"] - private_key_path = config["private_key_file"] - authority = f"https://login.microsoftonline.com/{config['tenant']}" - requests_ca_bundle = get_requests_ca_bundle(config) proxy_host = config.get("proxy_host") proxy_port = config.get("proxy_port") proxies = None @@ -92,6 +88,24 @@ def get_client_app(config: dict): "http": f"{proxy_host}:{proxy_port}", "https": f"{proxy_host}:{proxy_port}", } + return proxies + + +def get_client_app(config: dict): + """ + Configures an MSAL client application, that can later be used to request an access token. + + Parameters + ---------- + config: dict + The config map to use. + """ + client_id = config["client_id"] + thumbprint = config["thumbprint"] + private_key_path = config["private_key_file"] + authority = f"https://login.microsoftonline.com/{config['tenant']}" + requests_ca_bundle = get_requests_ca_bundle(config) + proxies = get_proxies(config) private_key = load_private_key(private_key_path) @@ -132,13 +146,15 @@ def acquire_token(app: ConfidentialClientApplication, scopes: List[str]): return result["access_token"] -def get_sslopts(verify_http: bool | str): +def get_sslopts(verify_http: Union[bool, str]): """ Constructs an SSLOpts object to use when establishing a WebSocket connection. """ if not verify_http: # explicitly disable - print("\n\nWARNING: explicitly disabling SSL verification for WebSocket connection! By using this feature, your connection is not secure! \n") + print( + "\n\nWARNING: explicitly disabling SSL verification for WebSocket connection! By using this feature, your connection is not secure! \n" + ) return {"cert_reqs": ssl.CERT_NONE} if verify_http == True: # None = default settings @@ -152,7 +168,7 @@ def get_sslopts(verify_http: bool | str): context.verify_mode = ssl.CERT_REQUIRED context.check_hostname = True - return {'context': context} + return {"context": context} class WebSocketHandler: @@ -168,7 +184,7 @@ def __init__( retry_bad_handshake_status: bool, proxy_host: Optional[str] = None, proxy_port: Optional[int] = None, - verify_http: bool | str = True, + verify_http: Union[bool, str] = True, ): """ WebSocketHandler initialisation @@ -239,23 +255,26 @@ def get_headers(): http_proxy_host=proxy_host_ip, http_proxy_port=self.proxy_port, proxy_type=proxy_type, - sslopt=ssl_options + sslopt=ssl_options, ) if teardown: self.ws.close() - print("Connection was closed, sleeping for 10 seconds before reconnecting.") + print( + "Connection was closed, sleeping for 10 seconds before reconnecting." + ) time.sleep(10) except Exception as e: - print("An exception was in the connection loop. Sleeping for 10 seconds, then retrying.") + print( + "An exception was in the connection loop. Sleeping for 10 seconds, then retrying." + ) print("Exception: " + str(type(e)) + ": " + str(e)) self.ws.close() time.sleep(10) - + def on_message(self, ws, message): # put your logic here or pass the message out using a callback print("Received message: ", message) - def on_error(self, ws, error): print("An error occurred. Type of error: " + str(type(error))) print("Error message: " + str(error)) @@ -266,13 +285,14 @@ def on_error(self, ws, error): if isinstance(error, websocket.WebSocketBadStatusException): if not self.retry_bad_handshake_status: - print("Bad handshake status was encountered and the app was configured to not retry. Exiting the app.") + print( + "Bad handshake status was encountered and the app was configured to not retry. Exiting the app." + ) sys.exit(1) print("Trying to reconnect") # loop will retry - def on_close(self, ws, close_status_code, close_msg): print("Connection was closed") @@ -287,7 +307,9 @@ def create_connection(config: dict, url: str): proxy_port = config.get("proxy_port") requests_ca_bundle = get_requests_ca_bundle(config) app = get_client_app(config) - retry_bad_handshake_status = config.get("retry_bad_handshake_status", True) # default is to retry + retry_bad_handshake_status = config.get( + "retry_bad_handshake_status", True + ) # default is to retry return WebSocketHandler( url=url, @@ -296,7 +318,7 @@ def create_connection(config: dict, url: str): proxy_host=proxy_host, proxy_port=proxy_port, verify_http=requests_ca_bundle, - retry_bad_handshake_status=retry_bad_handshake_status + retry_bad_handshake_status=retry_bad_handshake_status, ) diff --git a/client-samples/python/websockets/requirements.txt b/client-samples/python/websockets/requirements.txt index de8065a..b13edb3 100644 Binary files a/client-samples/python/websockets/requirements.txt and b/client-samples/python/websockets/requirements.txt differ diff --git a/client-samples/python/websockets/tests/test-config.json b/client-samples/python/websockets/tests/test-config.json new file mode 100644 index 0000000..e1a853a --- /dev/null +++ b/client-samples/python/websockets/tests/test-config.json @@ -0,0 +1,13 @@ +{ + "client_id": "CLIENT-ID", + "scopes": [ + "API-SCOPE" + ], + "thumbprint": "CERT-THUMBPRINT", + "private_key_file": "PRIVATE-KEY-PATH", + "tenant": "TENANT", + "proxy_host": "PROXY-HOST", + "proxy_port": "PROXY-PORT", + "url": "HTTP://URL/", + "retry_bad_handshake_status": true +} \ No newline at end of file diff --git a/client-samples/python/websockets/tests/test_config.py b/client-samples/python/websockets/tests/test_config.py new file mode 100644 index 0000000..6baae35 --- /dev/null +++ b/client-samples/python/websockets/tests/test_config.py @@ -0,0 +1,38 @@ +import os +import sys +import unittest + +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from client import load_config, get_proxies +from test_consts import MOCK_CONFIG, TEST_PROXY_CONFIG + + +class TestConfigSetup(unittest.TestCase): + def test_config(self): + """ + Test that the correct config is loaded. + """ + config = load_config("./tests/test-config.json") + assert config == MOCK_CONFIG, "Config loaded correctly" + + def test_proxies(self): + """ + Test that the correct proxies are loaded. + """ + proxies = get_proxies(MOCK_CONFIG) + assert proxies == TEST_PROXY_CONFIG, "Proxies loaded correctly" + + def test_null_proxy_config(self): + """ + Test that no proxies are loaded when the proxy_host and proxy_port are not provided. + """ + config = MOCK_CONFIG.copy() + config.pop("proxy_host") + config.pop("proxy_port") + proxies = get_proxies(config) + assert proxies == None, "Proxies not loaded" + + +if __name__ == "__main__": + unittest.main() diff --git a/client-samples/python/websockets/tests/test_consts.py b/client-samples/python/websockets/tests/test_consts.py new file mode 100644 index 0000000..948de94 --- /dev/null +++ b/client-samples/python/websockets/tests/test_consts.py @@ -0,0 +1,18 @@ +MOCK_CONFIG = { + "client_id": "CLIENT-ID", + "scopes": ["API-SCOPE"], + "thumbprint": "CERT-THUMBPRINT", + "private_key_file": "PRIVATE-KEY-PATH", + "tenant": "TENANT", + "proxy_host": "PROXY-HOST", + "proxy_port": "PROXY-PORT", + "url": "HTTP://URL/", + "retry_bad_handshake_status": True, +} + +TEST_PROXY_HOST = MOCK_CONFIG["proxy_host"] +TEST_PROXY_PORT = MOCK_CONFIG["proxy_port"] +TEST_PROXY_CONFIG = { + "http": f"{TEST_PROXY_HOST}:{TEST_PROXY_PORT}", + "https": f"{TEST_PROXY_HOST}:{TEST_PROXY_PORT}", +}