Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions .devcontainer/python312/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,7 @@
"files.associations": {
"*.bicep": "bicep"
}
},
"openFiles": [
".devcontainer/CODESPACES-QUICKSTART.md"
]
}
},
"containerEnv": {
"PATH": "/workspaces/Apim-Samples/.venv/bin:${PATH}",
Expand All @@ -69,7 +66,7 @@
"postStartCommand": [
"bash",
"-c",
"echo 'APIM Samples Codespace Starting - Keep this terminal open to see progress!' && bash .devcontainer/post-start-setup.sh"
"echo 'APIM Samples Codespace Starting - Keep this terminal open to see progress!' && bash .devcontainer/post-start-setup.sh && sleep 2 && code --command markdown.showPreviewToSide .devcontainer/CODESPACES-QUICKSTART.md"
],
"forwardPorts": [
8000,
Expand Down
7 changes: 2 additions & 5 deletions .devcontainer/python313/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,7 @@
"files.associations": {
"*.bicep": "bicep"
}
},
"openFiles": [
".devcontainer/CODESPACES-QUICKSTART.md"
]
}
},
"containerEnv": {
"PATH": "/workspaces/Apim-Samples/.venv/bin:${PATH}",
Expand All @@ -69,7 +66,7 @@
"postStartCommand": [
"bash",
"-c",
"echo 'APIM Samples Codespace Starting - Keep this terminal open to see progress!' && bash .devcontainer/post-start-setup.sh"
"echo 'APIM Samples Codespace Starting - Keep this terminal open to see progress!' && bash .devcontainer/post-start-setup.sh && sleep 2 && code --command markdown.showPreviewToSide .devcontainer/CODESPACES-QUICKSTART.md"
],
"forwardPorts": [
8000,
Expand Down
7 changes: 2 additions & 5 deletions .devcontainer/python314/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,7 @@
"files.associations": {
"*.bicep": "bicep"
}
},
"openFiles": [
".devcontainer/CODESPACES-QUICKSTART.md"
]
}
},
"containerEnv": {
"PATH": "/workspaces/Apim-Samples/.venv/bin:${PATH}",
Expand All @@ -69,7 +66,7 @@
"postStartCommand": [
"bash",
"-c",
"echo 'APIM Samples Codespace Starting - Keep this terminal open to see progress!' && bash .devcontainer/post-start-setup.sh"
"echo 'APIM Samples Codespace Starting - Keep this terminal open to see progress!' && bash .devcontainer/post-start-setup.sh && sleep 2 && code --command markdown.showPreviewToSide .devcontainer/CODESPACES-QUICKSTART.md"
],
"forwardPorts": [
8000,
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/11057/badge)][openssf]
[![Python Tests][badge-python-tests]][workflow-python-tests]

[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/Azure-Samples/Apim-Samples?devcontainer_path=.devcontainer%2Fpython314%2Fdevcontainer.json)

**This repository provides resources to quickly deploy high-fidelity Azure API Management (APIM) infrastructures and experiment with common APIM.**

Historically, there were two general paths to experimenting with APIM. Standing up an entire landing zone with the [APIM Landing Zone Accelerator][apim-lza] can feel overwhelming and _more than needed_. Similarly, using [APIM policy snippets][apim-snippets] is only helpful when an APIM instance and infrastructure already exists.
Expand Down Expand Up @@ -89,6 +91,8 @@ This menu-driven interface provides quick access to:
- **Setup**: Complete environment setup, verify local setup, and show Azure account info
- **Tests**: Run pylint, pytest, and full Python checks

<img src="./assets/dev-cli-lint-test-results.png" alt="APIM Samples Developer CLI showing final linting, test, and code coverage results" title="APIM Samples Developer CLI Final Results" />

</details>


Expand All @@ -98,6 +102,9 @@ APIM Samples supports two setup options:

### Option 1: GitHub Codespaces / Dev Container (Recommended for First-Time Users)
<details>
<br/>

[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/Azure-Samples/Apim-Samples?devcontainer_path=.devcontainer%2Fpython314%2Fdevcontainer.json)

