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
1 change: 1 addition & 0 deletions .vscode/cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@
"delenv",
"dependened",
"deque",
"deser",
"deserialization",
"deserializers",
"disablecov",
Expand Down
224 changes: 180 additions & 44 deletions scripts/breaking_changes_checker/detect_breaking_changes.py

Large diffs are not rendered by default.

180 changes: 156 additions & 24 deletions scripts/breaking_changes_checker/tests/test_code_report_changelog.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ def _code_report_args(use_apistub: bool = False):
return ["--code-report"] + (["--use-apistub"] if use_apistub else [])


def _download_and_extract_sdist(executable: str, package_name: str, version: str, dest_dir: str) -> str:
def _download_and_extract_sdist(
executable: str, package_name: str, version: str, dest_dir: str
) -> str:
"""Download the sdist for ``package_name==version`` and extract it.

Returns the path to the extracted source tree (the directory that contains
Expand Down Expand Up @@ -98,10 +100,14 @@ def _prepare_fake_repo(tmpdir: str, package_name: str, source_dir: str) -> str:
return pkg_dir


def _assert_code_report_matches_expected(actual_report_path: str, expected_report_file: str):
def _assert_code_report_matches_expected(
actual_report_path: str, expected_report_file: str
):
with open(actual_report_path, encoding="utf-8") as f:
actual_report = json.load(f)
assert isinstance(actual_report, dict) and len(actual_report) > 0, "Code report should not be empty"
assert (
isinstance(actual_report, dict) and len(actual_report) > 0
), "Code report should not be empty"

expected_path = os.path.join(DATA_DIR, expected_report_file)
if os.environ.get("UPDATE_EXPECTED"):
Expand Down Expand Up @@ -147,9 +153,18 @@ def _generate_and_compare_code_report(
from packaging_tools.venvtools import create_venv_with_package

packages = [f"{package_name}=={package_version}"]
with create_venv_with_package(packages) as venv, tempfile.TemporaryDirectory() as tmpdir:
with create_venv_with_package(
packages
) as venv, tempfile.TemporaryDirectory() as tmpdir:
subprocess.check_call(
[venv.env_exe, "-m", "pip", "install", "-r", os.path.join(CHECKER_DIR, "dev_requirements.txt")],
[
venv.env_exe,
"-m",
"pip",
"install",
"-r",
os.path.join(CHECKER_DIR, "dev_requirements.txt"),
],
cwd=CHECKER_DIR,
)
if use_apistub:
Expand All @@ -170,7 +185,9 @@ def _generate_and_compare_code_report(
# under the temp dir. The import-based report only needs the installed
# package, so the package name works as the target directly.
if use_apistub:
source_dir = _download_and_extract_sdist(venv.env_exe, package_name, package_version, tmpdir)
source_dir = _download_and_extract_sdist(
venv.env_exe, package_name, package_version, tmpdir
)
target_package = _prepare_fake_repo(tmpdir, package_name, source_dir)
else:
target_package = package_name
Expand All @@ -191,13 +208,17 @@ def _generate_and_compare_code_report(
cwd=tmpdir,
)
elapsed = time.perf_counter() - start
assert result.returncode == 0, f"Code report generation for {package_version} failed:\n{result.stderr}"
assert (
result.returncode == 0
), f"Code report generation for {package_version} failed:\n{result.stderr}"
if max_code_report_seconds is not None:
assert elapsed < max_code_report_seconds, (
f"Code report generation for {package_version} took {elapsed:.2f}s, "
f"expected less than {max_code_report_seconds}s"
)
_assert_code_report_matches_expected(os.path.join(tmpdir, "code_report.json"), expected_report_file)
_assert_code_report_matches_expected(
os.path.join(tmpdir, "code_report.json"), expected_report_file
)


def _compare_code_reports_to_changelog(
Expand Down Expand Up @@ -238,7 +259,9 @@ def _compare_code_reports_to_changelog(
), f"Changelog output missing end marker:\n{changelog_output}"

# Extract changelog content between markers and compare with expected
start = changelog_output.index("===== changelog start =====") + len("===== changelog start =====\n")
start = changelog_output.index("===== changelog start =====") + len(
"===== changelog start =====\n"
)
end = changelog_output.index("\n===== changelog end =====")
actual_changelog = changelog_output[start:end].strip()

Expand All @@ -260,9 +283,9 @@ def _compare_code_reports_to_changelog(
expected_changelog = f.read().strip()

if order_insensitive:
matches = sorted(l.strip() for l in actual_changelog.splitlines() if l.strip()) == sorted(
l.strip() for l in expected_changelog.splitlines() if l.strip()
)
matches = sorted(
l.strip() for l in actual_changelog.splitlines() if l.strip()
) == sorted(l.strip() for l in expected_changelog.splitlines() if l.strip())
else:
matches = actual_changelog == expected_changelog

Expand All @@ -278,7 +301,9 @@ def _compare_code_reports_to_changelog(
)


@pytest.mark.slow(reason="external package code report generation creates venvs and may take several minutes")
@pytest.mark.slow(
reason="external package code report generation creates venvs and may take several minutes"
)
def test_generate_old_code_report_for_azure_mgmt_peering():
"""Generate azure-mgmt-peering 2.0.0b1 code report."""
_generate_and_compare_code_report(
Expand All @@ -289,7 +314,9 @@ def test_generate_old_code_report_for_azure_mgmt_peering():
)


@pytest.mark.slow(reason="external package code report generation creates venvs and may take several minutes")
@pytest.mark.slow(
reason="external package code report generation creates venvs and may take several minutes"
)
def test_generate_new_code_report_for_azure_mgmt_peering():
"""Generate azure-mgmt-peering 2.0.0b2 code report."""
_generate_and_compare_code_report(
Expand All @@ -310,7 +337,9 @@ def test_compare_code_reports_for_azure_mgmt_peering():
)


@pytest.mark.slow(reason="azure-mgmt-apimanagement code report generation may take up to 10 minutes")
@pytest.mark.slow(
reason="azure-mgmt-apimanagement code report generation may take up to 10 minutes"
)
def test_generate_old_code_report_for_azure_mgmt_apimanagement():
"""Generate azure-mgmt-apimanagement 5.0.0 code report. May take up to 10 minutes."""
_generate_and_compare_code_report(
Expand All @@ -321,7 +350,9 @@ def test_generate_old_code_report_for_azure_mgmt_apimanagement():
)


@pytest.mark.slow(reason="azure-mgmt-apimanagement code report generation may take up to 10 minutes")
@pytest.mark.slow(
reason="azure-mgmt-apimanagement code report generation may take up to 10 minutes"
)
def test_generate_new_code_report_for_azure_mgmt_apimanagement():
"""Generate azure-mgmt-apimanagement 6.0.0b1 code report. May take up to 10 minutes."""
_generate_and_compare_code_report(
Expand All @@ -342,7 +373,9 @@ def test_compare_code_reports_for_azure_mgmt_apimanagement():
)


@pytest.mark.slow(reason="external package apistub code report generation creates venvs and may take several minutes")
@pytest.mark.slow(
reason="external package apistub code report generation creates venvs and may take several minutes"
)
def test_generate_old_code_report_for_azure_mgmt_peering_apistub():
"""Generate azure-mgmt-peering 2.0.0b1 code report using --use-apistub."""
_generate_and_compare_code_report(
Expand All @@ -354,7 +387,9 @@ def test_generate_old_code_report_for_azure_mgmt_peering_apistub():
)


@pytest.mark.slow(reason="external package apistub code report generation creates venvs and may take several minutes")
@pytest.mark.slow(
reason="external package apistub code report generation creates venvs and may take several minutes"
)
def test_generate_new_code_report_for_azure_mgmt_peering_apistub():
"""Generate azure-mgmt-peering 2.0.0b2 code report using --use-apistub."""
_generate_and_compare_code_report(
Expand All @@ -377,7 +412,9 @@ def test_compare_code_reports_for_azure_mgmt_peering_apistub():
)


@pytest.mark.slow(reason="azure-mgmt-apimanagement apistub code report generation may take several minutes")
@pytest.mark.slow(
reason="azure-mgmt-apimanagement apistub code report generation may take several minutes"
)
def test_generate_old_code_report_for_azure_mgmt_apimanagement_apistub():
"""Generate azure-mgmt-apimanagement 5.0.0 code report using --use-apistub."""
_generate_and_compare_code_report(
Expand All @@ -390,7 +427,9 @@ def test_generate_old_code_report_for_azure_mgmt_apimanagement_apistub():
)


@pytest.mark.slow(reason="azure-mgmt-apimanagement apistub code report generation may take several minutes")
@pytest.mark.slow(
reason="azure-mgmt-apimanagement apistub code report generation may take several minutes"
)
def test_generate_new_code_report_for_azure_mgmt_apimanagement_apistub():
"""Generate azure-mgmt-apimanagement 6.0.0b1 code report using --use-apistub."""
_generate_and_compare_code_report(
Expand Down Expand Up @@ -418,7 +457,9 @@ def test_uninstall_package_uses_active_python_environment():
from breaking_changes_checker import detect_breaking_changes

with mock.patch.object(detect_breaking_changes.subprocess, "run") as run:
detect_breaking_changes._uninstall_package("azure-mgmt-network", "/tmp/azure-mgmt-network")
detect_breaking_changes._uninstall_package(
"azure-mgmt-network", "/tmp/azure-mgmt-network"
)

run.assert_called_once_with(
[sys.executable, "-m", "pip", "uninstall", "-y", "azure-mgmt-network"],
Expand Down Expand Up @@ -447,7 +488,9 @@ def test_use_apistub_changelog_resolves_stable_from_pypi_and_current_from_local(
checker.breaking_changes = []
events = mock.Mock()

with mock.patch("pypi_tools.pypi.PyPIClient", return_value=pypi_client) as pypi_client_cls, mock.patch.object(
with mock.patch(
"pypi_tools.pypi.PyPIClient", return_value=pypi_client
) as pypi_client_cls, mock.patch.object(
detect_breaking_changes, "_uninstall_package"
) as uninstall_package, mock.patch.object(
detect_breaking_changes, "build_report_from_apistub", return_value={}
Expand All @@ -470,7 +513,9 @@ def test_use_apistub_changelog_resolves_stable_from_pypi_and_current_from_local(
use_apistub=True,
)

assert build_report.call_count == 2, "Expected separate apistub reports for current and stable"
assert (
build_report.call_count == 2
), "Expected separate apistub reports for current and stable"
assert events.mock_calls == [
mock.call.uninstall("azure-mgmt-network", "/tmp/azure-mgmt-network"),
mock.call.build_report(
Expand All @@ -496,7 +541,9 @@ def test_use_apistub_changelog_resolves_stable_from_pypi_and_current_from_local(
# Reverting force_pypi=True would restore the original CI failure, so pin it.
pypi_client_cls.assert_called_once_with(force_pypi=True)

calls_by_label = {call.kwargs["label"]: call for call in build_report.call_args_list}
calls_by_label = {
call.kwargs["label"]: call for call in build_report.call_args_list
}
assert set(calls_by_label) == {"current", "stable"}

# "current" comes from the local source.
Expand All @@ -514,3 +561,88 @@ def test_use_apistub_changelog_resolves_stable_from_pypi_and_current_from_local(
compare.assert_called_once()
stable_arg, current_arg = compare.call_args.args[0], compare.call_args.args[1]
assert stable_arg is not None and current_arg is not None


def test_resolve_pypi_version_falls_back_to_configured_index_on_transport_error():
from urllib3.exceptions import MaxRetryError

from breaking_changes_checker import detect_breaking_changes

public_client = mock.MagicMock()
public_client.get_relevant_versions.side_effect = MaxRetryError(
None, "/pypi/azure-ai-contentsafety/json", OSError("Operation not permitted")
)
configured_index_client = mock.MagicMock()
configured_index_client.get_relevant_versions.return_value = ["1.1.0b1", "1.0.0"]

with mock.patch(
"pypi_tools.pypi.PyPIClient",
side_effect=[public_client, configured_index_client],
) as pypi_client_cls:
version = detect_breaking_changes._resolve_pypi_version(
"azure-ai-contentsafety", False
)

assert version == "1.0.0"
assert pypi_client_cls.call_args_list == [mock.call(force_pypi=True), mock.call()]
public_client.get_relevant_versions.assert_called_once_with(
"azure-ai-contentsafety"
)
configured_index_client.get_relevant_versions.assert_called_once_with(
"azure-ai-contentsafety"
)


def test_resolve_pypi_version_falls_back_to_configured_index_on_malformed_json():
from breaking_changes_checker import detect_breaking_changes

public_client = mock.MagicMock()
public_client.get_relevant_versions.side_effect = json.JSONDecodeError(
"Expecting value", "Service unavailable", 0
)
configured_index_client = mock.MagicMock()
configured_index_client.get_relevant_versions.return_value = ["1.1.0b1", "1.0.0"]

with mock.patch(
"pypi_tools.pypi.PyPIClient",
side_effect=[public_client, configured_index_client],
) as pypi_client_cls:
version = detect_breaking_changes._resolve_pypi_version(
"azure-ai-contentsafety", False
)

assert version == "1.0.0"
assert pypi_client_cls.call_args_list == [mock.call(force_pypi=True), mock.call()]
public_client.get_relevant_versions.assert_called_once_with(
"azure-ai-contentsafety"
)
configured_index_client.get_relevant_versions.assert_called_once_with(
"azure-ai-contentsafety"
)


def test_resolve_pypi_version_preserves_transport_error_when_fallback_has_no_version():
from urllib3.exceptions import MaxRetryError

from breaking_changes_checker import detect_breaking_changes

transport_error = MaxRetryError(
None, "/pypi/azure-mgmt-datatransfer/json", OSError("Operation not permitted")
)
public_client = mock.MagicMock()
public_client.get_relevant_versions.side_effect = transport_error
configured_index_client = mock.MagicMock()
configured_index_client.get_relevant_versions.side_effect = KeyError(
"azure-mgmt-datatransfer"
)

with mock.patch(
"pypi_tools.pypi.PyPIClient",
side_effect=[public_client, configured_index_client],
):
with pytest.raises(MaxRetryError) as raised:
detect_breaking_changes._resolve_pypi_version(
"azure-mgmt-datatransfer", False
)

assert raised.value is transport_error
13 changes: 13 additions & 0 deletions sdk/contentsafety/azure-ai-contentsafety/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Release History

## 1.1.0b1 (2026-09-14)

### Features Added

- Added support for Content Provenance Detection operations.
- Added support for Unified Moderate policy evaluation.
- Added support for prompt shielding and protected-material detection.
- Added regular-expression matching for text blocklist items.

### Breaking Changes

- Python 3.10 or later is now required.

## 1.0.0 (2023-12-15)

### Features Added
Expand Down
2 changes: 1 addition & 1 deletion sdk/contentsafety/azure-ai-contentsafety/MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ include azure/ai/contentsafety/py.typed
recursive-include tests *.py
recursive-include samples *.py *.md
include azure/__init__.py
include azure/ai/__init__.py
include azure/ai/__init__.py
2 changes: 1 addition & 1 deletion sdk/contentsafety/azure-ai-contentsafety/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Various documentation is available to help you get started

### Prerequisites

- Python 3.7 or later is required to use this package.
- Python 3.10 or later is required to use this package.
- You need an [Azure subscription][azure_sub] to use this package.
- An [Azure AI Content Safety][contentsafety_overview] resource, if no existing resource, you could [create a new one](https://aka.ms/acs-create).

Expand Down
11 changes: 11 additions & 0 deletions sdk/contentsafety/azure-ai-contentsafety/_metadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"apiVersion": "2026-09-01-preview",
"apiVersions": {
"ContentSafety": "2026-09-01-preview"
},
"commit": "e7d422ec1d105f2871caca4940636098ef6ab580",
"repository_url": "https://github.com/Azure/azure-rest-api-specs",
"typespec_src": "specification/cognitiveservices/ContentSafety",
"emitterVersion": "0.63.7",
"httpClientPythonVersion": "^0.37.2"
}
Loading
Loading