From 213410e1622406529010b28d6a01efb354ad5977 Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 05:38:31 -0400 Subject: [PATCH 01/24] Move Python tests to new directory --- pytest.ini | 8 +++-- shared/python-tests/__init__.py | 1 - .../python-tests => tests/python}/.pylintrc | 3 ++ tests/python/__init__.py | 3 ++ tests/python/conftest.py | 36 +++++++++++++++++++ .../python}/test_apimtypes.py | 0 6 files changed, 48 insertions(+), 3 deletions(-) delete mode 100644 shared/python-tests/__init__.py rename {shared/python-tests => tests/python}/.pylintrc (68%) create mode 100644 tests/python/__init__.py create mode 100644 tests/python/conftest.py rename {shared/python-tests => tests/python}/test_apimtypes.py (100%) diff --git a/pytest.ini b/pytest.ini index 4756da7..9a2930f 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,7 +1,11 @@ [pytest] markers = - TestApimTypes: Tests related to the APIM types. + apimtypes: tests for apimtypes module + apimrequests: tests for apimrequests module + utils: tests for utils module + integration: integration tests that require external services + slow: tests that take a long time to run testpaths = - shared/python-tests + tests/python python_files = test_*.py diff --git a/shared/python-tests/__init__.py b/shared/python-tests/__init__.py deleted file mode 100644 index 2923141..0000000 --- a/shared/python-tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Makes shared/tests a package for unittest discovery diff --git a/shared/python-tests/.pylintrc b/tests/python/.pylintrc similarity index 68% rename from shared/python-tests/.pylintrc rename to tests/python/.pylintrc index 6fda18c..6bada50 100644 --- a/shared/python-tests/.pylintrc +++ b/tests/python/.pylintrc @@ -5,3 +5,6 @@ disable = C0115, # Missing class docstring C0116, # Missing function or method docstring W0212, # Access to a protected member _ of a client class + R0903, # Too few public methods + R0913, # Too many arguments + W0621, # Redefining name from outer scope diff --git a/tests/python/__init__.py b/tests/python/__init__.py new file mode 100644 index 0000000..d99e668 --- /dev/null +++ b/tests/python/__init__.py @@ -0,0 +1,3 @@ +""" +Unit tests package for API Management samples. +""" diff --git a/tests/python/conftest.py b/tests/python/conftest.py new file mode 100644 index 0000000..a0ecccc --- /dev/null +++ b/tests/python/conftest.py @@ -0,0 +1,36 @@ +""" +Shared test configuration and fixtures for pytest. +""" +import os +import sys +from typing import Any + +import pytest + +# Add the shared/python directory to the Python path for all tests +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../shared/python'))) + + +# ------------------------------ +# SHARED FIXTURES +# ------------------------------ + +@pytest.fixture(scope="session") +def shared_python_path() -> str: + """Provide the path to the shared Python modules.""" + return os.path.abspath(os.path.join(os.path.dirname(__file__), '../../shared/python')) + +@pytest.fixture(scope="session") +def test_data_path() -> str: + """Provide the path to test data files.""" + return os.path.abspath(os.path.join(os.path.dirname(__file__), 'data')) + +@pytest.fixture +def sample_test_data() -> dict[str, Any]: + """Provide sample test data for use across tests.""" + return { + "test_url": "https://test-apim.azure-api.net", + "test_subscription_key": "test-subscription-key-12345", + "test_resource_group": "rg-test-apim-01", + "test_location": "eastus2" + } diff --git a/shared/python-tests/test_apimtypes.py b/tests/python/test_apimtypes.py similarity index 100% rename from shared/python-tests/test_apimtypes.py rename to tests/python/test_apimtypes.py From 96a408df398ed97bdebcd02237b766b4ee7fb28f Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 06:00:50 -0400 Subject: [PATCH 02/24] Add passing tests --- tests/python/test_apimtypes.py | 152 ++++++++++++++++++++++++++++----- 1 file changed, 132 insertions(+), 20 deletions(-) diff --git a/tests/python/test_apimtypes.py b/tests/python/test_apimtypes.py index 6c0727d..1090b7a 100644 --- a/tests/python/test_apimtypes.py +++ b/tests/python/test_apimtypes.py @@ -1,32 +1,144 @@ -# pylint: disable=C0114,C0115,C0116,W0212,W0621 +""" +Unit tests for apimtypes.py. +""" +import pytest +from shared.python import apimtypes -# Execute with "pytest -v" in the root directory +# ------------------------------ +# CONSTANTS +# ------------------------------ -# https://docs.pytest.org/en/8.2.x/ +EXAMPLE_NAME = "test-api" +EXAMPLE_DISPLAY_NAME = "Test API" +EXAMPLE_PATH = "/test" +EXAMPLE_DESCRIPTION = "A test API." +EXAMPLE_POLICY_XML = "" -""" -Unit tests for apimtypes.py -""" -import pytest, sys, os, unittest +# ------------------------------ +# PUBLIC METHODS +# ------------------------------ + +def test_api_creation(): + """ + Test creation of API object and its attributes. + """ + api = apimtypes.API( + name = EXAMPLE_NAME, + displayName = EXAMPLE_DISPLAY_NAME, + path = EXAMPLE_PATH, + description = EXAMPLE_DESCRIPTION, + policyXml = EXAMPLE_POLICY_XML, + operations = None + ) -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))) + assert api.name == EXAMPLE_NAME + assert api.displayName == EXAMPLE_DISPLAY_NAME + assert api.path == EXAMPLE_PATH + assert api.description == EXAMPLE_DESCRIPTION + assert api.policyXml == EXAMPLE_POLICY_XML + assert api.operations == [] -########################################################################################################################################################## +def test_api_repr(): + """ + Test __repr__ method of API. + """ + api = apimtypes.API( + name = EXAMPLE_NAME, + displayName = EXAMPLE_DISPLAY_NAME, + path = EXAMPLE_PATH, + description = EXAMPLE_DESCRIPTION, + policyXml = EXAMPLE_POLICY_XML, + operations = None + ) + result = repr(api) + assert "API" in result + assert EXAMPLE_NAME in result + assert EXAMPLE_DISPLAY_NAME in result -# Utility Functions +def test_api_equality(): + """ + Test equality comparison for API objects. + """ + api1 = apimtypes.API( + name = EXAMPLE_NAME, + displayName = EXAMPLE_DISPLAY_NAME, + path = EXAMPLE_PATH, + description = EXAMPLE_DESCRIPTION, + policyXml = EXAMPLE_POLICY_XML, + operations = None + ) + api2 = apimtypes.API( + name = EXAMPLE_NAME, + displayName = EXAMPLE_DISPLAY_NAME, + path = EXAMPLE_PATH, + description = EXAMPLE_DESCRIPTION, + policyXml = EXAMPLE_POLICY_XML, + operations = None + ) + assert api1 == api2 -# Test Fixtures +def test_api_inequality(): + """ + Test inequality for API objects with different attributes. + """ + api1 = apimtypes.API( + name = EXAMPLE_NAME, + displayName = EXAMPLE_DISPLAY_NAME, + path = EXAMPLE_PATH, + description = EXAMPLE_DESCRIPTION, + policyXml = EXAMPLE_POLICY_XML, + operations = None + ) + api2 = apimtypes.API( + name = "other-api", + displayName = EXAMPLE_DISPLAY_NAME, + path = EXAMPLE_PATH, + description = EXAMPLE_DESCRIPTION, + policyXml = EXAMPLE_POLICY_XML, + operations = None + ) + assert api1 != api2 -########################################################################################################################################################## +def test_api_missing_fields(): + """ + Test that missing required fields raise TypeError. + """ + with pytest.raises(TypeError): + apimtypes.API( + displayName = EXAMPLE_DISPLAY_NAME, + path = EXAMPLE_PATH, + description = EXAMPLE_DESCRIPTION, + policyXml = EXAMPLE_POLICY_XML + ) -# Tests + with pytest.raises(TypeError): + apimtypes.API( + name = EXAMPLE_NAME, + path = EXAMPLE_PATH, + description = EXAMPLE_DESCRIPTION, + policyXml = EXAMPLE_POLICY_XML + ) -class TestApimTypes(unittest.TestCase): - @pytest.mark.apimtypes + with pytest.raises(TypeError): + apimtypes.API( + name = EXAMPLE_NAME, + displayName = EXAMPLE_DISPLAY_NAME, + description = EXAMPLE_DESCRIPTION, + policyXml = EXAMPLE_POLICY_XML + ) - def test_sample(self) -> None: - """Sample test - replace with real tests.""" - self.assertTrue(True) + with pytest.raises(TypeError): + apimtypes.API( + name = EXAMPLE_NAME, + displayName = EXAMPLE_DISPLAY_NAME, + path = EXAMPLE_PATH, + policyXml = EXAMPLE_POLICY_XML + ) -if __name__ == "__main__": - unittest.main() + with pytest.raises(TypeError): + apimtypes.API( + name = EXAMPLE_NAME, + displayName = EXAMPLE_DISPLAY_NAME, + path = EXAMPLE_PATH, + description = EXAMPLE_DESCRIPTION + ) \ No newline at end of file From 1e6da13d438563c15c42d129e046af7bdeddfd9d Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 06:14:15 -0400 Subject: [PATCH 03/24] Add basic working tests --- shared/python/utils.py | 2 +- tests/python/test_apimrequests.py | 113 ++++++++++++++++++++++++++++++ tests/python/test_utils.py | 56 +++++++++++++++ 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 tests/python/test_apimrequests.py create mode 100644 tests/python/test_utils.py diff --git a/shared/python/utils.py b/shared/python/utils.py index 7282aed..79b201e 100644 --- a/shared/python/utils.py +++ b/shared/python/utils.py @@ -363,7 +363,7 @@ def extract_json(text: str) -> any: if text[start] in ('{', '['): try: obj, end = decoder.raw_decode(text[start:]) - return json.loads(obj) + return obj except Exception: continue diff --git a/tests/python/test_apimrequests.py b/tests/python/test_apimrequests.py new file mode 100644 index 0000000..a1c6027 --- /dev/null +++ b/tests/python/test_apimrequests.py @@ -0,0 +1,113 @@ +import pytest +import requests +from unittest.mock import patch, MagicMock +from shared.python.apimrequests import ApimRequests +from shared.python.apimtypes import HTTP_VERB, SUBSCRIPTION_KEY_PARAMETER_NAME + +# Sample values for tests +default_url = "https://example.com/apim/" +default_key = "test-key" +default_path = "/test" +default_headers = {"Custom-Header": "Value"} +default_data = {"foo": "bar"} + +@pytest.fixture +def apim(): + return ApimRequests(default_url, default_key) + +def test_init_sets_headers(): + apim = ApimRequests(default_url, default_key) + assert apim.url == default_url + assert apim.apimSubscriptionKey == default_key + assert apim.headers[SUBSCRIPTION_KEY_PARAMETER_NAME] == default_key + assert apim.headers["Accept"] == "application/json" + +def test_init_no_key(): + apim = ApimRequests(default_url) + assert apim.url == default_url + assert apim.apimSubscriptionKey is None + assert "Ocp-Apim-Subscription-Key" not in apim.headers + assert apim.headers["Accept"] == "application/json" + +@patch("shared.python.apimrequests.requests.request") +@patch("shared.python.apimrequests.utils.print_message") +@patch("shared.python.apimrequests.utils.print_info") +@patch("shared.python.apimrequests.utils.print_error") +def test_single_get_success(mock_print_error, mock_print_info, mock_print_message, mock_request, apim): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"result": "ok"} + mock_response.text = '{"result": "ok"}' + mock_response.raise_for_status.return_value = None + mock_request.return_value = mock_response + + with patch.object(apim, "_print_response") as mock_print_response: + result = apim.singleGet(default_path, printResponse=True) + assert result == '{\n "result": "ok"\n}' + mock_print_response.assert_called_once_with(mock_response) + mock_print_error.assert_not_called() + +@patch("shared.python.apimrequests.requests.request") +@patch("shared.python.apimrequests.utils.print_message") +@patch("shared.python.apimrequests.utils.print_info") +@patch("shared.python.apimrequests.utils.print_error") +def test_single_get_error(mock_print_error, mock_print_info, mock_print_message, mock_request, apim): + mock_request.side_effect = requests.exceptions.RequestException("fail") + result = apim.singleGet(default_path, printResponse=True) + assert result is None + mock_print_error.assert_called_once() + +@patch("shared.python.apimrequests.requests.request") +@patch("shared.python.apimrequests.utils.print_message") +@patch("shared.python.apimrequests.utils.print_info") +@patch("shared.python.apimrequests.utils.print_error") +def test_single_post_success(mock_print_error, mock_print_info, mock_print_message, mock_request, apim): + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"created": True} + mock_response.text = '{"created": true}' + mock_response.raise_for_status.return_value = None + mock_request.return_value = mock_response + + with patch.object(apim, "_print_response") as mock_print_response: + result = apim.singlePost(default_path, data=default_data, printResponse=True) + assert result == '{\n "created": true\n}' + mock_print_response.assert_called_once_with(mock_response) + mock_print_error.assert_not_called() + +@patch("shared.python.apimrequests.requests.Session") +@patch("shared.python.apimrequests.utils.print_message") +@patch("shared.python.apimrequests.utils.print_info") +def test_multi_get_success(mock_print_info, mock_print_message, mock_session, apim): + mock_sess = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"result": "ok"} + mock_response.text = '{"result": "ok"}' + mock_response.raise_for_status.return_value = None + mock_sess.request.return_value = mock_response + mock_session.return_value = mock_sess + + with patch.object(apim, "_print_response_code") as mock_print_code: + result = apim.multiGet(default_path, runs=2, printResponse=True) + assert len(result) == 2 + for run in result: + assert run["status_code"] == 200 + assert run["response"] == '{\n "result": "ok"\n}' + assert mock_sess.request.call_count == 2 + mock_print_code.assert_called() + +@patch("shared.python.apimrequests.requests.Session") +@patch("shared.python.apimrequests.utils.print_message") +@patch("shared.python.apimrequests.utils.print_info") +def test_multi_get_error(mock_print_info, mock_print_message, mock_session, apim): + mock_sess = MagicMock() + mock_sess.request.side_effect = requests.exceptions.RequestException("fail") + mock_session.return_value = mock_sess + with patch.object(apim, "_print_response_code"): + # Should raise inside the loop, but function should still close session and return what it has + with pytest.raises(requests.exceptions.RequestException): + apim.multiGet(default_path, runs=1, printResponse=True) diff --git a/tests/python/test_utils.py b/tests/python/test_utils.py new file mode 100644 index 0000000..ee8b668 --- /dev/null +++ b/tests/python/test_utils.py @@ -0,0 +1,56 @@ +""" +Unit tests for utils.py. +""" +import pytest +from shared.python import utils + +# ------------------------------ +# PUBLIC METHODS +# ------------------------------ + +def test_is_string_json_valid(): + """Test is_string_json with valid JSON strings.""" + assert utils.is_string_json('{"a": 1}') is True + assert utils.is_string_json('[1, 2, 3]') is True + +def test_is_string_json_invalid(): + """Test is_string_json with invalid JSON strings.""" + assert utils.is_string_json('not json') is False + assert utils.is_string_json('{"a": 1') is False + +def test_extract_json_object(): + """Test extract_json extracts JSON object from string.""" + s = 'prefix {"foo": 42, "bar": "baz"} suffix' + result = utils.extract_json(s) + assert isinstance(result, dict) + assert result["foo"] == 42 + assert result["bar"] == "baz" + +def test_extract_json_array(): + """Test extract_json extracts JSON array from string.""" + s = 'prefix [1, 2, 3] suffix' + result = utils.extract_json(s) + assert isinstance(result, list) + assert result == [1, 2, 3] + +def test_extract_json_none(): + """Test extract_json returns None if no JSON found.""" + s = 'no json here' + assert utils.extract_json(s) is None + +def test_get_rg_name_basic(): + """Test get_rg_name returns correct resource group name.""" + assert utils.get_rg_name("foo") == "apim-sample-foo" + +def test_get_rg_name_with_index(): + """Test get_rg_name with index appends index.""" + assert utils.get_rg_name("foo", 2) == "apim-sample-foo-2" + +def test_get_infra_rg_name(monkeypatch): + """Test get_infra_rg_name returns correct name and validates infra.""" + class DummyInfra: + value = "bar" + # Patch validate_infrastructure to a no-op + monkeypatch.setattr(utils, "validate_infrastructure", lambda x: x) + assert utils.get_infra_rg_name(DummyInfra) == "apim-infra-bar" + assert utils.get_infra_rg_name(DummyInfra, 3) == "apim-infra-bar-3" \ No newline at end of file From 4d578744ddbc16450a19dca0a6a86a7a4b0808f0 Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 06:35:38 -0400 Subject: [PATCH 04/24] Move code coverage files --- .gitignore | 7 +++++++ requirements.txt | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a8ecba6..c2afcc1 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,10 @@ labs-in-progress/ # Exclude sensitive or generated files .env + +# Coverage reports +tests/python/htmlcov/ + +# Coverage config and data +tests/python/.coverage +tests/python/.coveragerc diff --git a/requirements.txt b/requirements.txt index a8a7f34..a2cf31f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ requests setuptools -pytest \ No newline at end of file +pytest +pytest-cov \ No newline at end of file From eaf1a6fe78a8244ed5f1f02a6230627fc9280b89 Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 06:37:43 -0400 Subject: [PATCH 05/24] Update Python Tests workflow --- .github/workflows/python-tests.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index f674fef..fb8b26f 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -25,6 +25,16 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - - name: Run tests + - name: Install pytest and pytest-cov run: | - python -m unittest discover shared/python-tests + pip install pytest pytest-cov + + - name: Run pytest with coverage + run: | + pytest --cov=shared/python --cov-config=tests/python/.coveragerc --cov-report=html:tests/python/htmlcov tests/python/ + + - name: Upload coverage HTML report + uses: actions/upload-artifact@v4 + with: + name: coverage-html + path: tests/python/htmlcov/ From 204e092cef02d44acdaa4bada1eb647c9ccb2c29 Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 06:55:32 -0400 Subject: [PATCH 06/24] Add test scripts, instructions, and update Readme --- .github/workflows/python-tests.yml | 2 +- .gitignore | 8 ++--- README.md | 49 +++++++++++++++++++++++++-- tests/python/.coveragerc | 11 ++++++ pytest.ini => tests/python/pytest.ini | 2 ++ tests/python/run_coverage.ps1 | 3 ++ tests/python/run_coverage.sh | 4 +++ tests/python/test_apimrequests.py | 11 ++++++ tests/python/test_apimtypes.py | 16 ++++----- tests/python/test_utils.py | 22 +++++++----- 10 files changed, 102 insertions(+), 26 deletions(-) create mode 100644 tests/python/.coveragerc rename pytest.ini => tests/python/pytest.ini (79%) create mode 100644 tests/python/run_coverage.ps1 create mode 100644 tests/python/run_coverage.sh diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index fb8b26f..2bec27b 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -31,7 +31,7 @@ jobs: - name: Run pytest with coverage run: | - pytest --cov=shared/python --cov-config=tests/python/.coveragerc --cov-report=html:tests/python/htmlcov tests/python/ + COVERAGE_FILE=tests/python/.coverage pytest --cov=shared/python --cov-config=tests/python/.coveragerc --cov-report=html:tests/python/htmlcov tests/python/ - name: Upload coverage HTML report uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index c2afcc1..3fe9874 100644 --- a/.gitignore +++ b/.gitignore @@ -26,9 +26,7 @@ labs-in-progress/ # Exclude sensitive or generated files .env -# Coverage reports -tests/python/htmlcov/ -# Coverage config and data -tests/python/.coverage -tests/python/.coveragerc +# Coverage data and reports +.coverage +tests/python/htmlcov/ diff --git a/README.md b/README.md index 126f7e8..72fe4bc 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,54 @@ The repo uses the bicep linter and has rules defined in `bicepconfig.json`. See We welcome contributions! Please consider forking the repo and creating issues and pull requests to share your samples. Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details. Thank you! -### Testing -Python modules and files in the `shared` folder are supported with tests in the `python-tests` folder. They can be invoked manually via `pytest -v` but are also run upon push to GitHub origin. The `pytest.ini` file in the root sets up the test structure. +### Testing & Code Coverage -Additional information can be found [here](# https://docs.pytest.org/en/8.2.x/). +Python modules in `shared/python` are covered by comprehensive unit tests located in `tests/python`. All tests use [pytest](https://docs.pytest.org/) and leverage modern pytest features, including custom markers for unit and HTTP tests. + + +#### Running Tests Locally + +- **PowerShell (Windows):** + - Run all tests with coverage: `./tests/python/run_coverage.ps1` +- **Shell (Linux/macOS):** + - Run all tests with coverage: `./tests/python/run_coverage.sh` + +Both scripts: +- Run all tests in `tests/python` using pytest +- Generate a code coverage report (HTML output in `tests/python/htmlcov`) +- Store the raw coverage data in `tests/python/.coverage` + +You can also run tests manually: +```powershell +pytest -v --cov=shared/python --cov-report=html:tests/python/htmlcov --cov-report=term tests/python +``` + +#### Viewing Coverage Reports + +After running tests, open `tests/python/htmlcov/index.html` in your browser to view detailed coverage information. + +#### Pytest Markers + +- `@pytest.mark.unit` โ€” marks a unit test +- `@pytest.mark.http` โ€” marks a test involving HTTP/mocking + +Markers are registered in `pytest.ini` to avoid warnings. + +#### Continuous Integration (CI) + +On every push or pull request, GitHub Actions will: +- Install dependencies +- Run all Python tests in `tests/python` with coverage +- Store the `.coverage` file in `tests/python` +- Upload the HTML coverage report as a workflow artifact for download + +#### Additional Notes + +- The `.gitignore` is configured to exclude coverage output and artifacts. +- All test and coverage features work both locally and in CI. + +For more details on pytest usage, see the [pytest documentation](https://docs.pytest.org/en/8.2.x/). --- diff --git a/tests/python/.coveragerc b/tests/python/.coveragerc new file mode 100644 index 0000000..a7cf676 --- /dev/null +++ b/tests/python/.coveragerc @@ -0,0 +1,11 @@ +[run] +branch = True +source = + shared/python +omit = + */__init__.py + */__pycache__/* + +[report] +show_missing = True +skip_covered = True diff --git a/pytest.ini b/tests/python/pytest.ini similarity index 79% rename from pytest.ini rename to tests/python/pytest.ini index 9a2930f..5293117 100644 --- a/pytest.ini +++ b/tests/python/pytest.ini @@ -5,6 +5,8 @@ markers = utils: tests for utils module integration: integration tests that require external services slow: tests that take a long time to run + unit: marks tests as unit tests + http: marks tests that mock or use HTTP testpaths = tests/python python_files = diff --git a/tests/python/run_coverage.ps1 b/tests/python/run_coverage.ps1 new file mode 100644 index 0000000..48616c1 --- /dev/null +++ b/tests/python/run_coverage.ps1 @@ -0,0 +1,3 @@ +# PowerShell script to run pytest with coverage and store .coverage in tests/python +$env:COVERAGE_FILE = "tests/python/.coverage" +pytest --cov=shared/python --cov-config=tests/python/.coveragerc --cov-report=html:tests/python/htmlcov tests/python/ diff --git a/tests/python/run_coverage.sh b/tests/python/run_coverage.sh new file mode 100644 index 0000000..0e304b4 --- /dev/null +++ b/tests/python/run_coverage.sh @@ -0,0 +1,4 @@ +# Shell script to run pytest with coverage and store .coverage in tests/python +COVERAGE_FILE=tests/python/.coverage +export COVERAGE_FILE +pytest --cov=shared/python --cov-config=tests/python/.coveragerc --cov-report=html:tests/python/htmlcov tests/python/ diff --git a/tests/python/test_apimrequests.py b/tests/python/test_apimrequests.py index a1c6027..a2edc3f 100644 --- a/tests/python/test_apimrequests.py +++ b/tests/python/test_apimrequests.py @@ -15,20 +15,27 @@ def apim(): return ApimRequests(default_url, default_key) + +@pytest.mark.unit def test_init_sets_headers(): + """Test that headers are set correctly when subscription key is provided.""" apim = ApimRequests(default_url, default_key) assert apim.url == default_url assert apim.apimSubscriptionKey == default_key assert apim.headers[SUBSCRIPTION_KEY_PARAMETER_NAME] == default_key assert apim.headers["Accept"] == "application/json" + +@pytest.mark.unit def test_init_no_key(): + """Test that headers are set correctly when no subscription key is provided.""" apim = ApimRequests(default_url) assert apim.url == default_url assert apim.apimSubscriptionKey is None assert "Ocp-Apim-Subscription-Key" not in apim.headers assert apim.headers["Accept"] == "application/json" +@pytest.mark.http @patch("shared.python.apimrequests.requests.request") @patch("shared.python.apimrequests.utils.print_message") @patch("shared.python.apimrequests.utils.print_info") @@ -48,6 +55,7 @@ def test_single_get_success(mock_print_error, mock_print_info, mock_print_messag mock_print_response.assert_called_once_with(mock_response) mock_print_error.assert_not_called() +@pytest.mark.http @patch("shared.python.apimrequests.requests.request") @patch("shared.python.apimrequests.utils.print_message") @patch("shared.python.apimrequests.utils.print_info") @@ -58,6 +66,7 @@ def test_single_get_error(mock_print_error, mock_print_info, mock_print_message, assert result is None mock_print_error.assert_called_once() +@pytest.mark.http @patch("shared.python.apimrequests.requests.request") @patch("shared.python.apimrequests.utils.print_message") @patch("shared.python.apimrequests.utils.print_info") @@ -77,6 +86,7 @@ def test_single_post_success(mock_print_error, mock_print_info, mock_print_messa mock_print_response.assert_called_once_with(mock_response) mock_print_error.assert_not_called() +@pytest.mark.http @patch("shared.python.apimrequests.requests.Session") @patch("shared.python.apimrequests.utils.print_message") @patch("shared.python.apimrequests.utils.print_info") @@ -100,6 +110,7 @@ def test_multi_get_success(mock_print_info, mock_print_message, mock_session, ap assert mock_sess.request.call_count == 2 mock_print_code.assert_called() +@pytest.mark.http @patch("shared.python.apimrequests.requests.Session") @patch("shared.python.apimrequests.utils.print_message") @patch("shared.python.apimrequests.utils.print_info") diff --git a/tests/python/test_apimtypes.py b/tests/python/test_apimtypes.py index 1090b7a..9462bad 100644 --- a/tests/python/test_apimtypes.py +++ b/tests/python/test_apimtypes.py @@ -18,10 +18,10 @@ # PUBLIC METHODS # ------------------------------ + +@pytest.mark.unit def test_api_creation(): - """ - Test creation of API object and its attributes. - """ + """Test creation of API object and its attributes.""" api = apimtypes.API( name = EXAMPLE_NAME, displayName = EXAMPLE_DISPLAY_NAME, @@ -38,10 +38,10 @@ def test_api_creation(): assert api.policyXml == EXAMPLE_POLICY_XML assert api.operations == [] + +@pytest.mark.unit def test_api_repr(): - """ - Test __repr__ method of API. - """ + """Test __repr__ method of API.""" api = apimtypes.API( name = EXAMPLE_NAME, displayName = EXAMPLE_DISPLAY_NAME, @@ -55,9 +55,9 @@ def test_api_repr(): assert EXAMPLE_NAME in result assert EXAMPLE_DISPLAY_NAME in result +@pytest.mark.unit def test_api_equality(): - """ - Test equality comparison for API objects. + """Test equality comparison for API objects. """ api1 = apimtypes.API( name = EXAMPLE_NAME, diff --git a/tests/python/test_utils.py b/tests/python/test_utils.py index ee8b668..380c384 100644 --- a/tests/python/test_utils.py +++ b/tests/python/test_utils.py @@ -8,15 +8,19 @@ # PUBLIC METHODS # ------------------------------ -def test_is_string_json_valid(): - """Test is_string_json with valid JSON strings.""" - assert utils.is_string_json('{"a": 1}') is True - assert utils.is_string_json('[1, 2, 3]') is True - -def test_is_string_json_invalid(): - """Test is_string_json with invalid JSON strings.""" - assert utils.is_string_json('not json') is False - assert utils.is_string_json('{"a": 1') is False + +@pytest.mark.parametrize( + "input_str,expected", + [ + ('{"a": 1}', True), + ('[1, 2, 3]', True), + ('not json', False), + ('{"a": 1', False), + ] +) +def test_is_string_json(input_str, expected): + """Test is_string_json with valid and invalid JSON strings.""" + assert utils.is_string_json(input_str) is expected def test_extract_json_object(): """Test extract_json extracts JSON object from string.""" From 4c24e90ff673816c6dae8cbd392201bd4568a99a Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 06:57:39 -0400 Subject: [PATCH 07/24] Update test information --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 72fe4bc..c3cef9a 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,6 @@ We welcome contributions! Please consider forking the repo and creating issues a Python modules in `shared/python` are covered by comprehensive unit tests located in `tests/python`. All tests use [pytest](https://docs.pytest.org/) and leverage modern pytest features, including custom markers for unit and HTTP tests. - #### Running Tests Locally - **PowerShell (Windows):** @@ -105,8 +104,8 @@ Both scripts: - Generate a code coverage report (HTML output in `tests/python/htmlcov`) - Store the raw coverage data in `tests/python/.coverage` -You can also run tests manually: -```powershell +You can also run tests manually and see details in the console: +```sh pytest -v --cov=shared/python --cov-report=html:tests/python/htmlcov --cov-report=term tests/python ``` From 5298dfe87655b30d88788faab814c0455173c62a Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 07:00:24 -0400 Subject: [PATCH 08/24] Add Python tests badge --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c3cef9a..b03673f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Azure API Management Samples +[![Python Tests](https://github.com/Azure-Samples/Apim-Samples/actions/workflows/python-tests.yml/badge.svg?branch=main)](https://github.com/Azure-Samples/Apim-Samples/actions/workflows/python-tests.yml) + This repository provides a playground to safely experiment with and learn Azure API Management (APIM) policies in various architectures. ## Objectives From 26bc17310680fa62edcb727ccfe5a84fab0b1ddc Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 07:00:57 -0400 Subject: [PATCH 09/24] Enable workflow dispatch --- .github/workflows/python-tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 2bec27b..f9080c7 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -1,6 +1,7 @@ name: Python Tests on: + workflow_dispatch: push: branches: [ main ] pull_request: From d142cb96819fa7b1cee0b86baaca0048622a5cec Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 07:12:39 -0400 Subject: [PATCH 10/24] Fix module path --- .github/workflows/python-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index f9080c7..36f72a0 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -32,7 +32,7 @@ jobs: - name: Run pytest with coverage run: | - COVERAGE_FILE=tests/python/.coverage pytest --cov=shared/python --cov-config=tests/python/.coveragerc --cov-report=html:tests/python/htmlcov tests/python/ + PYTHONPATH=$(pwd) COVERAGE_FILE=tests/python/.coverage pytest --cov=shared/python --cov-config=tests/python/.coveragerc --cov-report=html:tests/python/htmlcov tests/python/ - name: Upload coverage HTML report uses: actions/upload-artifact@v4 From d90587e84c0b1d0b98332731254ae89a8b71aa94 Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 07:17:02 -0400 Subject: [PATCH 11/24] Separate test results by python version --- .github/workflows/python-tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 36f72a0..d3ae095 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -32,10 +32,10 @@ jobs: - name: Run pytest with coverage run: | - PYTHONPATH=$(pwd) COVERAGE_FILE=tests/python/.coverage pytest --cov=shared/python --cov-config=tests/python/.coveragerc --cov-report=html:tests/python/htmlcov tests/python/ + PYTHONPATH=$(pwd) COVERAGE_FILE=tests/python/.coverage-${{ matrix.python-version }} pytest --cov=shared/python --cov-config=tests/python/.coveragerc --cov-report=html:tests/python/htmlcov-${{ matrix.python-version }} tests/python/ - name: Upload coverage HTML report uses: actions/upload-artifact@v4 with: - name: coverage-html - path: tests/python/htmlcov/ + name: coverage-html-${{ matrix.python-version }} + path: tests/python/htmlcov-${{ matrix.python-version }}/ From fb02f8f155412ea37e6189a1bc9c272bdf77d345 Mon Sep 17 00:00:00 2001 From: Simon Kurtz <84809797+simonkurtz-MSFT@users.noreply.github.com> Date: Fri, 23 May 2025 07:22:49 -0400 Subject: [PATCH 12/24] Update tests/python/test_apimrequests.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/python/test_apimrequests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/python/test_apimrequests.py b/tests/python/test_apimrequests.py index a2edc3f..c4e53fb 100644 --- a/tests/python/test_apimrequests.py +++ b/tests/python/test_apimrequests.py @@ -119,6 +119,6 @@ def test_multi_get_error(mock_print_info, mock_print_message, mock_session, apim mock_sess.request.side_effect = requests.exceptions.RequestException("fail") mock_session.return_value = mock_sess with patch.object(apim, "_print_response_code"): - # Should raise inside the loop, but function should still close session and return what it has + # Should raise inside the loop and propagate the exception, ensuring the session is closed with pytest.raises(requests.exceptions.RequestException): apim.multiGet(default_path, runs=1, printResponse=True) From 3a6bfa203f53fa43a8f7acd70d057845257a584e Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 07:28:56 -0400 Subject: [PATCH 13/24] JUnit test results --- .github/workflows/python-tests.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index d3ae095..7124dd4 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -30,12 +30,18 @@ jobs: run: | pip install pytest pytest-cov - - name: Run pytest with coverage + - name: Run pytest with coverage and generate JUnit XML run: | - PYTHONPATH=$(pwd) COVERAGE_FILE=tests/python/.coverage-${{ matrix.python-version }} pytest --cov=shared/python --cov-config=tests/python/.coveragerc --cov-report=html:tests/python/htmlcov-${{ matrix.python-version }} tests/python/ + PYTHONPATH=$(pwd) COVERAGE_FILE=tests/python/.coverage-${{ matrix.python-version }} pytest --cov=shared/python --cov-config=tests/python/.coveragerc --cov-report=html:tests/python/htmlcov-${{ matrix.python-version }} --junitxml=tests/python/junit-${{ matrix.python-version }}.xml tests/python/ - name: Upload coverage HTML report uses: actions/upload-artifact@v4 with: name: coverage-html-${{ matrix.python-version }} path: tests/python/htmlcov-${{ matrix.python-version }}/ + + - name: Upload JUnit test results + uses: actions/upload-artifact@v4 + with: + name: junit-results-${{ matrix.python-version }} + path: tests/python/junit-${{ matrix.python-version }}.xml From 5d8969222bdf5ac42332e7f2d393b96b2986bca8 Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 07:32:45 -0400 Subject: [PATCH 14/24] Publish Unit Test Results to the PR --- .github/workflows/python-tests.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 7124dd4..afc8313 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -45,3 +45,9 @@ jobs: with: name: junit-results-${{ matrix.python-version }} path: tests/python/junit-${{ matrix.python-version }}.xml + + - name: Publish Unit Test Results to PR + if: always() + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: tests/python/junit-*.xml \ No newline at end of file From feb99d0e30a98a20322141cd769b573993631b2b Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 07:35:35 -0400 Subject: [PATCH 15/24] Fix permissions --- .github/workflows/python-tests.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index afc8313..4881362 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -9,6 +9,8 @@ on: permissions: contents: read + checks: write + pull-requests: write jobs: test: @@ -18,14 +20,17 @@ jobs: python-version: [ '3.12', '3.13' ] steps: - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt + - name: Install pytest and pytest-cov run: | pip install pytest pytest-cov From 7b86a49ff5263543a50453219d681fb09e72c7eb Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 07:38:40 -0400 Subject: [PATCH 16/24] Customize test results title --- .github/workflows/python-tests.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 4881362..1a948b1 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -55,4 +55,5 @@ jobs: if: always() uses: EnricoMi/publish-unit-test-result-action@v2 with: - files: tests/python/junit-*.xml \ No newline at end of file + files: tests/python/junit-${{ matrix.python-version }}.xml + comment_title: Python ${{ matrix.python-version }} Test Results \ No newline at end of file From 9208c9c10187cebca866bd2e56b44ae04ebb4351 Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 09:28:13 -0400 Subject: [PATCH 17/24] Fix extract_json docstring --- shared/python/utils.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/shared/python/utils.py b/shared/python/utils.py index 79b201e..b5a5678 100644 --- a/shared/python/utils.py +++ b/shared/python/utils.py @@ -338,31 +338,32 @@ def cleanup_resources(deployment: str | INFRASTRUCTURE, rg_name: str ) -> None: def extract_json(text: str) -> any: """ - Extract the first valid JSON object or array from a string and return it as a JSON string. - Uses json.JSONDecoder().raw_decode to robustly parse the first JSON object or array found in the string. The JSON object may be buried in the string and have preceding or trailing regular text. + Extract the first valid JSON object or array from a string and return it as a Python object. + + This function searches the input string for the first occurrence of a JSON object or array (delimited by '{' or '['), + and attempts to decode it using json.JSONDecoder().raw_decode. If the input is already valid JSON, it is returned as a Python object. + If no valid JSON is found, None is returned. Args: - text (str): The string to search for JSON. + text (str): The string to search for a JSON object or array. Returns: - str | None: The extracted JSON as a string, or None if not found. + Any | None: The extracted JSON as a Python object (dict or list), or None if not found or not valid. """ if not isinstance(text, str): return None - - # If the string is already JSON, return it. - if is_string_json(text): - return text - print(text) + # If the string is already valid JSON, parse and return it as a Python object. + if is_string_json(text): + return json.loads(text) decoder = json.JSONDecoder() for start in range(len(text)): if text[start] in ('{', '['): try: - obj, end = decoder.raw_decode(text[start:]) + obj, _ = decoder.raw_decode(text[start:]) return obj except Exception: continue From 74ad5446063205e51e390dc9d7f0e3f1d186bdb2 Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 09:52:40 -0400 Subject: [PATCH 18/24] Ensure code coverage and test comments still occur upon test failures --- .github/workflows/python-tests.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 1a948b1..d602849 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -35,9 +35,12 @@ jobs: run: | pip install pytest pytest-cov + # Run tests with continue-on-error so that coverage and PR comments are always published. + # The final step will explicitly fail the job if any test failed, ensuring PRs cannot be merged with failing tests. - name: Run pytest with coverage and generate JUnit XML run: | PYTHONPATH=$(pwd) COVERAGE_FILE=tests/python/.coverage-${{ matrix.python-version }} pytest --cov=shared/python --cov-config=tests/python/.coveragerc --cov-report=html:tests/python/htmlcov-${{ matrix.python-version }} --junitxml=tests/python/junit-${{ matrix.python-version }}.xml tests/python/ + continue-on-error: true - name: Upload coverage HTML report uses: actions/upload-artifact@v4 @@ -56,4 +59,14 @@ jobs: uses: EnricoMi/publish-unit-test-result-action@v2 with: files: tests/python/junit-${{ matrix.python-version }}.xml - comment_title: Python ${{ matrix.python-version }} Test Results \ No newline at end of file + comment_title: Python ${{ matrix.python-version }} Test Results + + # Explicitly fail the job if any test failed (so PRs cannot be merged with failing tests). + # This runs after all reporting steps, meaning coverage and PR comments are always published. + - name: Fail if tests failed + if: always() + run: | + if grep -q 'failures="[1-9]' tests/python/junit-${{ matrix.python-version }}.xml; then + echo "::error ::Unit tests failed. See above for details." + exit 1 + fi \ No newline at end of file From 84aacdf10adc84f8c0c8a9265f5841099602b295 Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 10:08:27 -0400 Subject: [PATCH 19/24] Fix is_string_json --- shared/python/utils.py | 12 +- tests/python/test_utils.py | 271 ++++++++++++++++++++++++++++++------- 2 files changed, 234 insertions(+), 49 deletions(-) diff --git a/shared/python/utils.py b/shared/python/utils.py index b5a5678..62f6d1c 100644 --- a/shared/python/utils.py +++ b/shared/python/utils.py @@ -381,10 +381,14 @@ def is_string_json(text: str) -> bool: bool: True if the string is valid JSON, False otherwise. """ + # Accept only str, bytes, or bytearray as valid input for JSON parsing. + if not isinstance(text, (str, bytes, bytearray)): + return False + try: json.loads(text) return True - except ValueError: + except (ValueError, TypeError): return False def get_account_info() -> Tuple[str, str, str]: @@ -539,11 +543,13 @@ def run(command: str, ok_message: str = '', error_message: str = '', print_outpu start_time = time.time() # Execute the command and capture the output + try: output_text = subprocess.check_output(command, shell = True, stderr = subprocess.STDOUT).decode("utf-8") success = True - except subprocess.CalledProcessError as e: - output_text = e.output.decode("utf-8") + except Exception as e: + # Handles both CalledProcessError and any custom/other exceptions (for test mocks) + output_text = getattr(e, 'output', b'').decode("utf-8") if hasattr(e, 'output') and isinstance(e.output, (bytes, bytearray)) else str(e) success = False if print_output: diff --git a/tests/python/test_utils.py b/tests/python/test_utils.py index 380c384..26a0f32 100644 --- a/tests/python/test_utils.py +++ b/tests/python/test_utils.py @@ -1,60 +1,239 @@ -""" -Unit tests for utils.py. -""" +import os +import builtins import pytest -from shared.python import utils +from unittest.mock import patch, MagicMock, mock_open # ------------------------------ -# PUBLIC METHODS +# is_string_json # ------------------------------ - @pytest.mark.parametrize( "input_str,expected", [ - ('{"a": 1}', True), - ('[1, 2, 3]', True), - ('not json', False), - ('{"a": 1', False), + ("{\"a\": 1}", True), + ("[1, 2, 3]", True), + ("not json", False), + ("{\"a\": 1", False), + ("", False), + (None, False), + (123, False), ] ) def test_is_string_json(input_str, expected): - """Test is_string_json with valid and invalid JSON strings.""" - assert utils.is_string_json(input_str) is expected - -def test_extract_json_object(): - """Test extract_json extracts JSON object from string.""" - s = 'prefix {"foo": 42, "bar": "baz"} suffix' - result = utils.extract_json(s) - assert isinstance(result, dict) - assert result["foo"] == 42 - assert result["bar"] == "baz" - -def test_extract_json_array(): - """Test extract_json extracts JSON array from string.""" - s = 'prefix [1, 2, 3] suffix' - result = utils.extract_json(s) - assert isinstance(result, list) - assert result == [1, 2, 3] - -def test_extract_json_none(): - """Test extract_json returns None if no JSON found.""" - s = 'no json here' - assert utils.extract_json(s) is None - -def test_get_rg_name_basic(): - """Test get_rg_name returns correct resource group name.""" - assert utils.get_rg_name("foo") == "apim-sample-foo" - -def test_get_rg_name_with_index(): - """Test get_rg_name with index appends index.""" - assert utils.get_rg_name("foo", 2) == "apim-sample-foo-2" + from shared.python.utils import is_string_json + assert is_string_json(input_str) is expected + +# ------------------------------ +# get_account_info +# ------------------------------ + +def test_get_account_info_success(monkeypatch): + from shared.python import utils + mock_json = { + 'user': {'name': 'testuser'}, + 'tenantId': 'tenant', + 'id': 'subid' + } + mock_output = MagicMock(success=True, json_data=mock_json) + monkeypatch.setattr(utils, 'run', lambda *a, **kw: mock_output) + result = utils.get_account_info() + assert result == ('testuser', 'tenant', 'subid') + +def test_get_account_info_failure(monkeypatch): + from shared.python import utils + mock_output = MagicMock(success=False, json_data=None) + monkeypatch.setattr(utils, 'run', lambda *a, **kw: mock_output) + with pytest.raises(Exception): + utils.get_account_info() + +# ------------------------------ +# get_deployment_name +# ------------------------------ + +def test_get_deployment_name(monkeypatch): + from shared.python import utils + monkeypatch.setattr(os, 'getcwd', lambda: '/foo/bar/baz') + assert utils.get_deployment_name() == 'baz' + +def test_get_deployment_name_error(monkeypatch): + from shared.python import utils + monkeypatch.setattr(os, 'getcwd', lambda: '') + with pytest.raises(RuntimeError): + utils.get_deployment_name() + +# ------------------------------ +# get_frontdoor_url +# ------------------------------ + +def test_get_frontdoor_url_success(monkeypatch): + from shared.python import utils + from apimtypes import INFRASTRUCTURE + mock_profile = [{"name": "afd1"}] + mock_endpoints = [{"hostName": "foo.azurefd.net"}] + def run_side_effect(cmd, *a, **kw): + if 'profile list' in cmd: + return MagicMock(success=True, json_data=mock_profile) + if 'endpoint list' in cmd: + return MagicMock(success=True, json_data=mock_endpoints) + return MagicMock(success=False, json_data=None) + monkeypatch.setattr(utils, 'run', run_side_effect) + url = utils.get_frontdoor_url(INFRASTRUCTURE.AFD_APIM_PE, 'rg') + assert url == 'https://foo.azurefd.net' + +def test_get_frontdoor_url_none(monkeypatch): + from shared.python import utils + from apimtypes import INFRASTRUCTURE + monkeypatch.setattr(utils, 'run', lambda *a, **kw: MagicMock(success=False, json_data=None)) + url = utils.get_frontdoor_url(INFRASTRUCTURE.AFD_APIM_PE, 'rg') + assert url is None + +# ------------------------------ +# get_infra_rg_name & get_rg_name +# ------------------------------ def test_get_infra_rg_name(monkeypatch): - """Test get_infra_rg_name returns correct name and validates infra.""" + from shared.python import utils class DummyInfra: - value = "bar" - # Patch validate_infrastructure to a no-op - monkeypatch.setattr(utils, "validate_infrastructure", lambda x: x) - assert utils.get_infra_rg_name(DummyInfra) == "apim-infra-bar" - assert utils.get_infra_rg_name(DummyInfra, 3) == "apim-infra-bar-3" \ No newline at end of file + value = 'foo' + monkeypatch.setattr(utils, 'validate_infrastructure', lambda x: x) + assert utils.get_infra_rg_name(DummyInfra) == 'apim-infra-foo' + assert utils.get_infra_rg_name(DummyInfra, 2) == 'apim-infra-foo-2' + +def test_get_rg_name(): + from shared.python import utils + assert utils.get_rg_name('foo') == 'apim-sample-foo' + assert utils.get_rg_name('foo', 3) == 'apim-sample-foo-3' + +# ------------------------------ +# run +# ------------------------------ + +def test_run_success(monkeypatch): + from shared.python import utils + monkeypatch.setattr('subprocess.check_output', lambda *a, **kw: b'{"a": 1}') + out = utils.run('echo', print_command_to_run=False) + assert out.success is True + assert out.json_data == {"a": 1} + +def test_run_failure(monkeypatch): + from shared.python import utils + class DummyErr(Exception): + output = b'fail' + def fail(*a, **kw): + raise DummyErr() + monkeypatch.setattr('subprocess.check_output', fail) + out = utils.run('bad', print_command_to_run=False) + assert out.success is False + assert isinstance(out.text, str) + +# ------------------------------ +# create_resource_group & does_resource_group_exist +# ------------------------------ + +def test_does_resource_group_exist(monkeypatch): + from shared.python import utils + monkeypatch.setattr(utils, 'run', lambda *a, **kw: MagicMock(success=True)) + assert utils.does_resource_group_exist('foo') is True + monkeypatch.setattr(utils, 'run', lambda *a, **kw: MagicMock(success=False)) + assert utils.does_resource_group_exist('foo') is False + +def test_create_resource_group(monkeypatch): + from shared.python import utils + called = {} + monkeypatch.setattr(utils, 'does_resource_group_exist', lambda rg: False) + monkeypatch.setattr(utils, 'print_info', lambda *a, **kw: called.setdefault('info', True)) + monkeypatch.setattr(utils, 'run', lambda *a, **kw: called.setdefault('run', True)) + utils.create_resource_group('foo', 'bar') + assert called['info'] and called['run'] + +# ------------------------------ +# policy_xml_replacement +# ------------------------------ + +def test_policy_xml_replacement(monkeypatch): + from shared.python import utils + m = mock_open(read_data='foo') + monkeypatch.setattr(builtins, 'open', m) + assert utils.policy_xml_replacement('dummy.xml') == 'foo' + +# ------------------------------ +# cleanup_resources (smoke) +# ------------------------------ + +def test_cleanup_resources_smoke(monkeypatch): + from shared.python import utils + from apimtypes import INFRASTRUCTURE + monkeypatch.setattr(utils, 'run', lambda *a, **kw: MagicMock(success=True, json_data={})) + monkeypatch.setattr(utils, 'print_info', lambda *a, **kw: None) + monkeypatch.setattr(utils, 'print_error', lambda *a, **kw: None) + monkeypatch.setattr(utils, 'print_message', lambda *a, **kw: None) + monkeypatch.setattr(utils, 'print_ok', lambda *a, **kw: None) + monkeypatch.setattr(utils, 'print_warning', lambda *a, **kw: None) + monkeypatch.setattr(utils, 'print_val', lambda *a, **kw: None) + utils.cleanup_resources(INFRASTRUCTURE.SIMPLE_APIM, 'rg') + +# ------------------------------ +# EXTRACT_JSON EDGE CASES +# ------------------------------ + +@pytest.mark.parametrize( + "input_val,expected", + [ + (None, None), + (123, None), + ([], None), + ("", None), + (" ", None), + ("not json", None), + ("{\"a\": 1}", {"a": 1}), + ("[1, 2, 3]", [1, 2, 3]), + (" {\"a\": 1} ", {"a": 1}), + ("prefix {\"foo\": 42} suffix", {"foo": 42}), + ("prefix [1, 2, 3] suffix", [1, 2, 3]), + ("{\"a\": 1}{\"b\": 2}", {"a": 1}), # Only first JSON object + ("[1, 2, 3][4, 5, 6]", [1, 2, 3]), # Only first JSON array + ("{\"a\": 1,}", None), # Trailing comma + ("[1, 2,]", None), # Trailing comma in array + ("{\"a\": [1, 2, {\"b\": 3}]}", {"a": [1, 2, {"b": 3}]}), + ("\n\t{\"a\": 1}\n", {"a": 1}), + ("{\"a\": \"b \\u1234\"}", {"a": "b \u1234"}), + ("{\"a\": 1} [2, 3]", {"a": 1}), # Object before array + ("[2, 3] {\"a\": 1}", [2, 3]), # Array before object + ("{\"a\": 1, \"b\": {\"c\": 2}}", {"a": 1, "b": {"c": 2}}), + ("{\"a\": 1, \"b\": [1, 2, 3]}", {"a": 1, "b": [1, 2, 3]}), + ("\n\n[\n1, 2, 3\n]\n", [1, 2, 3]), + ("{\"a\": 1, \"b\": null}", {"a": 1, "b": None}), + ("{\"a\": true, \"b\": false}", {"a": True, "b": False}), + ("{\"a\": 1, \"b\": \"c\"}", {"a": 1, "b": "c"}), + ("{\"a\": 1, \"b\": [1, 2, {\"c\": 3}]} ", {"a": 1, "b": [1, 2, {"c": 3}]}), + ("{\"a\": 1, \"b\": [1, 2, {\"c\": 3, \"d\": [4, 5]}]} ", {"a": 1, "b": [1, 2, {"c": 3, "d": [4, 5]}]}), + ] +) +def test_extract_json_edge_cases(input_val, expected): + """Test extract_json with a wide range of edge cases and malformed input.""" + from shared.python.utils import extract_json + result = extract_json(input_val) + assert result == expected + +def test_extract_json_large_object(): + """Test extract_json with a large JSON object.""" + from shared.python.utils import extract_json + large_obj = {"a": list(range(1000)), "b": {"c": "x" * 1000}} + import json + s = json.dumps(large_obj) + assert extract_json(s) == large_obj + +def test_extract_json_multiple_json_types(): + """Test extract_json returns the first valid JSON (object or array) in the string.""" + from shared.python.utils import extract_json + s = '[1,2,3]{"a": 1}' + assert extract_json(s) == [1, 2, 3] + s2 = '{"a": 1}[1,2,3]' + assert extract_json(s2) == {"a": 1} + +""" +Unit tests for utils.py. +""" +import pytest +from shared.python import utils + From 0876c5d61a45cc0988dd0848501e656ce493990a Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 10:22:12 -0400 Subject: [PATCH 20/24] Rename test run files --- README.md | 4 ++-- tests/python/{run_coverage.ps1 => run_tests.ps1} | 0 tests/python/{run_coverage.sh => run_tests.sh} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename tests/python/{run_coverage.ps1 => run_tests.ps1} (100%) rename tests/python/{run_coverage.sh => run_tests.sh} (100%) diff --git a/README.md b/README.md index b03673f..23bbf39 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,9 @@ Python modules in `shared/python` are covered by comprehensive unit tests locate #### Running Tests Locally - **PowerShell (Windows):** - - Run all tests with coverage: `./tests/python/run_coverage.ps1` + - Run all tests with coverage: `./tests/python/run_tests.ps1` - **Shell (Linux/macOS):** - - Run all tests with coverage: `./tests/python/run_coverage.sh` + - Run all tests with coverage: `./tests/python/run_tests.sh` Both scripts: - Run all tests in `tests/python` using pytest diff --git a/tests/python/run_coverage.ps1 b/tests/python/run_tests.ps1 similarity index 100% rename from tests/python/run_coverage.ps1 rename to tests/python/run_tests.ps1 diff --git a/tests/python/run_coverage.sh b/tests/python/run_tests.sh similarity index 100% rename from tests/python/run_coverage.sh rename to tests/python/run_tests.sh From d9da993ca23d3bacdf3f16f29bcd473a0ad17fd7 Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 10:30:42 -0400 Subject: [PATCH 21/24] Add tests, fix failing code --- shared/python/apimtypes.py | 7 +++ tests/python/run_tests.ps1 | 2 +- tests/python/run_tests.sh | 2 +- tests/python/test_apimrequests.py | 72 ++++++++++++++++++++++ tests/python/test_apimtypes.py | 99 ++++++++++++++++++++++++++++++- 5 files changed, 179 insertions(+), 3 deletions(-) diff --git a/shared/python/apimtypes.py b/shared/python/apimtypes.py index fa419af..6b65415 100644 --- a/shared/python/apimtypes.py +++ b/shared/python/apimtypes.py @@ -145,6 +145,13 @@ class APIOperation: # ------------------------------ def __init__(self, name: str, displayName: str, urlTemplate: str, method: HTTP_VERB, description: str, policyXml: str): + # Validate that method is a valid HTTP_VERB + if not isinstance(method, HTTP_VERB): + try: + method = HTTP_VERB(method) + except Exception: + raise ValueError(f"Invalid HTTP_VERB: {method}") + self.name = name self.displayName = displayName self.method = method diff --git a/tests/python/run_tests.ps1 b/tests/python/run_tests.ps1 index 48616c1..a5cd748 100644 --- a/tests/python/run_tests.ps1 +++ b/tests/python/run_tests.ps1 @@ -1,3 +1,3 @@ # PowerShell script to run pytest with coverage and store .coverage in tests/python $env:COVERAGE_FILE = "tests/python/.coverage" -pytest --cov=shared/python --cov-config=tests/python/.coveragerc --cov-report=html:tests/python/htmlcov tests/python/ +pytest -v --cov=shared/python --cov-config=tests/python/.coveragerc --cov-report=html:tests/python/htmlcov tests/python/ diff --git a/tests/python/run_tests.sh b/tests/python/run_tests.sh index 0e304b4..b5160e9 100644 --- a/tests/python/run_tests.sh +++ b/tests/python/run_tests.sh @@ -1,4 +1,4 @@ # Shell script to run pytest with coverage and store .coverage in tests/python COVERAGE_FILE=tests/python/.coverage export COVERAGE_FILE -pytest --cov=shared/python --cov-config=tests/python/.coveragerc --cov-report=html:tests/python/htmlcov tests/python/ +pytest -v --cov=shared/python --cov-config=tests/python/.coveragerc --cov-report=html:tests/python/htmlcov tests/python/ diff --git a/tests/python/test_apimrequests.py b/tests/python/test_apimrequests.py index c4e53fb..f968ff4 100644 --- a/tests/python/test_apimrequests.py +++ b/tests/python/test_apimrequests.py @@ -122,3 +122,75 @@ def test_multi_get_error(mock_print_info, mock_print_message, mock_session, apim # Should raise inside the loop and propagate the exception, ensuring the session is closed with pytest.raises(requests.exceptions.RequestException): apim.multiGet(default_path, runs=1, printResponse=True) + + + +# Sample values for tests +url = "https://example.com/apim/" +key = "test-key" +path = "/test" + +def make_apim(): + return ApimRequests(url, key) + +@pytest.mark.http +def test_single_post_error(): + apim = make_apim() + with patch("shared.python.apimrequests.requests.request") as mock_request, \ + patch("shared.python.apimrequests.utils.print_error") as mock_print_error: + import requests + mock_request.side_effect = requests.RequestException("fail") + result = apim.singlePost(path, data={"foo": "bar"}, printResponse=True) + assert result is None + mock_print_error.assert_called() + +@pytest.mark.http +def test_multi_get_non_json(): + apim = make_apim() + with patch("shared.python.apimrequests.requests.Session") as mock_session: + mock_sess = MagicMock() + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"Content-Type": "text/plain"} + mock_response.text = "not json" + mock_response.raise_for_status.return_value = None + mock_sess.request.return_value = mock_response + mock_session.return_value = mock_sess + with patch.object(apim, "_print_response_code"): + result = apim.multiGet(path, runs=1, printResponse=True) + assert result[0]["response"] == "not json" + +@pytest.mark.http +def test_request_header_merging(): + apim = make_apim() + with patch("shared.python.apimrequests.requests.request") as mock_request: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"Content-Type": "application/json"} + mock_response.json.return_value = {"ok": True} + mock_response.text = '{"ok": true}' + mock_response.raise_for_status.return_value = None + mock_request.return_value = mock_response + # Custom header should override default + custom_headers = {"Accept": "application/xml", "X-Test": "1"} + with patch.object(apim, "_print_response"): + apim.singleGet(path, headers=custom_headers, printResponse=True) + called_headers = mock_request.call_args[1]["headers"] + assert called_headers["Accept"] == "application/xml" + assert called_headers["X-Test"] == "1" + +@pytest.mark.http +def test_init_missing_url(): + # Negative: missing URL should raise TypeError + with pytest.raises(TypeError): + ApimRequests() + +@pytest.mark.http +def test_print_response_code_edge(): + apim = make_apim() + class DummyResponse: + status_code = 302 + reason = "Found" + with patch("shared.python.apimrequests.utils.print_val") as mock_print_val: + apim._print_response_code(DummyResponse()) + mock_print_val.assert_called_with("Response status", "302") diff --git a/tests/python/test_apimtypes.py b/tests/python/test_apimtypes.py index 9462bad..f86a205 100644 --- a/tests/python/test_apimtypes.py +++ b/tests/python/test_apimtypes.py @@ -141,4 +141,101 @@ def test_api_missing_fields(): displayName = EXAMPLE_DISPLAY_NAME, path = EXAMPLE_PATH, description = EXAMPLE_DESCRIPTION - ) \ No newline at end of file + ) + + + + +# ------------------------------ +# ENUMS +# ------------------------------ + +def test_apimnetworkmode_enum(): + assert apimtypes.APIMNetworkMode.PUBLIC == "Public" + assert apimtypes.APIMNetworkMode.EXTERNAL_VNET == "External" + assert apimtypes.APIMNetworkMode.INTERNAL_VNET == "Internal" + assert apimtypes.APIMNetworkMode.NONE == "None" + with pytest.raises(ValueError): + apimtypes.APIMNetworkMode("invalid") + +def test_apim_sku_enum(): + assert apimtypes.APIM_SKU.DEVELOPER == "Developer" + assert apimtypes.APIM_SKU.BASIC == "Basic" + assert apimtypes.APIM_SKU.STANDARD == "Standard" + assert apimtypes.APIM_SKU.PREMIUM == "Premium" + assert apimtypes.APIM_SKU.BASICV2 == "Basicv2" + assert apimtypes.APIM_SKU.STANDARDV2 == "Standardv2" + assert apimtypes.APIM_SKU.PREMIUMV2 == "Premiumv2" + with pytest.raises(ValueError): + apimtypes.APIM_SKU("invalid") + +def test_http_verb_enum(): + assert apimtypes.HTTP_VERB.GET == "GET" + assert apimtypes.HTTP_VERB.POST == "POST" + assert apimtypes.HTTP_VERB.PUT == "PUT" + assert apimtypes.HTTP_VERB.DELETE == "DELETE" + assert apimtypes.HTTP_VERB.PATCH == "PATCH" + assert apimtypes.HTTP_VERB.OPTIONS == "OPTIONS" + assert apimtypes.HTTP_VERB.HEAD == "HEAD" + with pytest.raises(ValueError): + apimtypes.HTTP_VERB("FOO") + +def test_infrastructure_enum(): + assert apimtypes.INFRASTRUCTURE.SIMPLE_APIM == "simple-apim" + assert apimtypes.INFRASTRUCTURE.APIM_ACA == "apim-aca" + assert apimtypes.INFRASTRUCTURE.AFD_APIM_PE == "afd-apim-pe" + with pytest.raises(ValueError): + apimtypes.INFRASTRUCTURE("bad") + +# ------------------------------ +# OPERATION CLASSES +# ------------------------------ + +def test_apioperation_to_dict(): + op = apimtypes.APIOperation( + name="op1", + displayName="Operation 1", + urlTemplate="/foo", + method=apimtypes.HTTP_VERB.GET, + description="desc", + policyXml="" + ) + d = op.to_dict() + assert d["name"] == "op1" + assert d["displayName"] == "Operation 1" + assert d["urlTemplate"] == "/foo" + assert d["method"] == apimtypes.HTTP_VERB.GET + assert d["description"] == "desc" + assert d["policyXml"] == "" + +def test_get_apioperation(): + op = apimtypes.GET_APIOperation(description="desc", policyXml="") + assert op.name == "GET" + assert op.method == apimtypes.HTTP_VERB.GET + assert op.urlTemplate == "/" + assert op.description == "desc" + assert op.policyXml == "" + d = op.to_dict() + assert d["method"] == apimtypes.HTTP_VERB.GET + +def test_post_apioperation(): + op = apimtypes.POST_APIOperation(description="desc", policyXml="") + assert op.name == "POST" + assert op.method == apimtypes.HTTP_VERB.POST + assert op.urlTemplate == "/" + assert op.description == "desc" + assert op.policyXml == "" + d = op.to_dict() + assert d["method"] == apimtypes.HTTP_VERB.POST + +def test_apioperation_invalid_method(): + # Negative: method must be a valid HTTP_VERB + with pytest.raises(ValueError): + apimtypes.APIOperation( + name="bad", + displayName="Bad", + urlTemplate="/bad", + method="INVALID", + description="desc", + policyXml="" + ) From 7b7d6d05f89776bd875fad56daf6d99d552a23d2 Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 10:33:42 -0400 Subject: [PATCH 22/24] Clean up --- tests/python/test_apimrequests.py | 1 - tests/python/test_utils.py | 6 ------ 2 files changed, 7 deletions(-) diff --git a/tests/python/test_apimrequests.py b/tests/python/test_apimrequests.py index f968ff4..344a943 100644 --- a/tests/python/test_apimrequests.py +++ b/tests/python/test_apimrequests.py @@ -124,7 +124,6 @@ def test_multi_get_error(mock_print_info, mock_print_message, mock_session, apim apim.multiGet(default_path, runs=1, printResponse=True) - # Sample values for tests url = "https://example.com/apim/" key = "test-key" diff --git a/tests/python/test_utils.py b/tests/python/test_utils.py index 26a0f32..b313e57 100644 --- a/tests/python/test_utils.py +++ b/tests/python/test_utils.py @@ -231,9 +231,3 @@ def test_extract_json_multiple_json_types(): s2 = '{"a": 1}[1,2,3]' assert extract_json(s2) == {"a": 1} -""" -Unit tests for utils.py. -""" -import pytest -from shared.python import utils - From 86141d21e3bfc6951b05717fab4f477a38029532 Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 10:58:45 -0400 Subject: [PATCH 23/24] Clean up --- shared/python/utils.py | 62 ++++++++++++++++++++++++++----- tests/python/test_apimrequests.py | 1 - tests/python/test_apimtypes.py | 8 ++-- tests/python/test_utils.py | 49 +++++++++++------------- 4 files changed, 77 insertions(+), 43 deletions(-) diff --git a/shared/python/utils.py b/shared/python/utils.py index 62f6d1c..d3bb89b 100644 --- a/shared/python/utils.py +++ b/shared/python/utils.py @@ -262,14 +262,13 @@ def policy_xml_replacement(policy_xml_filepath: str) -> str: # Convert the XML to JSON format return policy_template_xml -# Cleans up resources associated with a deployment in a resource group -def cleanup_resources(deployment: str | INFRASTRUCTURE, rg_name: str ) -> None: +def _cleanup_resources(deployment_name: str, rg_name: str) -> None: """ Clean up resources associated with a deployment in a resource group. Deletes and purges Cognitive Services, API Management, and Key Vault resources, then deletes the resource group itself. Args: - deployment (str | INFRASTRUCTURE): The deployment name or enum value. + deployment_name (str): The deployment name (string). rg_name (str): The resource group name. Returns: @@ -278,8 +277,7 @@ def cleanup_resources(deployment: str | INFRASTRUCTURE, rg_name: str ) -> None: Raises: Exception: If an error occurs during cleanup. """ - - if not deployment: + if not deployment_name: print_error("Missing deployment name parameter.") return @@ -288,11 +286,6 @@ def cleanup_resources(deployment: str | INFRASTRUCTURE, rg_name: str ) -> None: return try: - if hasattr(deployment, 'value'): - deployment_name = deployment.value - else: - deployment_name = deployment - print_info(f"๐Ÿงน Cleaning up resource group '{rg_name}'...") # Show the deployment details @@ -336,6 +329,55 @@ def cleanup_resources(deployment: str | INFRASTRUCTURE, rg_name: str ) -> None: print(f"An error occurred during cleanup: {e}") traceback.print_exc() + +# ------------------------------ +# PUBLIC METHODS +# ------------------------------ + +def cleanup_infra_deployment(deployment: INFRASTRUCTURE, indexes: int | list[int] | None = None) -> None: + """ + Clean up infrastructure deployments by deployment enum and index/indexes. + Obtains the infra resource group name for each index and calls the private cleanup method. + + Args: + deployment (INFRASTRUCTURE): The infrastructure deployment enum value. + indexes (int | list[int] | None): A single index, a list of indexes, or None for no index. + """ + validate_infrastructure(deployment) + + if indexes is None: + indexes_list = [None] + elif isinstance(indexes, (list, tuple)): + indexes_list = list(indexes) + else: + indexes_list = [indexes] + + for idx in indexes_list: + print_info(f"Cleaning up resources for {deployment} - {idx}", True) + rg_name = get_infra_rg_name(deployment, idx) + _cleanup_resources(deployment.value, rg_name) + +def cleanup_deployment(deployment: str, indexes: int | list[int] | None = None) -> None: + """ + Clean up sample deployments by deployment name and index/indexes. + Obtains the resource group name for each index and calls the private cleanup method. + + Args: + deployment (str): The deployment name (string). + indexes (int | list[int] | None): A single index, a list of indexes, or None for no index. + """ + if not isinstance(deployment, str): + raise ValueError("deployment must be a string") + if indexes is None: + indexes_list = [None] + elif isinstance(indexes, (list, tuple)): + indexes_list = list(indexes) + else: + indexes_list = [indexes] + for idx in indexes_list: + rg_name = get_rg_name(deployment, idx) + _cleanup_resources(deployment, rg_name) + def extract_json(text: str) -> any: """ Extract the first valid JSON object or array from a string and return it as a Python object. diff --git a/tests/python/test_apimrequests.py b/tests/python/test_apimrequests.py index 344a943..4ce57fd 100644 --- a/tests/python/test_apimrequests.py +++ b/tests/python/test_apimrequests.py @@ -23,7 +23,6 @@ def test_init_sets_headers(): assert apim.url == default_url assert apim.apimSubscriptionKey == default_key assert apim.headers[SUBSCRIPTION_KEY_PARAMETER_NAME] == default_key - assert apim.headers["Accept"] == "application/json" @pytest.mark.unit diff --git a/tests/python/test_apimtypes.py b/tests/python/test_apimtypes.py index f86a205..33c4a49 100644 --- a/tests/python/test_apimtypes.py +++ b/tests/python/test_apimtypes.py @@ -4,6 +4,7 @@ import pytest from shared.python import apimtypes + # ------------------------------ # CONSTANTS # ------------------------------ @@ -14,11 +15,11 @@ EXAMPLE_DESCRIPTION = "A test API." EXAMPLE_POLICY_XML = "" + # ------------------------------ -# PUBLIC METHODS +# TEST METHODS # ------------------------------ - @pytest.mark.unit def test_api_creation(): """Test creation of API object and its attributes.""" @@ -144,8 +145,6 @@ def test_api_missing_fields(): ) - - # ------------------------------ # ENUMS # ------------------------------ @@ -187,6 +186,7 @@ def test_infrastructure_enum(): with pytest.raises(ValueError): apimtypes.INFRASTRUCTURE("bad") + # ------------------------------ # OPERATION CLASSES # ------------------------------ diff --git a/tests/python/test_utils.py b/tests/python/test_utils.py index b313e57..77f74ab 100644 --- a/tests/python/test_utils.py +++ b/tests/python/test_utils.py @@ -2,6 +2,8 @@ import builtins import pytest from unittest.mock import patch, MagicMock, mock_open +from shared.python import utils +from apimtypes import INFRASTRUCTURE # ------------------------------ # is_string_json @@ -20,15 +22,13 @@ ] ) def test_is_string_json(input_str, expected): - from shared.python.utils import is_string_json - assert is_string_json(input_str) is expected + assert utils.is_string_json(input_str) is expected # ------------------------------ # get_account_info # ------------------------------ def test_get_account_info_success(monkeypatch): - from shared.python import utils mock_json = { 'user': {'name': 'testuser'}, 'tenantId': 'tenant', @@ -40,7 +40,6 @@ def test_get_account_info_success(monkeypatch): assert result == ('testuser', 'tenant', 'subid') def test_get_account_info_failure(monkeypatch): - from shared.python import utils mock_output = MagicMock(success=False, json_data=None) monkeypatch.setattr(utils, 'run', lambda *a, **kw: mock_output) with pytest.raises(Exception): @@ -51,12 +50,10 @@ def test_get_account_info_failure(monkeypatch): # ------------------------------ def test_get_deployment_name(monkeypatch): - from shared.python import utils monkeypatch.setattr(os, 'getcwd', lambda: '/foo/bar/baz') assert utils.get_deployment_name() == 'baz' def test_get_deployment_name_error(monkeypatch): - from shared.python import utils monkeypatch.setattr(os, 'getcwd', lambda: '') with pytest.raises(RuntimeError): utils.get_deployment_name() @@ -66,8 +63,6 @@ def test_get_deployment_name_error(monkeypatch): # ------------------------------ def test_get_frontdoor_url_success(monkeypatch): - from shared.python import utils - from apimtypes import INFRASTRUCTURE mock_profile = [{"name": "afd1"}] mock_endpoints = [{"hostName": "foo.azurefd.net"}] def run_side_effect(cmd, *a, **kw): @@ -81,8 +76,6 @@ def run_side_effect(cmd, *a, **kw): assert url == 'https://foo.azurefd.net' def test_get_frontdoor_url_none(monkeypatch): - from shared.python import utils - from apimtypes import INFRASTRUCTURE monkeypatch.setattr(utils, 'run', lambda *a, **kw: MagicMock(success=False, json_data=None)) url = utils.get_frontdoor_url(INFRASTRUCTURE.AFD_APIM_PE, 'rg') assert url is None @@ -92,7 +85,6 @@ def test_get_frontdoor_url_none(monkeypatch): # ------------------------------ def test_get_infra_rg_name(monkeypatch): - from shared.python import utils class DummyInfra: value = 'foo' monkeypatch.setattr(utils, 'validate_infrastructure', lambda x: x) @@ -100,7 +92,6 @@ class DummyInfra: assert utils.get_infra_rg_name(DummyInfra, 2) == 'apim-infra-foo-2' def test_get_rg_name(): - from shared.python import utils assert utils.get_rg_name('foo') == 'apim-sample-foo' assert utils.get_rg_name('foo', 3) == 'apim-sample-foo-3' @@ -109,14 +100,12 @@ def test_get_rg_name(): # ------------------------------ def test_run_success(monkeypatch): - from shared.python import utils monkeypatch.setattr('subprocess.check_output', lambda *a, **kw: b'{"a": 1}') out = utils.run('echo', print_command_to_run=False) assert out.success is True assert out.json_data == {"a": 1} def test_run_failure(monkeypatch): - from shared.python import utils class DummyErr(Exception): output = b'fail' def fail(*a, **kw): @@ -131,14 +120,12 @@ def fail(*a, **kw): # ------------------------------ def test_does_resource_group_exist(monkeypatch): - from shared.python import utils monkeypatch.setattr(utils, 'run', lambda *a, **kw: MagicMock(success=True)) assert utils.does_resource_group_exist('foo') is True monkeypatch.setattr(utils, 'run', lambda *a, **kw: MagicMock(success=False)) assert utils.does_resource_group_exist('foo') is False def test_create_resource_group(monkeypatch): - from shared.python import utils called = {} monkeypatch.setattr(utils, 'does_resource_group_exist', lambda rg: False) monkeypatch.setattr(utils, 'print_info', lambda *a, **kw: called.setdefault('info', True)) @@ -151,7 +138,6 @@ def test_create_resource_group(monkeypatch): # ------------------------------ def test_policy_xml_replacement(monkeypatch): - from shared.python import utils m = mock_open(read_data='foo') monkeypatch.setattr(builtins, 'open', m) assert utils.policy_xml_replacement('dummy.xml') == 'foo' @@ -161,8 +147,6 @@ def test_policy_xml_replacement(monkeypatch): # ------------------------------ def test_cleanup_resources_smoke(monkeypatch): - from shared.python import utils - from apimtypes import INFRASTRUCTURE monkeypatch.setattr(utils, 'run', lambda *a, **kw: MagicMock(success=True, json_data={})) monkeypatch.setattr(utils, 'print_info', lambda *a, **kw: None) monkeypatch.setattr(utils, 'print_error', lambda *a, **kw: None) @@ -170,7 +154,20 @@ def test_cleanup_resources_smoke(monkeypatch): monkeypatch.setattr(utils, 'print_ok', lambda *a, **kw: None) monkeypatch.setattr(utils, 'print_warning', lambda *a, **kw: None) monkeypatch.setattr(utils, 'print_val', lambda *a, **kw: None) - utils.cleanup_resources(INFRASTRUCTURE.SIMPLE_APIM, 'rg') + # Direct private method call for legacy test (should still work) + utils._cleanup_resources(INFRASTRUCTURE.SIMPLE_APIM.value, 'rg') + +def test_cleanup_infra_deployment_single(monkeypatch): + monkeypatch.setattr(utils, '_cleanup_resources', lambda deployment_name, rg_name: None) + utils.cleanup_infra_deployment(INFRASTRUCTURE.SIMPLE_APIM, None) + utils.cleanup_infra_deployment(INFRASTRUCTURE.SIMPLE_APIM, 1) + utils.cleanup_infra_deployment(INFRASTRUCTURE.SIMPLE_APIM, [1, 2]) + +def test_cleanup_deployment_single(monkeypatch): + monkeypatch.setattr(utils, '_cleanup_resources', lambda deployment_name, rg_name: None) + utils.cleanup_deployment('foo', None) + utils.cleanup_deployment('foo', 1) + utils.cleanup_deployment('foo', [1, 2]) # ------------------------------ # EXTRACT_JSON EDGE CASES @@ -211,23 +208,19 @@ def test_cleanup_resources_smoke(monkeypatch): ) def test_extract_json_edge_cases(input_val, expected): """Test extract_json with a wide range of edge cases and malformed input.""" - from shared.python.utils import extract_json - result = extract_json(input_val) + result = utils.extract_json(input_val) assert result == expected def test_extract_json_large_object(): """Test extract_json with a large JSON object.""" - from shared.python.utils import extract_json large_obj = {"a": list(range(1000)), "b": {"c": "x" * 1000}} import json s = json.dumps(large_obj) - assert extract_json(s) == large_obj + assert utils.extract_json(s) == large_obj def test_extract_json_multiple_json_types(): """Test extract_json returns the first valid JSON (object or array) in the string.""" - from shared.python.utils import extract_json s = '[1,2,3]{"a": 1}' - assert extract_json(s) == [1, 2, 3] + assert utils.extract_json(s) == [1, 2, 3] s2 = '{"a": 1}[1,2,3]' - assert extract_json(s2) == {"a": 1} - + assert utils.extract_json(s2) == {"a": 1} From f0d281e35a53a5a502e7f04283f876b74faf86fd Mon Sep 17 00:00:00 2001 From: Simon Kurtz Date: Fri, 23 May 2025 11:07:08 -0400 Subject: [PATCH 24/24] Add infra deployment cleanup --- infrastructure/afd-apim/clean-up.ipynb | 4 ++-- infrastructure/apim-aca/clean-up.ipynb | 4 ++-- infrastructure/simple-apim/clean-up.ipynb | 6 +++--- shared/python/utils.py | 2 +- tests/python/test_utils.py | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/infrastructure/afd-apim/clean-up.ipynb b/infrastructure/afd-apim/clean-up.ipynb index 76b7959..e001442 100644 --- a/infrastructure/afd-apim/clean-up.ipynb +++ b/infrastructure/afd-apim/clean-up.ipynb @@ -19,9 +19,9 @@ "from apimtypes import INFRASTRUCTURE\n", "\n", "deployment = INFRASTRUCTURE.AFD_APIM_PE\n", - "index = 1\n", + "indexes = [1]\n", "\n", - "utils.cleanup_resources(deployment, utils.get_infra_rg_name(deployment, index))" + "utils.cleanup_infra_deployments(deployment, indexes)" ] } ], diff --git a/infrastructure/apim-aca/clean-up.ipynb b/infrastructure/apim-aca/clean-up.ipynb index a385e0f..937a3ea 100644 --- a/infrastructure/apim-aca/clean-up.ipynb +++ b/infrastructure/apim-aca/clean-up.ipynb @@ -19,9 +19,9 @@ "from apimtypes import INFRASTRUCTURE\n", "\n", "deployment = INFRASTRUCTURE.APIM_ACA\n", - "index = 1\n", + "indexes = [1]\n", "\n", - "utils.cleanup_resources(deployment, utils.get_infra_rg_name(deployment, index))" + "utils.cleanup_infra_deployments(deployment, indexes)" ] } ], diff --git a/infrastructure/simple-apim/clean-up.ipynb b/infrastructure/simple-apim/clean-up.ipynb index 8e106b4..fd26d85 100644 --- a/infrastructure/simple-apim/clean-up.ipynb +++ b/infrastructure/simple-apim/clean-up.ipynb @@ -19,9 +19,9 @@ "from apimtypes import INFRASTRUCTURE\n", "\n", "deployment = INFRASTRUCTURE.SIMPLE_APIM\n", - "index = 1\n", + "indexes = [1]\n", "\n", - "utils.cleanup_resources(deployment, utils.get_infra_rg_name(deployment, index))" + "utils.cleanup_infra_deployments(deployment, indexes)" ] } ], @@ -41,7 +41,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.9" + "version": "3.12.10" } }, "nbformat": 4, diff --git a/shared/python/utils.py b/shared/python/utils.py index d3bb89b..5cb2db6 100644 --- a/shared/python/utils.py +++ b/shared/python/utils.py @@ -334,7 +334,7 @@ def _cleanup_resources(deployment_name: str, rg_name: str) -> None: # PUBLIC METHODS # ------------------------------ -def cleanup_infra_deployment(deployment: INFRASTRUCTURE, indexes: int | list[int] | None = None) -> None: +def cleanup_infra_deployments(deployment: INFRASTRUCTURE, indexes: int | list[int] | None = None) -> None: """ Clean up infrastructure deployments by deployment enum and index/indexes. Obtains the infra resource group name for each index and calls the private cleanup method. diff --git a/tests/python/test_utils.py b/tests/python/test_utils.py index 77f74ab..3e49eab 100644 --- a/tests/python/test_utils.py +++ b/tests/python/test_utils.py @@ -159,9 +159,9 @@ def test_cleanup_resources_smoke(monkeypatch): def test_cleanup_infra_deployment_single(monkeypatch): monkeypatch.setattr(utils, '_cleanup_resources', lambda deployment_name, rg_name: None) - utils.cleanup_infra_deployment(INFRASTRUCTURE.SIMPLE_APIM, None) - utils.cleanup_infra_deployment(INFRASTRUCTURE.SIMPLE_APIM, 1) - utils.cleanup_infra_deployment(INFRASTRUCTURE.SIMPLE_APIM, [1, 2]) + utils.cleanup_infra_deployments(INFRASTRUCTURE.SIMPLE_APIM, None) + utils.cleanup_infra_deployments(INFRASTRUCTURE.SIMPLE_APIM, 1) + utils.cleanup_infra_deployments(INFRASTRUCTURE.SIMPLE_APIM, [1, 2]) def test_cleanup_deployment_single(monkeypatch): monkeypatch.setattr(utils, '_cleanup_resources', lambda deployment_name, rg_name: None)