**The fastest way to get started is using our pre-configured development environment.** Everything is pre-installed and configured—just sign in to Azure and you're ready to go.

Expand Down
Binary file added assets/dev-cli-lint-test-results.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 9 additions & 9 deletions shared/python/charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,16 @@ def _plot_barchart(self, api_results: list[dict]) -> None:

# Exclude high outliers for average calculation
valid_200 = df[(df['Status Code'] == 200)].copy()

# Exclude high outliers (e.g., above 95th percentile)
if not valid_200.empty:
# Exclude high outliers (e.g., above 95th percentile)
if not valid_200.empty:
upper = valid_200['Response Time (ms)'].quantile(0.95)
filtered = valid_200[valid_200['Response Time (ms)'] <= upper]
if not filtered.empty:
avg = filtered['Response Time (ms)'].mean()
avg_label = f'Mean APIM response time: {avg:.1f} ms'
plt.axhline(y = avg, color = 'b', linestyle = '--')
plt.text(len(df) - 1, avg, avg_label, color = 'b', va = 'bottom', ha = 'right', fontsize = 10)
upper = valid_200['Response Time (ms)'].quantile(0.95)
filtered = valid_200[valid_200['Response Time (ms)'] <= upper]
if not filtered.empty:
avg = filtered['Response Time (ms)'].mean()
avg_label = f'Mean APIM response time: {avg:.1f} ms'
plt.axhline(y = avg, color = 'b', linestyle = '--')
plt.text(len(df) - 1, avg, avg_label, color = 'b', va = 'bottom', ha = 'right', fontsize = 10)

