From 821c7f81134c5e99283995a05c5a1c171371ea42 Mon Sep 17 00:00:00 2001 From: harveymmaunders Date: Mon, 6 Jan 2025 11:37:53 +0000 Subject: [PATCH 01/11] Python dependabot config --- .github/dependabot.yml | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9436b95..660ec57 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: "weekly" + day: "monday" + time: "08:00" \ No newline at end of file From 219d6ea2894b4ecdc3669ebb7cdbbb5d5120469f Mon Sep 17 00:00:00 2001 From: harveymmaunders Date: Mon, 6 Jan 2025 13:13:19 +0000 Subject: [PATCH 02/11] Python rest tests and ci job --- .github/workflows/python-ci.yaml | 30 +++ client-samples/python/rest/.gitignore | 172 ++++++++++++++++++ client-samples/python/rest/README.md | 17 +- ...t-application.py => client_application.py} | 44 +++-- client-samples/python/rest/requirements.txt | Bin 215 -> 830 bytes .../python/rest/tests/test-config.json | 12 ++ .../python/rest/tests/test_config.py | 45 +++++ .../python/rest/tests/test_consts.py | 10 + .../python/rest/tests/test_request.py | 80 ++++++++ 9 files changed, 385 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/python-ci.yaml create mode 100644 client-samples/python/rest/.gitignore rename client-samples/python/rest/{client-application.py => client_application.py} (89%) mode change 100755 => 100644 create mode 100644 client-samples/python/rest/tests/test-config.json create mode 100644 client-samples/python/rest/tests/test_config.py create mode 100644 client-samples/python/rest/tests/test_consts.py create mode 100644 client-samples/python/rest/tests/test_request.py diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml new file mode 100644 index 0000000..a5d0c83 --- /dev/null +++ b/.github/workflows/python-ci.yaml @@ -0,0 +1,30 @@ +name: Python Tests + +on: + pull_request: + branches: + - main +jobs: + test-python-rest: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + defaults: + run: + working-directory: ./client-samples/python/rest + 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 . 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..fe76d56 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,13 @@ python client-application.py The application will launch and connect to the Morgan Stanley API offering and output the result. +## Testing +This project uses `pytest` for unit testing. +Run the following command to run unit tests: +```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 89% rename from client-samples/python/rest/client-application.py rename to client-samples/python/rest/client_application.py index 50d3ed2..2c86243 --- 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 # 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. @@ -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 bf0b632dd79a1b230c162febd20a89c6713f6339..013b0d054af47505e8c201df9899c6290ffa9219 100644 GIT binary patch literal 830 zcmZuv%Wi`}5c9cGKLr#ZZFA`lQ}R&6bQ<2uLC3H1sSTl2q~!@75o`|i%3n#&drc0m$T;DJ zPIlx8*YUd)xU)$DZ<*Ttn0o zlT!AKggjHFj=9Lq-MyS|$fnI|bHi==GAg|hvBU@7ZxwCHb5XY@4t&d{D!yNN99U`X zK5gyAyzBmpp1jX9(qxs*SN)60_y=^BTiU@i4ds0?B~4dM<~!3x%hO;@e)YOin&1yV CM0Ohh literal 215 zcmWlTF%H5o3`O_c1*=I~%Hj(!F(5IgX-Xr~5}Z^boF04g)Bk?9UmeY%B3kI6S0#QC zHRu645NH GGyVa7**%&7 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..88f61b0 --- /dev/null +++ b/client-samples/python/rest/tests/test_request.py @@ -0,0 +1,80 @@ +import sys +import os +import unittest +from unittest.mock import patch +import requests_mock + +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() + + @requests_mock.Mocker() + def test_call_api_success(self, mock_request): + """ + Mock calling API with a successful response. + Parameters + ---------- + mock_request: requests_mock.Mocker + Mock return for the requests get function. + """ + mock_request.get(URL, json=MOCK_BODY_RESPONSE, status_code=200) + + response = call_api(MOCK_CONFIG) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), MOCK_BODY_RESPONSE) + + @requests_mock.Mocker() + def test_call_api_failure(self, mock_request): + """ + Mock calling API with a failed response. + Parameters + ---------- + mock_request: requests_mock.Mocker + Mock return for the requests get function. + """ + mock_request.get(URL, json={}, status_code=401) + + response = call_api(MOCK_CONFIG) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json(), {}) + + +if __name__ == "__main__": + unittest.main() From 0bbcf296f57a1aefc891d5acd5b7a74d77dd6364 Mon Sep 17 00:00:00 2001 From: harveymmaunders Date: Mon, 6 Jan 2025 13:41:16 +0000 Subject: [PATCH 03/11] Added python websocket testing and ci job --- .github/workflows/python-ci.yaml | 24 +++ client-samples/python/websockets/.gitignore | 172 ++++++++++++++++++ client-samples/python/websockets/README.md | 11 +- client-samples/python/websockets/client.py | 28 ++- .../python/websockets/tests/test-config.json | 13 ++ .../python/websockets/tests/test_config.py | 45 +++++ .../python/websockets/tests/test_consts.py | 11 ++ 7 files changed, 295 insertions(+), 9 deletions(-) create mode 100644 client-samples/python/websockets/.gitignore create mode 100644 client-samples/python/websockets/tests/test-config.json create mode 100644 client-samples/python/websockets/tests/test_config.py create mode 100644 client-samples/python/websockets/tests/test_consts.py diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml index a5d0c83..40be26b 100644 --- a/.github/workflows/python-ci.yaml +++ b/.github/workflows/python-ci.yaml @@ -28,3 +28,27 @@ jobs: run: pytest tests/ - name: Run black check (linting) run: black --check . + + test-python-websocket: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + defaults: + run: + working-directory: ./client-samples/python/websocket + 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/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..c301fe1 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,13 @@ If you want to reduce the amount of logs output, please remove this line from th websocket.enableTrace(True) ``` +## Testing +This project uses `pytest` for unit testing. +Run the following command to run unit tests: +```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..16fb2a3 100644 --- a/client-samples/python/websockets/client.py +++ b/client-samples/python/websockets/client.py @@ -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) -> 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) 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..a03a064 --- /dev/null +++ b/client-samples/python/websockets/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 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/websockets/tests/test_consts.py b/client-samples/python/websockets/tests/test_consts.py new file mode 100644 index 0000000..85fc884 --- /dev/null +++ b/client-samples/python/websockets/tests/test_consts.py @@ -0,0 +1,11 @@ +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, +} From 530983f99f1a3296531940146d3494119b8beac4 Mon Sep 17 00:00:00 2001 From: harveymmaunders Date: Mon, 6 Jan 2025 13:47:57 +0000 Subject: [PATCH 04/11] Type version compatability and updated requirements.txt --- .../python/rest/client_application.py | 6 +-- client-samples/python/websockets/client.py | 44 +++++++++++------- .../python/websockets/requirements.txt | Bin 201 -> 968 bytes 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/client-samples/python/rest/client_application.py b/client-samples/python/rest/client_application.py index 2c86243..014d5ad 100644 --- a/client-samples/python/rest/client_application.py +++ b/client-samples/python/rest/client_application.py @@ -6,7 +6,7 @@ from msal import ConfidentialClientApplication import json import requests -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) @@ -38,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. @@ -61,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. diff --git a/client-samples/python/websockets/client.py b/client-samples/python/websockets/client.py index 16fb2a3..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,7 +68,7 @@ def get_requests_ca_bundle(config: dict) -> str | bool: return config.get("requests_ca_bundle") or True -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. @@ -146,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 @@ -166,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: @@ -182,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 @@ -253,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)) @@ -280,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") @@ -301,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, @@ -310,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 de8065a3f0367a260e1f7599f61923dee19184fa..164e497846fce5ae84ab76cc0bfc36b980e21be8 100644 GIT binary patch literal 968 zcmZWo(Qbl35S-^G{S=9yRbTo8KJ}qVpFsgh5h!R|{rI*sw|AaNb2$>&*_qwh<@cvR ziyS+=fu7qN_ Date: Mon, 6 Jan 2025 13:52:00 +0000 Subject: [PATCH 05/11] Optimised requirements.txt file --- client-samples/python/rest/requirements.txt | Bin 830 -> 216 bytes .../python/websockets/requirements.txt | Bin 968 -> 138 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/client-samples/python/rest/requirements.txt b/client-samples/python/rest/requirements.txt index 013b0d054af47505e8c201df9899c6290ffa9219..c8db09ced63bb87237e77b81f94f45252be4ec17 100644 GIT binary patch delta 21 dcmdnTc7t((%ETQ}R&6bQ<2uLC3H1sSTl2q~!@75o`|i%3n#&drc0m$T;DJ zPIlx8*YUd)xU)$DZ<*Ttn0o zlT!AKggjHFj=9Lq-MyS|$fnI|bHi==GAg|hvBU@7ZxwCHb5XY@4t&d{D!yNN99U`X zK5gyAyzBmpp1jX9(qxs*SN)60_y=^BTiU@i4ds0?B~4dM<~!3x%hO;@e)YOin&1yV CM0Ohh diff --git a/client-samples/python/websockets/requirements.txt b/client-samples/python/websockets/requirements.txt index 164e497846fce5ae84ab76cc0bfc36b980e21be8..b13edb3e66d31b00128dd49a224a3a842b159e59 100644 GIT binary patch delta 22 ecmX@X-o-dUX>tpr%;Y&ta+B4Vg(k-^%K!jV#s+Kv literal 968 zcmZWo(Qbl35S-^G{S=9yRbTo8KJ}qVpFsgh5h!R|{rI*sw|AaNb2$>&*_qwh<@cvR ziyS+=fu7qN_ Date: Mon, 6 Jan 2025 14:18:08 +0000 Subject: [PATCH 06/11] Fixed github ci action --- .github/workflows/python-ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml index 40be26b..304312d 100644 --- a/.github/workflows/python-ci.yaml +++ b/.github/workflows/python-ci.yaml @@ -36,7 +36,7 @@ jobs: python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] defaults: run: - working-directory: ./client-samples/python/websocket + working-directory: ./client-samples/python/websockets steps: - name: Checkout code uses: actions/checkout@v4 From 5a5b44844a4c7de933ba03943a6f002decda91a7 Mon Sep 17 00:00:00 2001 From: harveymmaunders Date: Mon, 6 Jan 2025 16:39:10 +0000 Subject: [PATCH 07/11] Updated README --- .github/dependabot.yml | 2 +- client-samples/python/rest/README.md | 7 +++---- client-samples/python/websockets/README.md | 7 +++---- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 660ec57..ec98fbf 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -16,6 +16,6 @@ updates: prefix: "pip-websocket" schedule: - interval: "weekly" + interval: "monthly" day: "monday" time: "08:00" \ No newline at end of file diff --git a/client-samples/python/rest/README.md b/client-samples/python/rest/README.md index fe76d56..18ad920 100644 --- a/client-samples/python/rest/README.md +++ b/client-samples/python/rest/README.md @@ -56,8 +56,8 @@ python client-application.py Mac/Linux: ```bash -python -m venv .venv -. .venv/bin/activate +python -m venv venv +. venv/bin/activate python -m pip install --upgrade pip pip install -r requirements.txt @@ -67,8 +67,7 @@ python client-application.py The application will launch and connect to the Morgan Stanley API offering and output the result. ## Testing -This project uses `pytest` for unit testing. -Run the following command to run unit tests: +The tests have been built using the `unittest` framework, but can be run using the `pytest` command. ```bash pytest tests ``` diff --git a/client-samples/python/websockets/README.md b/client-samples/python/websockets/README.md index c301fe1..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 .venv +python -m venv venv source virtualenv/bin/activate # on Linux, using bash -.\.venv\Scripts\activate # on Windows +.\venv\Scripts\activate # on Windows python -m pip install --upgrade pip pip install -r requirements.txt @@ -53,8 +53,7 @@ websocket.enableTrace(True) ``` ## Testing -This project uses `pytest` for unit testing. -Run the following command to run unit tests: +The tests have been built using the `unittest` framework, but can be run using the `pytest` command. ```bash pytest tests ``` From f07e9093e5295d6d31d9899d54dbd47f1a75027e Mon Sep 17 00:00:00 2001 From: harveymmaunders Date: Mon, 6 Jan 2025 16:43:18 +0000 Subject: [PATCH 08/11] Updated README --- client-samples/python/rest/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client-samples/python/rest/README.md b/client-samples/python/rest/README.md index 18ad920..316565a 100644 --- a/client-samples/python/rest/README.md +++ b/client-samples/python/rest/README.md @@ -45,8 +45,8 @@ Once Python is installed you can run following to launch the application. Please Windows: ```cmd -python -m venv .venv -.\.venv\Scripts\activate +python -m venv venv +.\venv\Scripts\activate python -m pip install --upgrade pip pip install -r requirements.txt From ea64dcf43721652463fff93b1b1c5ddd77036575 Mon Sep 17 00:00:00 2001 From: harveymmaunders Date: Wed, 8 Jan 2025 10:46:21 +0000 Subject: [PATCH 09/11] Added parameterized test for call API --- client-samples/python/rest/requirements.txt | Bin 216 -> 260 bytes .../python/rest/tests/test_request.py | 37 ++++++++---------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/client-samples/python/rest/requirements.txt b/client-samples/python/rest/requirements.txt index c8db09ced63bb87237e77b81f94f45252be4ec17..ee481d8e3efa02f477075d82b3ec0694e709b0ff 100644 GIT binary patch delta 43 ucmcb?*upd+K{1h`h#`?7mm!s*1c-|mG8w9XbP9tl5E?M(F<4FvZ3Y19@(Jhw delta 9 QcmZo+y1_UhVPa1O01`d} Date: Wed, 8 Jan 2025 10:47:41 +0000 Subject: [PATCH 10/11] Lint --- .../python/rest/tests/test_request.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/client-samples/python/rest/tests/test_request.py b/client-samples/python/rest/tests/test_request.py index cf189bc..f2c73d0 100644 --- a/client-samples/python/rest/tests/test_request.py +++ b/client-samples/python/rest/tests/test_request.py @@ -47,14 +47,18 @@ def tearDown(self): self.get_client_app_patch.stop() return super().tearDown() - @parameterized.expand([ - [200, MOCK_BODY_RESPONSE], - [401, {}], - [404, {}], - [500, {}], - ]) + @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): + def test_call_api( + self, status_code: int, json: dict, mock_request: requests_mock.Mocker + ): """ Mock calling API with different responses. Parameters From 33097f8483392b945a8a9042efc4d7769c02bde2 Mon Sep 17 00:00:00 2001 From: harveymmaunders Date: Thu, 9 Jan 2025 13:03:31 +0000 Subject: [PATCH 11/11] Python CI job --- .github/workflows/python-ci.yaml | 54 ------------------- .github/workflows/python-ci.yml | 33 ++++++++++++ .../python/websockets/tests/test_config.py | 9 +--- .../python/websockets/tests/test_consts.py | 7 +++ 4 files changed, 41 insertions(+), 62 deletions(-) delete mode 100644 .github/workflows/python-ci.yaml create mode 100644 .github/workflows/python-ci.yml diff --git a/.github/workflows/python-ci.yaml b/.github/workflows/python-ci.yaml deleted file mode 100644 index 304312d..0000000 --- a/.github/workflows/python-ci.yaml +++ /dev/null @@ -1,54 +0,0 @@ -name: Python Tests - -on: - pull_request: - branches: - - main -jobs: - test-python-rest: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] - defaults: - run: - working-directory: ./client-samples/python/rest - 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 . - - test-python-websocket: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] - defaults: - run: - working-directory: ./client-samples/python/websockets - 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/.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/websockets/tests/test_config.py b/client-samples/python/websockets/tests/test_config.py index a03a064..6baae35 100644 --- a/client-samples/python/websockets/tests/test_config.py +++ b/client-samples/python/websockets/tests/test_config.py @@ -5,14 +5,7 @@ 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_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}", -} +from test_consts import MOCK_CONFIG, TEST_PROXY_CONFIG class TestConfigSetup(unittest.TestCase): diff --git a/client-samples/python/websockets/tests/test_consts.py b/client-samples/python/websockets/tests/test_consts.py index 85fc884..948de94 100644 --- a/client-samples/python/websockets/tests/test_consts.py +++ b/client-samples/python/websockets/tests/test_consts.py @@ -9,3 +9,10 @@ "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}", +}