# Add figtext under the chart
plt.figtext(0.13, -0.1, wrap = True, ha = 'left', fontsize = 11, s = self.fig_text)
Expand Down
8 changes: 4 additions & 4 deletions shared/python/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,10 @@ def create_infrastructure(self, bypass_infrastructure_check: bool = False, allow
self.index = new_index
# Recursively call create_infrastructure with the new index
return self.create_infrastructure(bypass_infrastructure_check, allow_update)
elif not should_proceed:
elif not should_proceed: # pragma: no cover
print_error('Infrastructure deployment cancelled by user.')
raise SystemExit("User cancelled deployment")
except (KeyboardInterrupt, EOFError) as exc:
except (KeyboardInterrupt, EOFError) as exc: # pragma: no cover
raise SystemExit("User cancelled deployment") from exc

# Check infrastructure existence for the normal flow
Expand Down Expand Up @@ -766,7 +766,7 @@ def _prompt_for_infrastructure_update(rg_name: str) -> tuple[bool, int | None]:
print_plain('❌ Please enter a valid integer for the index.')
elif choice == '3':
return False, None
elif not choice:
elif not choice: # pragma: no cover
# Empty input (ESC pressed in Jupyter) - cancel
raise EOFError()
else:
Expand Down Expand Up @@ -812,7 +812,7 @@ def does_infrastructure_exist(infrastructure: INFRASTRUCTURE, index: int, allow_
return False # Allow deployment to proceed
elif choice in ('2', '3'):
return True # Block deployment
elif not choice:
elif not choice: # pragma: no cover
# Empty input (ESC pressed in Jupyter) - cancel
raise EOFError()
else:
Expand Down
57 changes: 57 additions & 0 deletions tests/python/test_apimtypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import importlib
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
Expand Down Expand Up @@ -1166,3 +1167,59 @@ def test_api_subscription_required(self):

assert api.subscriptionRequired is False
assert api.to_dict()['subscriptionRequired'] is False


class TestProjectRootDetection:
"""Test project root detection logic."""

def test_get_project_root_from_env_var(self, monkeypatch):
"""Test get_project_root when PROJECT_ROOT env var is set."""
expected_root = Path('/custom/project/root')
monkeypatch.setenv('PROJECT_ROOT', str(expected_root))

result = get_project_root()
assert result == expected_root

def test_get_project_root_without_env_var(self, monkeypatch):
"""Test get_project_root when PROJECT_ROOT env var is not set."""
monkeypatch.delenv('PROJECT_ROOT', raising=False)

# Should return a Path object (doesn't have to exist in tests)
result = get_project_root()
assert isinstance(result, Path)


class TestOutputGetEdgeCases:
"""Additional edge case tests for Output.get() method."""

def test_output_get_with_missing_label_returns_none(self):
"""Test Output.get() without label returns None on error."""
output = apimtypes.Output(success=True, text='{}')

# Should return None when key is missing and no label is provided
result = output.get('nonexistent_key')
assert result is None

def test_output_get_json_with_syntax_error(self):
"""Test Output.getJson() returns original value when parsing fails."""
json_text = json.dumps({
'output1': {
'value': 'invalid syntax {bracket'
}
})
output = apimtypes.Output(success=True, text=json_text)

# Should return the original value when parsing fails
result = output.getJson('output1')
assert result == 'invalid syntax {bracket'

def test_output_get_json_with_non_dict_properties(self):
"""Test Output.getJson() when properties is not a dict."""
json_text = json.dumps({
'properties': 'not a dict'
})
output = apimtypes.Output(success=True, text=json_text)

# Should handle gracefully and return None
result = output.getJson('key')
assert result is None
16 changes: 16 additions & 0 deletions tests/python/test_local_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,22 @@ def test_merge_string_list_no_duplicates():
assert result == ["a", "b", "c"]


def test_merge_string_list_empty_existing():
"""Test _merge_string_list with empty existing list."""
existing = []
required = ["a", "b", "c"]
result = sps._merge_string_list(existing, required)
assert result == ["a", "b", "c"]


def test_merge_string_list_with_none_and_empty_string():
"""Test _merge_string_list handles None existing and normalizes it to empty list."""
existing = None
required = ["a", "b"]
result = sps._merge_string_list(existing, required)
assert result == ["a", "b"]


def test_get_project_root_finds_indicators(tmp_path: Path):
"""Test get_project_root locates project root by indicators."""
# Create indicators at root
Expand Down
29 changes: 29 additions & 0 deletions tests/python/test_show_soft_deleted_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,12 @@ def test_purge_key_vaults_all_protected():
assert skipped_count == 2


def test_purge_key_vaults_empty_list():
"""Test purge_key_vaults with empty list returns early."""
result = sdr.purge_key_vaults([])
assert result == (0, 0)


# ------------------------------
# CONFIRMATION TESTS
# ------------------------------
Expand Down Expand Up @@ -701,6 +707,29 @@ def mock_az_run(cmd, *a, **k):
assert not result


def test_main_purge_all_resources_successfully(monkeypatch):
"""Test main when all purgeable resources are successfully purged."""
services = [{'name': 'apim-1', 'location': 'eastus', 'deletionDate': '2025-12-13T10:00:00Z', 'scheduledPurgeDate': '2026-01-13T10:00:00Z', 'serviceId': 'id-1'}]
vaults = [{'name': 'vault-1', 'properties': {'location': 'eastus', 'purgeProtectionEnabled': False}}]

def mock_purge_apim(s):
return len(s) # Successfully purge all

def mock_purge_kv(v):
return (1, 0) # Successfully purge all, 0 skipped

monkeypatch.setattr('show_soft_deleted_resources.get_deleted_apim_services', lambda: services)
monkeypatch.setattr('show_soft_deleted_resources.get_deleted_key_vaults', lambda: vaults)
monkeypatch.setattr('show_soft_deleted_resources.purge_apim_services', mock_purge_apim)
monkeypatch.setattr('show_soft_deleted_resources.purge_key_vaults', mock_purge_kv)
monkeypatch.setattr('show_soft_deleted_resources.az.run', lambda cmd, *a, **k: MagicMock(success=True, json_data={'name': 'test-sub', 'id': 'sub-id'}))
mock_module_functions(monkeypatch, builtins, ['print'])
monkeypatch.setattr('sys.argv', ['script.py', '--purge', '--yes'])

result = sdr.main()
assert not result


def test_show_deleted_key_vaults_empty():
"""Test show_deleted_key_vaults with no data."""
with patch('builtins.print') as mock_print:
Expand Down
Loading