From faf46fa29d830563657126366ffd751837a03ae7 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang Date: Sat, 12 Sep 2026 18:57:23 -0700 Subject: [PATCH 01/16] Declare Content Understanding SDK dependency --- cu-cli/packages/azure-cli-extension/HISTORY.rst | 6 ++++++ cu-cli/packages/azure-cli-extension/pyproject.toml | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/cu-cli/packages/azure-cli-extension/HISTORY.rst b/cu-cli/packages/azure-cli-extension/HISTORY.rst index 0f8855b..58f33dd 100644 --- a/cu-cli/packages/azure-cli-extension/HISTORY.rst +++ b/cu-cli/packages/azure-cli-extension/HISTORY.rst @@ -1,6 +1,12 @@ Release History =============== +0.1.0b2 (2026-09-14) ++++++++++++++++++++++ + +* Declare the Azure AI Content Understanding SDK as a direct dependency so + Azure CLI validation environments install the SDK imported by the extension. + 0.1.0b1 (2026-09-11) +++++++++++++++++++++ diff --git a/cu-cli/packages/azure-cli-extension/pyproject.toml b/cu-cli/packages/azure-cli-extension/pyproject.toml index 1b0e078..1c86a84 100644 --- a/cu-cli/packages/azure-cli-extension/pyproject.toml +++ b/cu-cli/packages/azure-cli-extension/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "content-understanding" -version = "0.1.0b1" +version = "0.1.0b2" description = "Azure CLI extension for Azure Content Understanding." readme = "README.md" requires-python = ">=3.10" @@ -21,6 +21,7 @@ classifiers = [ ] dependencies = [ "cu-cli-core>=0.1.0b2,<0.2.0", + "azure-ai-contentunderstanding>=1.2.0b3", "azure-mgmt-cognitiveservices>=13.6.0,<14.0.0", ] From 8b42820147e581234b9e03ebf253c0f4b81c7b11 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang Date: Mon, 14 Sep 2026 09:47:42 -0700 Subject: [PATCH 02/16] Validate extension wheel installation --- .github/workflows/ci.yml | 28 ++++++++++++++++ .github/workflows/release.yml | 6 ++++ cu-cli/CONTRIBUTING.md | 11 +++++++ cu-cli/scripts/validate_extension_wheel.sh | 38 ++++++++++++++++++++++ 4 files changed, 83 insertions(+) create mode 100644 cu-cli/scripts/validate_extension_wheel.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa32ef3..843c7c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,34 @@ jobs: - name: Run CI run: bash cu-cli/scripts/ci.sh + extension-wheel-smoke: + name: extension wheel smoke test (ubuntu-latest, py3.12) + needs: changes + if: needs.changes.outputs.cu-cli == 'true' && (github.event_name == 'pull_request' || github.ref == 'refs/heads/main') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: | + cu-cli/packages/core/pyproject.toml + cu-cli/packages/azure-cli-extension/pyproject.toml + - name: Build extension and core wheels + working-directory: cu-cli + run: | + python -m pip install "build==1.3.0" + python -m build --wheel packages/core + python -m build --wheel packages/azure-cli-extension + - name: Test clean extension installation + working-directory: cu-cli + run: >- + bash scripts/validate_extension_wheel.sh + packages/azure-cli-extension/dist/content_understanding-*.whl + packages/core/dist/cu_cli_core-*.whl + dynamic-hitl-test: name: dynamic HITL calibration tests (ubuntu-latest, py3.14) needs: changes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ede2ed2..be1bc23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,6 +100,12 @@ jobs: if [[ "${PACKAGE}" == "extension" ]]; then (cd dist && sha256sum *.whl > "content-understanding-${{ inputs.version }}.sha256") fi + - name: Test clean extension installation + if: inputs.package == 'extension' + run: >- + bash scripts/validate_extension_wheel.sh + dist/content_understanding-*.whl + packages/core/dist/cu_cli_core-*.whl - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dist-${{ inputs.target }}-${{ inputs.package }}-${{ inputs.version }} diff --git a/cu-cli/CONTRIBUTING.md b/cu-cli/CONTRIBUTING.md index 0804aca..1a02614 100644 --- a/cu-cli/CONTRIBUTING.md +++ b/cu-cli/CONTRIBUTING.md @@ -48,6 +48,17 @@ az cu --help In PowerShell, set `$env:PIP_FIND_LINKS = (Resolve-Path ../core/dist)` before running `az extension add`. +Before releasing the extension, validate the built wheels in a clean Azure CLI +environment. This check installs only dependencies resolved from the wheels and +loads the command group with `az cu --help`: + +```bash +cd ../.. +bash scripts/validate_extension_wheel.sh \ + packages/azure-cli-extension/dist/content_understanding-*.whl \ + packages/core/dist/cu_cli_core-*.whl +``` + ## Running checks ```bash diff --git a/cu-cli/scripts/validate_extension_wheel.sh b/cu-cli/scripts/validate_extension_wheel.sh new file mode 100644 index 0000000..0142644 --- /dev/null +++ b/cu-cli/scripts/validate_extension_wheel.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +set -euo pipefail + +if [[ "$#" -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +extension_wheel="$(realpath "$1")" +core_wheel="$(realpath "$2")" +temp_root="$(mktemp -d)" +trap 'rm -rf "${temp_root}"' EXIT + +if command -v python >/dev/null 2>&1; then + bootstrap_python="python" +else + bootstrap_python="python3" +fi + +"${bootstrap_python}" -m venv "${temp_root}/venv" +python_bin="${temp_root}/venv/bin/python" +az_bin="${temp_root}/venv/bin/az" +export AZURE_CONFIG_DIR="${temp_root}/azure" +export PIP_FIND_LINKS="$(dirname "${core_wheel}")" + +"${python_bin}" -m pip install --disable-pip-version-check --quiet \ + "azure-cli>=2.75.0" +"${az_bin}" extension add \ + --source "${extension_wheel}" \ + --yes \ + --only-show-errors + +"${az_bin}" cu --help >/dev/null + +echo "Validated clean Azure CLI installation of $(basename "${extension_wheel}")." From cb69018bdc29b8ee1b6197d8c4862acb25a68b2e Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang Date: Mon, 14 Sep 2026 11:11:54 -0700 Subject: [PATCH 03/16] Validate Azure CLI extension release artifacts --- .github/workflows/ci.yml | 28 +++++++++ .github/workflows/release.yml | 11 ++++ cu-cli/CONTRIBUTING.md | 10 +++ .../packages/azure-cli-extension/HISTORY.rst | 4 +- .../azext_content_understanding/__init__.py | 10 +++ .../azext_content_understanding/_analysis.py | 3 +- .../_infra_models.py | 4 +- .../azure-cli-extension/pyproject.toml | 1 - .../tests/unit/test_expanded_commands.py | 4 +- .../tests/unit/test_infra_models.py | 41 ++++++++++++ .../packages/core/src/cu_cli_core/analysis.py | 7 +++ .../core/src/cu_cli_core/command_spec.py | 2 + .../scripts/extension_linter_exclusions.yml | 12 ++++ cu-cli/scripts/validate_extension_azdev.sh | 63 +++++++++++++++++++ cu-cli/scripts/validate_extension_wheel.sh | 15 ++++- 15 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 cu-cli/scripts/extension_linter_exclusions.yml create mode 100644 cu-cli/scripts/validate_extension_azdev.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 843c7c1..533c42f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,6 +111,34 @@ jobs: packages/azure-cli-extension/dist/content_understanding-*.whl packages/core/dist/cu_cli_core-*.whl + extension-azdev-linter: + name: extension azdev linter (ubuntu-latest, py3.14) + needs: changes + if: needs.changes.outputs.cu-cli == 'true' && (github.event_name == 'pull_request' || github.ref == 'refs/heads/main') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: | + cu-cli/packages/core/pyproject.toml + cu-cli/packages/azure-cli-extension/pyproject.toml + - name: Build extension and core wheels + working-directory: cu-cli + run: | + python -m pip install "build==1.3.0" + python -m build --wheel packages/core + python -m build --wheel packages/azure-cli-extension + - name: Run Azure CLI extension linter + working-directory: cu-cli + run: >- + bash scripts/validate_extension_azdev.sh + packages/azure-cli-extension/dist/content_understanding-*.whl + packages/core/dist/cu_cli_core-*.whl + dynamic-hitl-test: name: dynamic HITL calibration tests (ubuntu-latest, py3.14) needs: changes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be1bc23..ba7772d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -106,6 +106,17 @@ jobs: bash scripts/validate_extension_wheel.sh dist/content_understanding-*.whl packages/core/dist/cu_cli_core-*.whl + - name: Set up Azure CLI validation Python + if: inputs.package == 'extension' + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.14" + - name: Run Azure CLI extension linter + if: inputs.package == 'extension' + run: >- + bash scripts/validate_extension_azdev.sh + dist/content_understanding-*.whl + packages/core/dist/cu_cli_core-*.whl - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dist-${{ inputs.target }}-${{ inputs.package }}-${{ inputs.version }} diff --git a/cu-cli/CONTRIBUTING.md b/cu-cli/CONTRIBUTING.md index 1a02614..d961a9d 100644 --- a/cu-cli/CONTRIBUTING.md +++ b/cu-cli/CONTRIBUTING.md @@ -59,6 +59,16 @@ bash scripts/validate_extension_wheel.sh \ packages/core/dist/cu_cli_core-*.whl ``` +Run the Azure CLI extensions linter before publishing. This clones clean, +temporary copies of the Azure CLI `dev` branch and the extensions repository, +then runs the same pinned `azdev` wheel linter used by their pipeline: + +```bash +bash scripts/validate_extension_azdev.sh \ + packages/azure-cli-extension/dist/content_understanding-*.whl \ + packages/core/dist/cu_cli_core-*.whl +``` + ## Running checks ```bash diff --git a/cu-cli/packages/azure-cli-extension/HISTORY.rst b/cu-cli/packages/azure-cli-extension/HISTORY.rst index 58f33dd..3a2fcdd 100644 --- a/cu-cli/packages/azure-cli-extension/HISTORY.rst +++ b/cu-cli/packages/azure-cli-extension/HISTORY.rst @@ -4,8 +4,8 @@ Release History 0.1.0b2 (2026-09-14) +++++++++++++++++++++ -* Declare the Azure AI Content Understanding SDK as a direct dependency so - Azure CLI validation environments install the SDK imported by the extension. +* Correct loading of the Azure AI Content Understanding SDK installed through + the shared ``cu-cli-core`` package in isolated Azure CLI environments. 0.1.0b1 (2026-09-11) +++++++++++++++++++++ diff --git a/cu-cli/packages/azure-cli-extension/azext_content_understanding/__init__.py b/cu-cli/packages/azure-cli-extension/azext_content_understanding/__init__.py index b863452..9766e35 100644 --- a/cu-cli/packages/azure-cli-extension/azext_content_understanding/__init__.py +++ b/cu-cli/packages/azure-cli-extension/azext_content_understanding/__init__.py @@ -4,13 +4,23 @@ """Azure CLI command loader for the Content Understanding extension.""" from importlib.metadata import version +from pathlib import Path +import azure.ai from azure.cli.core import AzCommandsLoader from ._help import helps as helps __version__ = version("content-understanding") +# Azure CLI adds an extension's ``azure`` directory to the namespace package, +# but currently does not do the same for an already imported ``azure.ai``. +# azdev loads that namespace before loading extensions, so expose SDKs bundled +# with this extension explicitly. +_azure_ai_path = str(Path(__file__).resolve().parent.parent / "azure" / "ai") +if Path(_azure_ai_path).is_dir() and _azure_ai_path not in azure.ai.__path__: + azure.ai.__path__.append(_azure_ai_path) + class ContentUnderstandingCommandsLoader(AzCommandsLoader): """Load the native ``az cu`` command surface.""" diff --git a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_analysis.py b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_analysis.py index 73b8ff9..996a9a3 100644 --- a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_analysis.py +++ b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_analysis.py @@ -20,6 +20,7 @@ analyze_one_inline, analyze_one_inline_with_usage, analyze_one_with_usage, + to_llm_input, ) from cu_cli_core.command_spec import ANALYZE, build_request, resolve_identifier from cu_cli_core.contracts import ExistingResultPolicy, ResultView @@ -170,8 +171,6 @@ def persist(outcome: Any) -> None: usage = response.usage if isinstance(response, AnalyzeResponse) else None result = response.result if isinstance(response, AnalyzeResponse) else response if request.llm_input: - from azure.ai.contentunderstanding import to_llm_input - payload = to_llm_input(result) if not isinstance(payload, str) or not payload.strip(): raise ServiceError("analysis succeeded, but the model-input result was empty.") diff --git a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_infra_models.py b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_infra_models.py index 8adccea..01d3897 100644 --- a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_infra_models.py +++ b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_infra_models.py @@ -8,12 +8,12 @@ from pathlib import Path from typing import Any, Iterable -from azure.ai.contentunderstanding import ContentUnderstandingClient from azure.core.credentials import AzureKeyCredential from azure.cli.core.util import get_az_user_agent from azure.mgmt.cognitiveservices.models import Deployment, DeploymentModel, DeploymentProperties, Sku from knack.prompting import prompt +from cu_cli_core.client import build_content_understanding_client from cu_cli_core.errors import ConflictError, ServiceError, UsageError from cu_cli_core.defaults import apply_defaults from cu_cli_core.infra_models import ( @@ -120,7 +120,7 @@ def setup_models(cmd: Any, **values: Any) -> dict[str, Any]: key = str(keys.key1 or keys.key2 or "") if not key: raise ServiceError("the Microsoft Foundry resource returned no account key.") - cu_client = ContentUnderstandingClient( + cu_client = build_content_understanding_client( endpoint=endpoint, credential=AzureKeyCredential(key), api_version=api_version, diff --git a/cu-cli/packages/azure-cli-extension/pyproject.toml b/cu-cli/packages/azure-cli-extension/pyproject.toml index 1c86a84..5b57247 100644 --- a/cu-cli/packages/azure-cli-extension/pyproject.toml +++ b/cu-cli/packages/azure-cli-extension/pyproject.toml @@ -21,7 +21,6 @@ classifiers = [ ] dependencies = [ "cu-cli-core>=0.1.0b2,<0.2.0", - "azure-ai-contentunderstanding>=1.2.0b3", "azure-mgmt-cognitiveservices>=13.6.0,<14.0.0", ] diff --git a/cu-cli/packages/azure-cli-extension/tests/unit/test_expanded_commands.py b/cu-cli/packages/azure-cli-extension/tests/unit/test_expanded_commands.py index 8cc690e..42e27c3 100644 --- a/cu-cli/packages/azure-cli-extension/tests/unit/test_expanded_commands.py +++ b/cu-cli/packages/azure-cli-extension/tests/unit/test_expanded_commands.py @@ -39,10 +39,8 @@ def execute(_client, _request, *, jobs, on_result, **_kwargs): return SimpleNamespace(failures=[]) monkeypatch.setattr(_analysis, "resolve_identifier", lambda _operation: execute) - import azure.ai.contentunderstanding as content_understanding - monkeypatch.setattr( - content_understanding, + _analysis, "to_llm_input", lambda result: captured.setdefault("result", result) and formatted, ) diff --git a/cu-cli/packages/azure-cli-extension/tests/unit/test_infra_models.py b/cu-cli/packages/azure-cli-extension/tests/unit/test_infra_models.py index 0d8da89..2117905 100644 --- a/cu-cli/packages/azure-cli-extension/tests/unit/test_infra_models.py +++ b/cu-cli/packages/azure-cli-extension/tests/unit/test_infra_models.py @@ -53,6 +53,47 @@ def test_none_writes_empty_model_file_without_clients(tmp_path: Path) -> None: assert result == {"models": [], "outputFile": str(output), "deployed": False} +def test_model_setup_with_key_uses_core_client_factory( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + captured = {} + management = SimpleNamespace( + accounts=SimpleNamespace( + list_keys=lambda _rg, _account: SimpleNamespace(key1="secret", key2=None), + list_models=lambda _rg, _account: [], + ) + ) + cu_client = SimpleNamespace( + get_analyzer=lambda _name: { + "supportedModels": {"completion": [], "embedding": []} + } + ) + + monkeypatch.setattr(_infra_models, "get_subscription_id", lambda _ctx: "sub-id") + monkeypatch.setattr(_infra_models, "_management_client", lambda _cmd, _sub: management) + monkeypatch.setattr( + _infra_models, + "build_content_understanding_client", + lambda **kwargs: captured.update(kwargs) or cu_client, + ) + + with pytest.raises(_infra_models.ServiceError, match="empty supportedModels catalog"): + _infra_models.setup_models( + SimpleNamespace(cli_ctx=object()), + selection="recommended", + out_path=str(tmp_path / "models.json"), + resource_group="rg", + account_name="account", + endpoint="https://example.services.ai.azure.com/", + api_version="2026-06-01-preview", + use_key=True, + ) + + assert captured["endpoint"] == "https://example.services.ai.azure.com/" + assert captured["api_version"] == "2026-06-01-preview" + assert isinstance(captured["credential"], _infra_models.AzureKeyCredential) + + def test_model_setup_deploys_and_configures_service_defaults( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/cu-cli/packages/core/src/cu_cli_core/analysis.py b/cu-cli/packages/core/src/cu_cli_core/analysis.py index 100875f..f0ca3bc 100644 --- a/cu-cli/packages/core/src/cu_cli_core/analysis.py +++ b/cu-cli/packages/core/src/cu_cli_core/analysis.py @@ -256,6 +256,13 @@ def _analysis_url_input(url: str) -> Any: return AnalysisInput(url=url) +def to_llm_input(result: Any) -> str: + """Convert an SDK analysis result to LLM-ready text.""" + from azure.ai.contentunderstanding import to_llm_input as sdk_to_llm_input + + return sdk_to_llm_input(result) + + def analyze_url( client: Any, analyzer_id: str, diff --git a/cu-cli/packages/core/src/cu_cli_core/command_spec.py b/cu-cli/packages/core/src/cu_cli_core/command_spec.py index b3fa4f8..499c87f 100644 --- a/cu-cli/packages/core/src/cu_cli_core/command_spec.py +++ b/cu-cli/packages/core/src/cu_cli_core/command_spec.py @@ -622,6 +622,7 @@ def _profile_name_arguments(option_help: str) -> tuple[ArgumentSpec, ...]: ), ArgumentSpec( "--destination-subscription", + aliases=("-u",), field="destination_subscription", parser_name="destination_subscription", help=( @@ -631,6 +632,7 @@ def _profile_name_arguments(option_help: str) -> tuple[ArgumentSpec, ...]: ), ArgumentSpec( "--destination-resource-group", + aliases=("-g",), field="destination_resource_group", parser_name="destination_resource_group", help="Resource group used for destination discovery.", diff --git a/cu-cli/scripts/extension_linter_exclusions.yml b/cu-cli/scripts/extension_linter_exclusions.yml new file mode 100644 index 0000000..bdc5d1b --- /dev/null +++ b/cu-cli/scripts/extension_linter_exclusions.yml @@ -0,0 +1,12 @@ + +# These source/destination qualifiers are required because analyzer copy can +# operate across two Azure resources. Keep these exclusions synchronized with +# the Azure CLI extension index PR. +cu analyzer copy: + parameters: + destination_resource_group: + rule_exclusions: + - parameter_should_not_end_in_resource_group + source_resource_group: + rule_exclusions: + - parameter_should_not_end_in_resource_group \ No newline at end of file diff --git a/cu-cli/scripts/validate_extension_azdev.sh b/cu-cli/scripts/validate_extension_azdev.sh new file mode 100644 index 0000000..b4c5312 --- /dev/null +++ b/cu-cli/scripts/validate_extension_azdev.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +set -euo pipefail + +if [[ "$#" -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +extension_wheel="$(realpath "$1")" +core_wheel="$(realpath "$2")" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +temp_root="$(mktemp -d)" +trap 'rm -rf "${temp_root}"' EXIT + +if command -v python >/dev/null 2>&1; then + bootstrap_python="python" +else + bootstrap_python="python3" +fi + +"${bootstrap_python}" -m venv "${temp_root}/venv" +export VIRTUAL_ENV="${temp_root}/venv" +export PATH="${VIRTUAL_ENV}/bin:${PATH}" +export AZURE_CONFIG_DIR="${temp_root}/azure" +export PIP_FIND_LINKS="$(dirname "${core_wheel}")" + +git clone --depth 1 --branch dev \ + https://github.com/Azure/azure-cli.git "${temp_root}/azure-cli" +git clone --depth 1 \ + https://github.com/Azure/azure-cli-extensions.git "${temp_root}/azure-cli-extensions" +cat "${script_dir}/extension_linter_exclusions.yml" \ + >> "${temp_root}/azure-cli-extensions/linter_exclusions.yml" + +python -m pip install --disable-pip-version-check --quiet \ + --upgrade pip \ + "azdev==0.2.13" \ + "build==1.3.0" \ + wheel +azdev setup \ + -c "${temp_root}/azure-cli" \ + -r "${temp_root}/azure-cli-extensions" + +az extension add \ + --source "${extension_wheel}" \ + --yes \ + --only-show-errors +python -m pip install --disable-pip-version-check --quiet \ + --no-deps \ + --upgrade \ + --force-reinstall \ + --target "${AZURE_CONFIG_DIR}/cliextensions/content-understanding" \ + "${core_wheel}" +( + cd "${temp_root}/azure-cli-extensions" + azdev linter \ + --include-whl-extensions content-understanding \ + --min-severity medium +) + +echo "Validated $(basename "${extension_wheel}") with the Azure CLI extension linter." \ No newline at end of file diff --git a/cu-cli/scripts/validate_extension_wheel.sh b/cu-cli/scripts/validate_extension_wheel.sh index 0142644..46f775f 100644 --- a/cu-cli/scripts/validate_extension_wheel.sh +++ b/cu-cli/scripts/validate_extension_wheel.sh @@ -32,7 +32,18 @@ export PIP_FIND_LINKS="$(dirname "${core_wheel}")" --source "${extension_wheel}" \ --yes \ --only-show-errors - -"${az_bin}" cu --help >/dev/null +"${python_bin}" -m pip install --disable-pip-version-check --quiet \ + --no-deps \ + --upgrade \ + --force-reinstall \ + --target "${AZURE_CONFIG_DIR}/cliextensions/content-understanding" \ + "${core_wheel}" + +"${python_bin}" - <<'PY' +import azure.ai # Simulate Azure CLI command modules that load this namespace first. +from azure.cli.core import get_default_cli + +raise SystemExit(get_default_cli().invoke(["cu", "--help"])) +PY echo "Validated clean Azure CLI installation of $(basename "${extension_wheel}")." From 8f52b5bf4cf1ad200b1f6d7b856f56eadecf6709 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang Date: Mon, 14 Sep 2026 11:39:43 -0700 Subject: [PATCH 04/16] Enforce CU SDK frontend boundary --- .../azext_content_understanding/_analysis.py | 5 +- .../tests/unit/test_expanded_commands.py | 2 +- .../packages/core/src/cu_cli_core/analysis.py | 7 -- .../core/src/cu_cli_core/serialization.py | 11 +++ .../packages/core/tests/test_serialization.py | 29 +++++- cu-cli/packages/standalone/pyproject.toml | 1 - .../standalone/src/cu_cli/commands/analyze.py | 4 +- .../packages/standalone/src/cu_cli/output.py | 12 +-- .../unit/commands/test_analyze_contract.py | 59 +++++------- .../tests/unit/core/test_analyzers.py | 5 +- .../standalone/tests/unit/test_client.py | 83 ++++++---------- .../tests/unit/test_frontend_sdk_boundary.py | 77 +++++++++++++++ .../standalone/tests/unit/test_output.py | 20 +--- cu-cli/scripts/ci.sh | 4 + .../scripts/validate_frontend_sdk_boundary.py | 95 +++++++++++++++++++ 15 files changed, 281 insertions(+), 133 deletions(-) create mode 100644 cu-cli/packages/standalone/tests/unit/test_frontend_sdk_boundary.py create mode 100644 cu-cli/scripts/validate_frontend_sdk_boundary.py diff --git a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_analysis.py b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_analysis.py index 996a9a3..13122fc 100644 --- a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_analysis.py +++ b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_analysis.py @@ -20,7 +20,6 @@ analyze_one_inline, analyze_one_inline_with_usage, analyze_one_with_usage, - to_llm_input, ) from cu_cli_core.command_spec import ANALYZE, build_request, resolve_identifier from cu_cli_core.contracts import ExistingResultPolicy, ResultView @@ -33,7 +32,7 @@ ) from cu_cli_core.profiles import Profile from cu_cli_core.reporting import build_analysis_report -from cu_cli_core.serialization import to_plain_value +from cu_cli_core.serialization import render_llm_input, to_plain_value from ._client_factory import create_content_understanding_client from ._io import write_json, write_text @@ -171,7 +170,7 @@ def persist(outcome: Any) -> None: usage = response.usage if isinstance(response, AnalyzeResponse) else None result = response.result if isinstance(response, AnalyzeResponse) else response if request.llm_input: - payload = to_llm_input(result) + payload = render_llm_input(result) if not isinstance(payload, str) or not payload.strip(): raise ServiceError("analysis succeeded, but the model-input result was empty.") else: diff --git a/cu-cli/packages/azure-cli-extension/tests/unit/test_expanded_commands.py b/cu-cli/packages/azure-cli-extension/tests/unit/test_expanded_commands.py index 42e27c3..0998cfb 100644 --- a/cu-cli/packages/azure-cli-extension/tests/unit/test_expanded_commands.py +++ b/cu-cli/packages/azure-cli-extension/tests/unit/test_expanded_commands.py @@ -41,7 +41,7 @@ def execute(_client, _request, *, jobs, on_result, **_kwargs): monkeypatch.setattr(_analysis, "resolve_identifier", lambda _operation: execute) monkeypatch.setattr( _analysis, - "to_llm_input", + "render_llm_input", lambda result: captured.setdefault("result", result) and formatted, ) diff --git a/cu-cli/packages/core/src/cu_cli_core/analysis.py b/cu-cli/packages/core/src/cu_cli_core/analysis.py index f0ca3bc..100875f 100644 --- a/cu-cli/packages/core/src/cu_cli_core/analysis.py +++ b/cu-cli/packages/core/src/cu_cli_core/analysis.py @@ -256,13 +256,6 @@ def _analysis_url_input(url: str) -> Any: return AnalysisInput(url=url) -def to_llm_input(result: Any) -> str: - """Convert an SDK analysis result to LLM-ready text.""" - from azure.ai.contentunderstanding import to_llm_input as sdk_to_llm_input - - return sdk_to_llm_input(result) - - def analyze_url( client: Any, analyzer_id: str, diff --git a/cu-cli/packages/core/src/cu_cli_core/serialization.py b/cu-cli/packages/core/src/cu_cli_core/serialization.py index 6b5c9c2..d5fbbe2 100644 --- a/cu-cli/packages/core/src/cu_cli_core/serialization.py +++ b/cu-cli/packages/core/src/cu_cli_core/serialization.py @@ -15,6 +15,17 @@ from .errors import ValidationError +def render_llm_input(result: Any) -> Any: + """Render an SDK result or plain result mapping as LLM-ready content.""" + from azure.ai.contentunderstanding import to_llm_input + + if isinstance(result, Mapping): + from azure.ai.contentunderstanding.models import AnalysisResult + + result = AnalysisResult(result) + return to_llm_input(result) + + def to_plain_value(value: Any) -> Any: """Recursively convert supported values to dictionaries, lists, and scalars.""" diff --git a/cu-cli/packages/core/tests/test_serialization.py b/cu-cli/packages/core/tests/test_serialization.py index 659c432..61693fd 100644 --- a/cu-cli/packages/core/tests/test_serialization.py +++ b/cu-cli/packages/core/tests/test_serialization.py @@ -11,7 +11,7 @@ import pytest from cu_cli_core.errors import ValidationError -from cu_cli_core.serialization import to_plain_value +from cu_cli_core.serialization import render_llm_input, to_plain_value pytestmark = pytest.mark.unit @@ -38,6 +38,33 @@ def as_dict(self): } +def test_render_llm_input_passes_sdk_result_through(monkeypatch): + result = _SdkModel() + captured = {} + + def render(value): + captured["value"] = value + return "rendered" + + monkeypatch.setattr("azure.ai.contentunderstanding.to_llm_input", render) + + assert render_llm_input(result) == "rendered" + assert captured["value"] is result + + +def test_render_llm_input_converts_mapping_to_analysis_result(monkeypatch): + captured = {} + + def render(value): + captured["value"] = value + return "rendered" + + monkeypatch.setattr("azure.ai.contentunderstanding.to_llm_input", render) + + assert render_llm_input({"contents": []}) == "rendered" + assert captured["value"].as_dict() == {"contents": []} + + def test_to_plain_value_recursively_serializes_supported_values(): assert to_plain_value(_SdkModel()) == { "record": {"path": "result.json", "status": "ready"}, diff --git a/cu-cli/packages/standalone/pyproject.toml b/cu-cli/packages/standalone/pyproject.toml index 3cf2ac1..06caa0d 100644 --- a/cu-cli/packages/standalone/pyproject.toml +++ b/cu-cli/packages/standalone/pyproject.toml @@ -28,7 +28,6 @@ classifiers = [ ] dependencies = [ "cu-cli-core>=0.1.0b2,<0.2.0", - "azure-ai-contentunderstanding>=1.2.0b3", "azure-identity>=1.19", # Management-plane clients used by `cu analyzer copy` to resolve # Foundry endpoint URLs / account names / ARM IDs to canonical diff --git a/cu-cli/packages/standalone/src/cu_cli/commands/analyze.py b/cu-cli/packages/standalone/src/cu_cli/commands/analyze.py index 870917b..4e07f55 100644 --- a/cu-cli/packages/standalone/src/cu_cli/commands/analyze.py +++ b/cu-cli/packages/standalone/src/cu_cli/commands/analyze.py @@ -164,9 +164,7 @@ def _redact_remote_result(result, *, input_url: str): def _render_remote_markdown(result, *, input_url: str) -> str: - from azure.ai.contentunderstanding.models import AnalysisResult - - return render_markdown(AnalysisResult(_redact_remote_result(result, input_url=input_url))) + return render_markdown(_redact_remote_result(result, input_url=input_url)) def _write_markdown_stdout(result, *, input_url: str) -> None: diff --git a/cu-cli/packages/standalone/src/cu_cli/output.py b/cu-cli/packages/standalone/src/cu_cli/output.py index ee0f289..7ba7c18 100644 --- a/cu-cli/packages/standalone/src/cu_cli/output.py +++ b/cu-cli/packages/standalone/src/cu_cli/output.py @@ -18,7 +18,7 @@ from pathlib import Path from typing import Any, Iterable -from cu_cli_core.serialization import to_plain_value +from cu_cli_core.serialization import render_llm_input, to_plain_value from rich.console import Console from rich.table import Table @@ -125,15 +125,7 @@ def dump_json( def render_markdown(result: Any) -> str: """Render an analysis result as LLM-friendly markdown via SDK helper only.""" - try: - from azure.ai.contentunderstanding import to_llm_input - except Exception as exc: # noqa: BLE001 - raise RuntimeError( - "Markdown output requires SDK support for to_llm_input(). " - "Install azure-ai-contentunderstanding>=1.2.0b3." - ) from exc - - rendered = to_llm_input(result) + rendered = render_llm_input(result) if not isinstance(rendered, str) or not rendered.strip(): raise EmptyMarkdownOutputError("to_llm_input() returned empty markdown output.") return rendered diff --git a/cu-cli/packages/standalone/tests/unit/commands/test_analyze_contract.py b/cu-cli/packages/standalone/tests/unit/commands/test_analyze_contract.py index d825d4e..7cfc265 100644 --- a/cu-cli/packages/standalone/tests/unit/commands/test_analyze_contract.py +++ b/cu-cli/packages/standalone/tests/unit/commands/test_analyze_contract.py @@ -3,6 +3,7 @@ from __future__ import annotations +import copy import hashlib import json from pathlib import Path @@ -171,7 +172,7 @@ def test_success_markdown_redacts_sas_echoed_by_service( ) monkeypatch.setattr( "cu_cli.commands.analyze.render_markdown", - lambda result: result.contents[0].markdown, + lambda result: result["contents"][0]["markdown"], ) result = _run("analyze", url) @@ -199,7 +200,8 @@ def test_remote_result_preserves_unrelated_links_and_markdown( lambda _client, job: (job, {"contents": [{"markdown": body}], "source": url}), ) monkeypatch.setattr( - "cu_cli.commands.analyze.render_markdown", lambda result: result.contents[0].markdown, + "cu_cli.commands.analyze.render_markdown", + lambda result: result["contents"][0]["markdown"], ) output_dir = Path("results") args = ["analyze", url] @@ -230,7 +232,6 @@ def test_remote_result_preserves_unrelated_links_and_markdown( def test_remote_markdown_redacts_before_sdk_yaml_escaping( analyze_runtime, monkeypatch, write_file, ): - from azure.ai.contentunderstanding.models import AnalysisResult, DocumentContent from cu_cli.output import render_markdown url = "https://example.test/bob's.pdf?sv=1&sig=render-secret" @@ -238,11 +239,11 @@ def test_remote_markdown_redacts_before_sdk_yaml_escaping( public_link = "[invoice](https://example.test/view?id=42)" def make_result(source): - return AnalysisResult(contents=[DocumentContent( - mime_type="application/pdf", - metadata={"source": f"Downloaded: {source}"}, - markdown=f"{public_link}\n[source]({source}) after", - )]) + return {"contents": [{ + "mimeType": "application/pdf", + "metadata": {"source": f"Downloaded: {source}"}, + "markdown": f"{public_link}\n[source]({source}) after", + }]} original = make_result(url) expected = render_markdown(make_result(safe_url)) @@ -260,7 +261,7 @@ def make_result(source): assert text.rstrip("\n") == expected.rstrip("\n") assert "render-secret" not in text assert public_link in text - assert original.contents[0].metadata["source"] == f"Downloaded: {url}" + assert original["contents"][0]["metadata"]["source"] == f"Downloaded: {url}" @pytest.mark.parametrize("json_output", [False, True], ids=["markdown", "json"]) @@ -272,7 +273,6 @@ def make_result(source): def test_remote_result_redaction_preserves_span_boundaries( analyze_runtime, monkeypatch, tmp_path, json_output, write_file, classification, query, ): - from azure.ai.contentunderstanding.models import AnalysisResult from cu_cli.output import render_markdown url = f"https://example.test/bob's.pdf{query}" @@ -311,10 +311,10 @@ def make_result(source): {"pageNumber": page_number, "spans": [span]} for page_number, span in enumerate(spans, start=1) ] - return AnalysisResult({"contents": [content]}) + return {"contents": [content]} original = make_result(url) - snapshot = original.as_dict() + snapshot = copy.deepcopy(original) expected_result = make_result(safe_url) expected = render_markdown(expected_result) monkeypatch.setattr( @@ -333,13 +333,13 @@ def make_result(source): text = destination.read_text(encoding="utf-8") if write_file else result.stdout if json_output: exported = json.loads(text) - assert exported == expected_result.as_dict() - rendered = render_markdown(AnalysisResult(exported)) + assert exported == expected_result + rendered = render_markdown(exported) else: rendered = text assert rendered.rstrip("\n") == expected.rstrip("\n") assert public_link in text - assert original.as_dict() == snapshot + assert original == snapshot @pytest.mark.parametrize("write_file", [False, True]) @@ -349,7 +349,6 @@ def make_result(source): def test_remote_json_redaction_preserves_nested_spans( analyze_runtime, monkeypatch, tmp_path, write_file, query, ): - from azure.ai.contentunderstanding.models import AnalysisResult from cu_cli.output import render_markdown url = f"https://example.test/input.pdf{query}" @@ -362,7 +361,7 @@ def make_result(source): url_span = {"offset": len(prefix), "length": len(source)} tail_span = {"offset": len(prefix) + len(source) + 1, "length": len(tail)} opaque = {"span": {"offset": 100, "length": 20}, "spans": [1, 2], "url": source} - return AnalysisResult({ + return { "stringEncoding": "unicodeCodePoint", "contents": [{ "kind": "document", @@ -400,10 +399,10 @@ def make_result(source): "markdown": tail, "pages": [{"pageNumber": 2, "spans": [{"offset": 0, "length": len(tail)}]}], }], - }) + } original = make_result(url) - snapshot = original.as_dict() + snapshot = copy.deepcopy(original) expected = make_result(safe_url) monkeypatch.setattr( "cu_cli.commands.analyze._run_one", lambda _client, job: (job, original), @@ -418,9 +417,9 @@ def make_result(source): assert result.exit_code == 0, result.output text = destination.read_text(encoding="utf-8") if write_file else result.stdout exported = json.loads(text) - assert exported == expected.as_dict() - assert render_markdown(AnalysisResult(exported)) == render_markdown(expected) - assert original.as_dict() == snapshot + assert exported == expected + assert render_markdown(exported) == render_markdown(expected) + assert original == snapshot @pytest.mark.parametrize("input_option", [(), ("--url",)], ids=["positional", "named"]) @@ -430,7 +429,6 @@ def make_result(source): def test_remote_json_preserves_sdk_response_envelope( monkeypatch, tmp_path, inline, write_file, show_usage, input_option, ): - from azure.ai.contentunderstanding.models import AnalysisResult from cu_cli.output import render_markdown url = "https://example.test/bob's.pdf?sv=1&sig=raw-response-secret" @@ -470,7 +468,7 @@ def make_response(source): return response raw_response = make_response(url) - sdk_result = AnalysisResult(raw_response["result"]) + sdk_result = raw_response["result"] calls = [] def sdk_analyze(*, analyzer_id, inputs, cls=None): @@ -508,12 +506,10 @@ def sdk_analyze(*, analyzer_id, inputs, cls=None): exported = json.loads(text) expected = make_response(safe_url) assert exported == expected - assert render_markdown(AnalysisResult(exported["result"])) == render_markdown( - AnalysisResult(expected["result"]), - ) + assert render_markdown(exported["result"]) == render_markdown(expected["result"]) assert "raw-response-secret" not in result.output + text assert raw_response == make_response(url) - assert sdk_result.as_dict() == raw_response["result"] + assert sdk_result == raw_response["result"] if show_usage: assert usage_key in result.stderr @@ -641,7 +637,6 @@ def run_one(_client, job): def test_remote_result_filename_is_written_and_reused( analyze_runtime, monkeypatch, tmp_path, json_output, character, explicit_output, ): - from azure.ai.contentunderstanding.models import AnalysisResult, DocumentContent from cu_cli.output import render_markdown suffix = ".result.json" if json_output else ".result.md" @@ -651,9 +646,7 @@ def test_remote_result_filename_is_written_and_reused( if explicit_output: basename = character + basename url = f"https://example.test/{basename}?sig=secret" - original = AnalysisResult(contents=[DocumentContent( - mime_type="application/pdf", markdown="Example", - )]) + original = {"contents": [{"mimeType": "application/pdf", "markdown": "Example"}]} calls = [] def run_one(_client, job): @@ -682,7 +675,7 @@ def run_one(_client, job): assert len(outputs[0].name.encode("utf-8")) == 240 text = outputs[0].read_text(encoding="utf-8") if json_output: - assert json.loads(text) == original.as_dict() + assert json.loads(text) == original else: assert text == render_markdown(original) diff --git a/cu-cli/packages/standalone/tests/unit/core/test_analyzers.py b/cu-cli/packages/standalone/tests/unit/core/test_analyzers.py index b85661d..3ca5f16 100644 --- a/cu-cli/packages/standalone/tests/unit/core/test_analyzers.py +++ b/cu-cli/packages/standalone/tests/unit/core/test_analyzers.py @@ -625,15 +625,14 @@ def test_copy_analyzer_progress_never_leaks_authorization_material(): scrubbed if the SDK model grows sensitive fields in a future version. """ import datetime - - from azure.ai.contentunderstanding.models import CopyAuthorization + from types import SimpleNamespace from cu_cli.core.analyzers import copy_analyzer source_path = "/subscriptions/S/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/src" target_path = "/subscriptions/T/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/tgt" expiry = datetime.datetime(2026, 8, 25, 12, tzinfo=datetime.timezone.utc) - copy_authorization = CopyAuthorization( + copy_authorization = SimpleNamespace( source=source_path, target_azure_resource_id=target_path, expires_at=expiry, diff --git a/cu-cli/packages/standalone/tests/unit/test_client.py b/cu-cli/packages/standalone/tests/unit/test_client.py index 6f4f452..a4e64b2 100644 --- a/cu-cli/packages/standalone/tests/unit/test_client.py +++ b/cu-cli/packages/standalone/tests/unit/test_client.py @@ -1,24 +1,26 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -"""Credential-hygiene warnings emitted by ``build_client``. - -Regression coverage for an ``--api-key`` value on argv leaking via -``ps``/shell history, and ``--api-key`` being silently ignored when -``--entra`` also given). -""" +"""Credential-hygiene warnings emitted by ``build_client``.""" from __future__ import annotations +import pytest + from cu_cli.client import build_client +from cu_cli.errors import CuCliError from cu_cli.profile import Profile +pytestmark = pytest.mark.unit -import pytest -from cu_cli.errors import CuCliError +@pytest.fixture(autouse=True) +def _stub_core_client_factory(monkeypatch): + monkeypatch.setattr( + "cu_cli.client.build_content_understanding_client", + lambda **kwargs: kwargs, + ) -pytestmark = pytest.mark.unit def test_build_client_warns_on_argv_api_key(capsys): profile = Profile(endpoint="https://x.services.ai.azure.com/") @@ -36,87 +38,62 @@ def test_build_client_warns_when_api_key_combined_with_entra(capsys): def test_build_client_silent_without_argv_api_key(capsys): - # A key sourced from config (not argv) must not trigger the warning. - profile = Profile(endpoint="https://x.services.ai.azure.com/", auth_mode="key", - api_key="from-profile") + profile = Profile( + endpoint="https://x.services.ai.azure.com/", + auth_mode="key", + api_key="from-profile", + ) build_client(profile) - err = capsys.readouterr().err - assert "--api-key" not in err + assert "--api-key" not in capsys.readouterr().err -def test_build_client_rejects_non_https_login_endpoint_before_sdk_construction( - monkeypatch, -): +def test_build_client_rejects_non_https_login_endpoint_before_core_factory(monkeypatch): monkeypatch.setattr( - "azure.ai.contentunderstanding.ContentUnderstandingClient", - lambda **_kwargs: pytest.fail("SDK client must not be constructed"), + "cu_cli.client.build_content_understanding_client", + lambda **_kwargs: pytest.fail("core client factory must not be called"), ) - with pytest.raises(CuCliError) as exc_info: - build_client( - Profile(endpoint="http://not-https.example.invalid/"), - force_entra=True, - ) - + build_client(Profile(endpoint="http://not-https.example.invalid/"), force_entra=True) rendered = exc_info.value.format_message() assert "authentication mode 'login' requires an HTTPS endpoint" in rendered assert "******" not in rendered -def test_build_client_rejects_malformed_endpoint_before_sdk_construction(monkeypatch): +def test_build_client_rejects_malformed_endpoint_before_core_factory(monkeypatch): monkeypatch.setattr( - "azure.ai.contentunderstanding.ContentUnderstandingClient", - lambda **_kwargs: pytest.fail("SDK client must not be constructed"), + "cu_cli.client.build_content_understanding_client", + lambda **_kwargs: pytest.fail("core client factory must not be called"), ) - with pytest.raises(CuCliError, match="invalid foundry endpoint"): build_client(Profile(endpoint="not-a-url")) def test_build_client_honors_telemetry_opt_out(monkeypatch): - # Opt-out flows all the way to the SDK client as an empty User-Agent prefix - # (azure-core then sends only its standard azsdk moniker, no cu-cli marker). captured: dict = {} - - class _FakeSdkClient: - def __init__(self, **kwargs): - captured.update(kwargs) - monkeypatch.setattr( - "azure.ai.contentunderstanding.ContentUnderstandingClient", _FakeSdkClient + "cu_cli.client.build_content_understanding_client", + lambda **kwargs: captured.update(kwargs), ) monkeypatch.setenv("CU_TELEMETRY", "off") build_client(Profile(endpoint="https://x.services.ai.azure.com/")) assert captured["user_agent"] == "" - assert "cu-cli" not in captured["user_agent"] def test_build_client_sends_marker_when_telemetry_on(monkeypatch): captured: dict = {} - - class _FakeSdkClient: - def __init__(self, **kwargs): - captured.update(kwargs) - monkeypatch.setattr( - "azure.ai.contentunderstanding.ContentUnderstandingClient", _FakeSdkClient + "cu_cli.client.build_content_understanding_client", + lambda **kwargs: captured.update(kwargs), ) - # CU_* env is stripped by the isolate fixture -> telemetry on by default. build_client(Profile(endpoint="https://x.services.ai.azure.com/")) assert captured["user_agent"].startswith("cu-cli/") def test_build_client_polls_long_running_operations_every_second(monkeypatch): captured: dict = {} - - class _FakeSdkClient: - def __init__(self, **kwargs): - captured.update(kwargs) - monkeypatch.setattr( - "azure.ai.contentunderstanding.ContentUnderstandingClient", _FakeSdkClient + "cu_cli.client.build_content_understanding_client", + lambda **kwargs: captured.update(kwargs), ) - build_client(Profile(endpoint="https://x.services.ai.azure.com/")) - assert captured["polling_interval"] == 1 diff --git a/cu-cli/packages/standalone/tests/unit/test_frontend_sdk_boundary.py b/cu-cli/packages/standalone/tests/unit/test_frontend_sdk_boundary.py new file mode 100644 index 0000000..764551d --- /dev/null +++ b/cu-cli/packages/standalone/tests/unit/test_frontend_sdk_boundary.py @@ -0,0 +1,77 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType + +import pytest + + +def _load_validator() -> ModuleType: + script_path = Path(__file__).resolve().parents[4] / "scripts" / "validate_frontend_sdk_boundary.py" + spec = importlib.util.spec_from_file_location("validate_frontend_sdk_boundary", script_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Unable to load {script_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +validator = _load_validator() + + +def _write_frontends(root: Path) -> None: + for package, source in ( + ("standalone", "src/cu_cli"), + ("azure-cli-extension", "azext_content_understanding"), + ): + source_root = root / "packages" / package / source + source_root.mkdir(parents=True) + (source_root / "module.py").write_text("from cu_cli_core import client\n", encoding="utf-8") + tests = root / "packages" / package / "tests" + tests.mkdir(parents=True) + (tests / "test_module.py").write_text("def test_placeholder(): pass\n", encoding="utf-8") + (root / "packages" / package / "pyproject.toml").write_text( + '[project]\nname = "frontend"\nversion = "1.0.0"\ndependencies = ["cu-cli-core"]\n', + encoding="utf-8", + ) + + +def test_repository_frontends_use_core_sdk_boundary() -> None: + assert validator.find_violations() == [] + + +@pytest.mark.parametrize( + ("relative_path", "content", "message"), + [ + ( + "packages/standalone/src/cu_cli/module.py", + "from " + "azure.ai." + "contentunderstanding import to_llm_input\n", + "direct CU SDK reference", + ), + ( + "packages/azure-cli-extension/tests/test_module.py", + 'TARGET = "' + "azure.ai." + 'contentunderstanding.ContentUnderstandingClient"\n', + "direct CU SDK reference", + ), + ( + "packages/standalone/pyproject.toml", + '[project]\nname = "frontend"\nversion = "1.0.0"\n' + 'dependencies = ["azure_ai_' + 'contentunderstanding>=1"]\n', + "direct CU SDK dependency", + ), + ], +) +def test_validator_rejects_direct_sdk_coupling( + tmp_path: Path, + relative_path: str, + content: str, + message: str, +) -> None: + _write_frontends(tmp_path) + (tmp_path / relative_path).write_text(content, encoding="utf-8") + + assert any(message in violation for violation in validator.find_violations(tmp_path)) diff --git a/cu-cli/packages/standalone/tests/unit/test_output.py b/cu-cli/packages/standalone/tests/unit/test_output.py index dbf99d0..2b2fb0c 100644 --- a/cu-cli/packages/standalone/tests/unit/test_output.py +++ b/cu-cli/packages/standalone/tests/unit/test_output.py @@ -3,9 +3,6 @@ from __future__ import annotations -import sys -import types - import pytest from cu_cli_core.contracts import OutcomeStatus @@ -14,29 +11,16 @@ pytestmark = pytest.mark.unit -def _install_fake_sdk(monkeypatch: pytest.MonkeyPatch, *, fn) -> None: - fake = types.ModuleType("azure.ai.contentunderstanding") - fake.to_llm_input = fn - monkeypatch.setitem(sys.modules, "azure.ai.contentunderstanding", fake) - - def test_render_markdown_uses_to_llm_input_only(monkeypatch: pytest.MonkeyPatch) -> None: class _Result: pass - _install_fake_sdk(monkeypatch, fn=lambda _r: "hello") + monkeypatch.setattr("cu_cli.output.render_llm_input", lambda _result: "hello") assert render_markdown(_Result()) == "hello" -def test_render_markdown_requires_to_llm_input(monkeypatch: pytest.MonkeyPatch) -> None: - fake = types.ModuleType("azure.ai.contentunderstanding") - monkeypatch.setitem(sys.modules, "azure.ai.contentunderstanding", fake) - with pytest.raises(RuntimeError, match="to_llm_input"): - render_markdown(object()) - - def test_render_markdown_rejects_empty_output(monkeypatch: pytest.MonkeyPatch) -> None: - _install_fake_sdk(monkeypatch, fn=lambda _r: " \n") + monkeypatch.setattr("cu_cli.output.render_llm_input", lambda _result: " \n") with pytest.raises(EmptyMarkdownOutputError, match="empty markdown"): render_markdown(object()) diff --git a/cu-cli/scripts/ci.sh b/cu-cli/scripts/ci.sh index 78b886c..6413861 100644 --- a/cu-cli/scripts/ci.sh +++ b/cu-cli/scripts/ci.sh @@ -59,6 +59,10 @@ cd "${product_dir}" python scripts/check_headers.py end_section +section "Frontend CU SDK boundary" +python scripts/validate_frontend_sdk_boundary.py +end_section + section "Lint shared core (ruff)" cd "${core_dir}" python -m ruff check . diff --git a/cu-cli/scripts/validate_frontend_sdk_boundary.py b/cu-cli/scripts/validate_frontend_sdk_boundary.py new file mode 100644 index 0000000..0d25d49 --- /dev/null +++ b/cu-cli/scripts/validate_frontend_sdk_boundary.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Ensure frontend packages consume the CU SDK only through ``cu-cli-core``.""" + +from __future__ import annotations + +import ast +from pathlib import Path +import re +import sys +from typing import Any + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python 3.10 + import tomli as tomllib + + +ROOT = Path(__file__).resolve().parents[1] +SDK_MODULE = "azure.ai.contentunderstanding" +SDK_DISTRIBUTION = "azure-ai-contentunderstanding" +FRONTEND_SOURCE_ROOTS = ( + ROOT / "packages/standalone/src/cu_cli", + ROOT / "packages/standalone/tests", + ROOT / "packages/azure-cli-extension/azext_content_understanding", + ROOT / "packages/azure-cli-extension/tests", +) +FRONTEND_PROJECTS = ( + ROOT / "packages/standalone/pyproject.toml", + ROOT / "packages/azure-cli-extension/pyproject.toml", +) + + +def _canonicalize_name(value: str) -> str: + return re.sub(r"[-_.]+", "-", value).lower() + + +def _dependency_name(requirement: str) -> str: + match = re.match(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)", requirement) + return _canonicalize_name(match.group(1)) if match else "" + + +def _dependency_groups(project: dict[str, Any]) -> list[str]: + dependencies = list(project.get("dependencies", [])) + for group in project.get("optional-dependencies", {}).values(): + dependencies.extend(group) + return dependencies + + +def find_violations(root: Path = ROOT) -> list[str]: + """Return direct frontend CU SDK references and dependency declarations.""" + violations: list[str] = [] + source_roots = tuple(root / path.relative_to(ROOT) for path in FRONTEND_SOURCE_ROOTS) + projects = tuple(root / path.relative_to(ROOT) for path in FRONTEND_PROJECTS) + + for source_root in source_roots: + for path in sorted(source_root.rglob("*.py")): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + references = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom): + references = [node.module or ""] + elif isinstance(node, ast.Constant) and isinstance(node.value, str): + references = [node.value] + else: + continue + if any(SDK_MODULE in reference for reference in references): + relative = path.relative_to(root) + violations.append(f"{relative}:{node.lineno}: direct CU SDK reference") + + for path in projects: + project = tomllib.loads(path.read_text(encoding="utf-8"))["project"] + for requirement in _dependency_groups(project): + if _dependency_name(requirement) == SDK_DISTRIBUTION: + relative = path.relative_to(root) + violations.append(f"{relative}: direct CU SDK dependency: {requirement}") + return violations + + +def main() -> int: + violations = find_violations() + if violations: + print("Frontend packages must access the CU SDK through cu-cli-core:") + for violation in violations: + print(f" {violation}") + return 1 + print("Validated frontend CU SDK boundary.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From afca3b7ca21485449ebad34e9a97ec277e08dfcc Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang Date: Mon, 14 Sep 2026 12:02:32 -0700 Subject: [PATCH 05/16] Isolate Azure CLI extension validation --- cu-cli/scripts/validate_extension_azdev.sh | 16 +++++++++++++++- cu-cli/scripts/validate_extension_wheel.sh | 13 ++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/cu-cli/scripts/validate_extension_azdev.sh b/cu-cli/scripts/validate_extension_azdev.sh index b4c5312..0e3476d 100644 --- a/cu-cli/scripts/validate_extension_azdev.sh +++ b/cu-cli/scripts/validate_extension_azdev.sh @@ -25,6 +25,7 @@ fi export VIRTUAL_ENV="${temp_root}/venv" export PATH="${VIRTUAL_ENV}/bin:${PATH}" export AZURE_CONFIG_DIR="${temp_root}/azure" +export AZURE_EXTENSION_DIR="${AZURE_CONFIG_DIR}/cliextensions" export PIP_FIND_LINKS="$(dirname "${core_wheel}")" git clone --depth 1 --branch dev \ @@ -51,8 +52,21 @@ python -m pip install --disable-pip-version-check --quiet \ --no-deps \ --upgrade \ --force-reinstall \ - --target "${AZURE_CONFIG_DIR}/cliextensions/content-understanding" \ + --target "${AZURE_EXTENSION_DIR}/content-understanding" \ "${core_wheel}" +python - <<'PY' +import sys + +from pathlib import Path + +extension_dir = Path(__import__("os").environ["AZURE_EXTENSION_DIR"]) / "content-understanding" +sys.path.insert(0, str(extension_dir)) + +from cu_cli_core import serialization + +assert Path(serialization.__file__).is_relative_to(extension_dir) +assert hasattr(serialization, "render_llm_input") +PY ( cd "${temp_root}/azure-cli-extensions" azdev linter \ diff --git a/cu-cli/scripts/validate_extension_wheel.sh b/cu-cli/scripts/validate_extension_wheel.sh index 46f775f..bc258c9 100644 --- a/cu-cli/scripts/validate_extension_wheel.sh +++ b/cu-cli/scripts/validate_extension_wheel.sh @@ -24,6 +24,7 @@ fi python_bin="${temp_root}/venv/bin/python" az_bin="${temp_root}/venv/bin/az" export AZURE_CONFIG_DIR="${temp_root}/azure" +export AZURE_EXTENSION_DIR="${AZURE_CONFIG_DIR}/cliextensions" export PIP_FIND_LINKS="$(dirname "${core_wheel}")" "${python_bin}" -m pip install --disable-pip-version-check --quiet \ @@ -36,13 +37,23 @@ export PIP_FIND_LINKS="$(dirname "${core_wheel}")" --no-deps \ --upgrade \ --force-reinstall \ - --target "${AZURE_CONFIG_DIR}/cliextensions/content-understanding" \ + --target "${AZURE_EXTENSION_DIR}/content-understanding" \ "${core_wheel}" "${python_bin}" - <<'PY' +import sys + +from pathlib import Path + +extension_dir = Path(__import__("os").environ["AZURE_EXTENSION_DIR"]) / "content-understanding" +sys.path.insert(0, str(extension_dir)) + import azure.ai # Simulate Azure CLI command modules that load this namespace first. +from cu_cli_core import serialization from azure.cli.core import get_default_cli +assert Path(serialization.__file__).is_relative_to(extension_dir) +assert hasattr(serialization, "render_llm_input") raise SystemExit(get_default_cli().invoke(["cu", "--help"])) PY From c07f509ddc30ada11935887f4830b8d5498c5830 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang Date: Mon, 14 Sep 2026 12:08:15 -0700 Subject: [PATCH 06/16] Clarify extension path checks --- cu-cli/scripts/validate_extension_azdev.sh | 3 ++- cu-cli/scripts/validate_extension_wheel.sh | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cu-cli/scripts/validate_extension_azdev.sh b/cu-cli/scripts/validate_extension_azdev.sh index 0e3476d..49fef39 100644 --- a/cu-cli/scripts/validate_extension_azdev.sh +++ b/cu-cli/scripts/validate_extension_azdev.sh @@ -55,11 +55,12 @@ python -m pip install --disable-pip-version-check --quiet \ --target "${AZURE_EXTENSION_DIR}/content-understanding" \ "${core_wheel}" python - <<'PY' +import os import sys from pathlib import Path -extension_dir = Path(__import__("os").environ["AZURE_EXTENSION_DIR"]) / "content-understanding" +extension_dir = Path(os.environ["AZURE_EXTENSION_DIR"]) / "content-understanding" sys.path.insert(0, str(extension_dir)) from cu_cli_core import serialization diff --git a/cu-cli/scripts/validate_extension_wheel.sh b/cu-cli/scripts/validate_extension_wheel.sh index bc258c9..996618b 100644 --- a/cu-cli/scripts/validate_extension_wheel.sh +++ b/cu-cli/scripts/validate_extension_wheel.sh @@ -41,11 +41,12 @@ export PIP_FIND_LINKS="$(dirname "${core_wheel}")" "${core_wheel}" "${python_bin}" - <<'PY' +import os import sys from pathlib import Path -extension_dir = Path(__import__("os").environ["AZURE_EXTENSION_DIR"]) / "content-understanding" +extension_dir = Path(os.environ["AZURE_EXTENSION_DIR"]) / "content-understanding" sys.path.insert(0, str(extension_dir)) import azure.ai # Simulate Azure CLI command modules that load this namespace first. From a62ffe9fd071a18699139e98f89fed67c60a10bc Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang Date: Mon, 14 Sep 2026 12:15:28 -0700 Subject: [PATCH 07/16] Remove unrelated analyzer copy aliases --- cu-cli/packages/core/src/cu_cli_core/command_spec.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/cu-cli/packages/core/src/cu_cli_core/command_spec.py b/cu-cli/packages/core/src/cu_cli_core/command_spec.py index 499c87f..b3fa4f8 100644 --- a/cu-cli/packages/core/src/cu_cli_core/command_spec.py +++ b/cu-cli/packages/core/src/cu_cli_core/command_spec.py @@ -622,7 +622,6 @@ def _profile_name_arguments(option_help: str) -> tuple[ArgumentSpec, ...]: ), ArgumentSpec( "--destination-subscription", - aliases=("-u",), field="destination_subscription", parser_name="destination_subscription", help=( @@ -632,7 +631,6 @@ def _profile_name_arguments(option_help: str) -> tuple[ArgumentSpec, ...]: ), ArgumentSpec( "--destination-resource-group", - aliases=("-g",), field="destination_resource_group", parser_name="destination_resource_group", help="Resource group used for destination discovery.", From 4c2995d70cacac94cd032deebe5e4bcd38bb471c Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang Date: Mon, 14 Sep 2026 12:40:28 -0700 Subject: [PATCH 08/16] Keep extension linter changes scoped --- cu-cli/packages/standalone/tests/unit/test_client.py | 12 +++++++++++- cu-cli/scripts/extension_linter_exclusions.yml | 7 +++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/cu-cli/packages/standalone/tests/unit/test_client.py b/cu-cli/packages/standalone/tests/unit/test_client.py index a4e64b2..2c031b6 100644 --- a/cu-cli/packages/standalone/tests/unit/test_client.py +++ b/cu-cli/packages/standalone/tests/unit/test_client.py @@ -1,7 +1,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -"""Credential-hygiene warnings emitted by ``build_client``.""" +"""Credential-hygiene warnings emitted by ``build_client``. + +Regression coverage for an ``--api-key`` value on argv leaking via +``ps``/shell history, and ``--api-key`` being silently ignored when +``--entra`` also given). +""" from __future__ import annotations @@ -38,6 +43,7 @@ def test_build_client_warns_when_api_key_combined_with_entra(capsys): def test_build_client_silent_without_argv_api_key(capsys): + # A key sourced from config (not argv) must not trigger the warning. profile = Profile( endpoint="https://x.services.ai.azure.com/", auth_mode="key", @@ -69,6 +75,8 @@ def test_build_client_rejects_malformed_endpoint_before_core_factory(monkeypatch def test_build_client_honors_telemetry_opt_out(monkeypatch): + # Opt-out flows all the way to the SDK client as an empty User-Agent prefix + # (azure-core then sends only its standard azsdk moniker, no cu-cli marker). captured: dict = {} monkeypatch.setattr( "cu_cli.client.build_content_understanding_client", @@ -77,6 +85,7 @@ def test_build_client_honors_telemetry_opt_out(monkeypatch): monkeypatch.setenv("CU_TELEMETRY", "off") build_client(Profile(endpoint="https://x.services.ai.azure.com/")) assert captured["user_agent"] == "" + assert "cu-cli" not in captured["user_agent"] def test_build_client_sends_marker_when_telemetry_on(monkeypatch): @@ -85,6 +94,7 @@ def test_build_client_sends_marker_when_telemetry_on(monkeypatch): "cu_cli.client.build_content_understanding_client", lambda **kwargs: captured.update(kwargs), ) + # CU_* env is stripped by the isolate fixture -> telemetry on by default. build_client(Profile(endpoint="https://x.services.ai.azure.com/")) assert captured["user_agent"].startswith("cu-cli/") diff --git a/cu-cli/scripts/extension_linter_exclusions.yml b/cu-cli/scripts/extension_linter_exclusions.yml index bdc5d1b..0bf7134 100644 --- a/cu-cli/scripts/extension_linter_exclusions.yml +++ b/cu-cli/scripts/extension_linter_exclusions.yml @@ -1,4 +1,3 @@ - # These source/destination qualifiers are required because analyzer copy can # operate across two Azure resources. Keep these exclusions synchronized with # the Azure CLI extension index PR. @@ -6,7 +5,11 @@ cu analyzer copy: parameters: destination_resource_group: rule_exclusions: + - option_length_too_long - parameter_should_not_end_in_resource_group + destination_subscription: + rule_exclusions: + - option_length_too_long source_resource_group: rule_exclusions: - - parameter_should_not_end_in_resource_group \ No newline at end of file + - parameter_should_not_end_in_resource_group From 3c31599c2f384cff7e835518801f649fc98d3da6 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang Date: Mon, 14 Sep 2026 13:28:40 -0700 Subject: [PATCH 09/16] Add upstream extension style validation --- .github/workflows/ci.yml | 9 +++- .github/workflows/release.yml | 1 + cu-cli/CONTRIBUTING.md | 3 +- .../azext_content_understanding/__init__.py | 2 +- .../azext_content_understanding/_analysis.py | 2 +- .../azext_content_understanding/_analyzers.py | 2 +- .../azext_content_understanding/_defaults.py | 2 +- .../_diagnostics.py | 2 +- .../_infra_models.py | 1 + .../azext_content_understanding/_io.py | 2 +- .../azext_content_understanding/_params.py | 1 + .../azext_content_understanding/_profiles.py | 2 +- .../azext_content_understanding/_resources.py | 2 +- cu-cli/scripts/validate_extension_azdev.sh | 47 +++++++++++++++++-- 14 files changed, 64 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 533c42f..67164df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,16 +84,20 @@ jobs: run: bash cu-cli/scripts/ci.sh extension-wheel-smoke: - name: extension wheel smoke test (ubuntu-latest, py3.12) + name: extension wheel smoke test (ubuntu-latest, py${{ matrix.python-version }}) needs: changes if: needs.changes.outputs.cu-cli == 'true' && (github.event_name == 'pull_request' || github.ref == 'refs/heads/main') runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} cache: pip cache-dependency-path: | cu-cli/packages/core/pyproject.toml @@ -138,6 +142,7 @@ jobs: bash scripts/validate_extension_azdev.sh packages/azure-cli-extension/dist/content_understanding-*.whl packages/core/dist/cu_cli_core-*.whl + packages/azure-cli-extension dynamic-hitl-test: name: dynamic HITL calibration tests (ubuntu-latest, py3.14) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba7772d..bcccb4f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -117,6 +117,7 @@ jobs: bash scripts/validate_extension_azdev.sh dist/content_understanding-*.whl packages/core/dist/cu_cli_core-*.whl + packages/azure-cli-extension - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dist-${{ inputs.target }}-${{ inputs.package }}-${{ inputs.version }} diff --git a/cu-cli/CONTRIBUTING.md b/cu-cli/CONTRIBUTING.md index d961a9d..5987773 100644 --- a/cu-cli/CONTRIBUTING.md +++ b/cu-cli/CONTRIBUTING.md @@ -66,7 +66,8 @@ then runs the same pinned `azdev` wheel linter used by their pipeline: ```bash bash scripts/validate_extension_azdev.sh \ packages/azure-cli-extension/dist/content_understanding-*.whl \ - packages/core/dist/cu_cli_core-*.whl + packages/core/dist/cu_cli_core-*.whl \ + packages/azure-cli-extension ``` ## Running checks diff --git a/cu-cli/packages/azure-cli-extension/azext_content_understanding/__init__.py b/cu-cli/packages/azure-cli-extension/azext_content_understanding/__init__.py index 9766e35..a352ca9 100644 --- a/cu-cli/packages/azure-cli-extension/azext_content_understanding/__init__.py +++ b/cu-cli/packages/azure-cli-extension/azext_content_understanding/__init__.py @@ -9,7 +9,7 @@ import azure.ai from azure.cli.core import AzCommandsLoader -from ._help import helps as helps +from ._help import helps as helps # pylint: disable=unused-import,useless-import-alias __version__ = version("content-understanding") diff --git a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_analysis.py b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_analysis.py index 13122fc..6460c3d 100644 --- a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_analysis.py +++ b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_analysis.py @@ -230,4 +230,4 @@ def persist(outcome: Any) -> None: f"{len(batch.failures)} analysis job(s) failed.", hint="Use --report-file to retain per-input status without exposing SAS tokens.", ) - return streamed if len(jobs) == 1 and jobs[0].out_path is None else records \ No newline at end of file + return streamed if len(jobs) == 1 and jobs[0].out_path is None else records diff --git a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_analyzers.py b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_analyzers.py index c1abe91..cef48fe 100644 --- a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_analyzers.py +++ b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_analyzers.py @@ -289,4 +289,4 @@ def copy_analyzer(cmd: Any, **values: Any) -> Any: target_region=destination.resource.region if cross_resource else None, source_analyzer=source_analyzer, target_cli_options=target_options, - ) \ No newline at end of file + ) diff --git a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_defaults.py b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_defaults.py index 0b9da2d..72cb457 100644 --- a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_defaults.py +++ b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_defaults.py @@ -38,4 +38,4 @@ def set_defaults(cmd: Any, **values: Any) -> Any: updated, _ = resolve_identifier(DEFAULTS_SET.operation)( _client(cmd, values), desired, replace=request.replace ) - return updated \ No newline at end of file + return updated diff --git a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_diagnostics.py b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_diagnostics.py index a12b9e5..91e2e0b 100644 --- a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_diagnostics.py +++ b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_diagnostics.py @@ -44,4 +44,4 @@ def doctor(cmd: Any, **values: Any) -> dict[str, Any]: def list_environment_variables(_cmd: Any, **values: Any) -> Any: request = build_request(ENV_VAR_LIST, values) del request - return resolve_identifier(ENV_VAR_LIST.operation)() \ No newline at end of file + return resolve_identifier(ENV_VAR_LIST.operation)() diff --git a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_infra_models.py b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_infra_models.py index 01d3897..c738829 100644 --- a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_infra_models.py +++ b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_infra_models.py @@ -33,6 +33,7 @@ ) from ._resources import _management_client + def _value(value: Any, snake: str, camel: str | None = None) -> Any: if isinstance(value, dict): return value.get(snake, value.get(camel or snake)) diff --git a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_io.py b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_io.py index 887c981..7b92ee2 100644 --- a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_io.py +++ b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_io.py @@ -53,4 +53,4 @@ def write_text(path: Path, content: str, *, overwrite: bool) -> None: def write_json(path: Path, value: Any, *, overwrite: bool) -> None: payload = json.dumps(to_plain_value(value), indent=2, ensure_ascii=False) - write_text(path, payload + "\n", overwrite=overwrite) \ No newline at end of file + write_text(path, payload + "\n", overwrite=overwrite) diff --git a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_params.py b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_params.py index 1c65c13..723da04 100644 --- a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_params.py +++ b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_params.py @@ -23,6 +23,7 @@ } +# pylint: disable=too-few-public-methods class _ExplicitArgumentContext: """Register arguments absent from variadic command wrapper signatures.""" diff --git a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_profiles.py b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_profiles.py index 4fd3c62..81f8cf8 100644 --- a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_profiles.py +++ b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_profiles.py @@ -158,4 +158,4 @@ def sync_profile_defaults(cmd: Any, **values: Any) -> dict[str, Any]: "name": target, "path": str(path), "modelDeployments": models, - } \ No newline at end of file + } diff --git a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_resources.py b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_resources.py index b25abd4..423fab5 100644 --- a/cu-cli/packages/azure-cli-extension/azext_content_understanding/_resources.py +++ b/cu-cli/packages/azure-cli-extension/azext_content_understanding/_resources.py @@ -204,4 +204,4 @@ def list_model_deployments(cmd: Any, endpoint: str) -> list[dict[str, Any]]: "capacity": getattr(sku, "capacity", None), } ) - return sorted(result, key=lambda item: item["name"].casefold()) \ No newline at end of file + return sorted(result, key=lambda item: item["name"].casefold()) diff --git a/cu-cli/scripts/validate_extension_azdev.sh b/cu-cli/scripts/validate_extension_azdev.sh index 49fef39..18bb28a 100644 --- a/cu-cli/scripts/validate_extension_azdev.sh +++ b/cu-cli/scripts/validate_extension_azdev.sh @@ -4,13 +4,14 @@ set -euo pipefail -if [[ "$#" -ne 2 ]]; then - echo "Usage: $0 " >&2 +if [[ "$#" -ne 3 ]]; then + echo "Usage: $0 " >&2 exit 2 fi extension_wheel="$(realpath "$1")" core_wheel="$(realpath "$2")" +extension_source="$(realpath "$3")" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" temp_root="$(mktemp -d)" trap 'rm -rf "${temp_root}"' EXIT @@ -75,4 +76,44 @@ PY --min-severity medium ) -echo "Validated $(basename "${extension_wheel}") with the Azure CLI extension linter." \ No newline at end of file +rm -rf "${AZURE_EXTENSION_DIR}/content-understanding" +extension_checkout="${temp_root}/azure-cli-extensions/src/content-understanding" +mkdir -p "${extension_checkout}" +cp -R "${extension_source}/." "${extension_checkout}/" +rm -rf \ + "${extension_checkout}/build" \ + "${extension_checkout}/dist" \ + "${extension_checkout}/.pytest_cache" \ + "${extension_checkout}"/*.egg-info +find "${extension_checkout}" -type d -name __pycache__ -prune -exec rm -rf {} + +# azdev 0.2.13 discovers source extensions by setup.py even though its build +# pipeline supports pyproject.toml. Add a temporary shim only in the checkout. +cat > "${extension_checkout}/setup.py" <<'PY' +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +from setuptools import setup + +setup() +PY +( + cd "${temp_root}/azure-cli-extensions" + azdev extension add content-understanding +) +python -m pip install --disable-pip-version-check --quiet \ + --no-deps \ + --upgrade \ + --force-reinstall \ + "${core_wheel}" +python -m pip install --disable-pip-version-check --quiet \ + --no-deps \ + --upgrade \ + --force-reinstall \ + --target "${AZURE_EXTENSION_DIR}/content-understanding" \ + "${core_wheel}" +( + cd "${temp_root}/azure-cli-extensions" + azdev style content-understanding +) + +echo "Validated $(basename "${extension_wheel}") with Azure CLI extension lint and style checks." From 163878cd1aeb35ecbc9e7299aaf7e22f01395365 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang Date: Fri, 11 Sep 2026 18:37:33 -0700 Subject: [PATCH 10/16] Fix extension GitHub release notes --- .github/workflows/release.yml | 3 +- .../tests/unit/test_validate_release.py | 39 ++++++++++++++ cu-cli/scripts/validate_release.py | 51 +++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bcccb4f..0d5a43e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,6 +74,7 @@ jobs: --actual-commit "${GITHUB_SHA}" \ --repository "${GITHUB_REPOSITORY}" \ --ref "${GITHUB_REF}" \ + --release-notes-output dist/release-notes.md \ ${{ (inputs.package == 'cli' || inputs.package == 'extension') && '--verify-core-on-index' || '' }} - name: Test exact release commit run: bash scripts/ci.sh @@ -186,5 +187,5 @@ jobs: --repo "${GITHUB_REPOSITORY}" --target "${RELEASE_COMMIT}" --title "Azure Content Understanding CLI extension ${RELEASE_VERSION}" - --notes "Preview release of the Azure Content Understanding CLI extension." + --notes-file dist/release-notes.md --prerelease diff --git a/cu-cli/packages/standalone/tests/unit/test_validate_release.py b/cu-cli/packages/standalone/tests/unit/test_validate_release.py index 8e990b6..441ef7f 100644 --- a/cu-cli/packages/standalone/tests/unit/test_validate_release.py +++ b/cu-cli/packages/standalone/tests/unit/test_validate_release.py @@ -83,6 +83,17 @@ def _write_release_tree( f"# Release History\n\n## {core_version} ({core_status})\n", encoding="utf-8", ) + (root / "packages/azure-cli-extension/HISTORY.rst").write_text( + """Release History +=============== + +0.1.0b1 (2026-09-04) ++++++++++++++++++++++ + +* Add the preview ``az cu`` command group. +""", + encoding="utf-8", + ) def _validate(root: Path, **overrides: object) -> None: @@ -120,6 +131,34 @@ def test_validates_extension_release_metadata(tmp_path: Path) -> None: _validate(tmp_path, package="extension", expected_version="0.1.0b1") +def test_writes_extension_release_notes_as_markdown(tmp_path: Path) -> None: + _write_release_tree(tmp_path) + output = tmp_path / "dist/release-notes.md" + + _validate( + tmp_path, + package="extension", + expected_version="0.1.0b1", + release_notes_output=output, + ) + + assert output.read_text(encoding="utf-8") == ( + "## 0.1.0b1 (2026-09-04)\n\n" + "* Add the preview `az cu` command group.\n" + ) + + +def test_extension_requires_versioned_release_notes(tmp_path: Path) -> None: + _write_release_tree(tmp_path) + (tmp_path / "packages/azure-cli-extension/HISTORY.rst").write_text( + "Release History\n===============\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="must contain release notes"): + _validate(tmp_path, package="extension", expected_version="0.1.0b1") + + def test_rejects_duplicate_frontend_template(tmp_path: Path) -> None: _write_release_tree(tmp_path) duplicate = tmp_path / "packages/standalone/src/cu_cli/resources/azd_template" diff --git a/cu-cli/scripts/validate_release.py b/cu-cli/scripts/validate_release.py index 2751ac7..ff12937 100644 --- a/cu-cli/scripts/validate_release.py +++ b/cu-cli/scripts/validate_release.py @@ -151,6 +151,46 @@ def validate_changelog(path: Path, version: str) -> None: raise ValueError(f"{path} release date is invalid: {heading.group(1)}") from error +def extension_release_notes(path: Path, version: str) -> str: + """Return one extension release section as GitHub-flavored Markdown.""" + lines = path.read_text(encoding="utf-8").splitlines() + heading_pattern = re.compile(rf"^{re.escape(version)} \(([^)]+)\)$") + heading_index = next( + (index for index, line in enumerate(lines) if heading_pattern.fullmatch(line)), + None, + ) + if heading_index is None: + raise ValueError(f"{path} must contain release notes for {version}") + + heading = lines[heading_index] + release_date = heading_pattern.fullmatch(heading) + assert release_date is not None + try: + date.fromisoformat(release_date.group(1)) + except ValueError as error: + raise ValueError( + f"{path} release date is invalid: {release_date.group(1)}" + ) from error + + underline_index = heading_index + 1 + if underline_index >= len(lines) or re.fullmatch(r"\++", lines[underline_index]) is None: + raise ValueError(f"{path} must use an RST section heading for {version}") + + end_index = len(lines) + for index in range(underline_index + 2, len(lines) - 1): + if lines[index] and re.fullmatch(r"\++", lines[index + 1]): + end_index = index + break + + body = "\n".join(lines[underline_index + 1 : end_index]).strip() + if not body: + raise ValueError(f"{path} release notes for {version} must not be empty") + + # HISTORY.rst uses RST inline-code markers; GitHub release bodies use Markdown. + markdown_body = re.sub(r"``([^`]+)``", r"`\1`", body).expandtabs(4) + return f"## {heading}\n\n{markdown_body}\n" + + def verify_package_release( project_name: str, version: str, @@ -177,6 +217,7 @@ def validate_release( repository: str, ref: str, verify_core_on_index: bool, + release_notes_output: Path | None = None, ) -> None: validate_request_context( expected_commit=expected_commit, @@ -198,6 +239,14 @@ def validate_release( core_version = validate_frontend_metadata(root, package) if package == "cli": validate_changelog(root / "CHANGELOG.md", actual_version) + if package == "extension": + notes = extension_release_notes( + root / "packages/azure-cli-extension/HISTORY.rst", + actual_version, + ) + if release_notes_output is not None: + release_notes_output.parent.mkdir(parents=True, exist_ok=True) + release_notes_output.write_text(notes, encoding="utf-8") if verify_core_on_index: verify_package_release("cu-cli-core", core_version, index) @@ -214,6 +263,7 @@ def main() -> int: parser.add_argument("--repository", required=True) parser.add_argument("--ref", required=True) parser.add_argument("--verify-core-on-index", action="store_true") + parser.add_argument("--release-notes-output", type=Path) args = parser.parse_args() try: @@ -227,6 +277,7 @@ def main() -> int: repository=args.repository, ref=args.ref, verify_core_on_index=args.verify_core_on_index, + release_notes_output=args.release_notes_output, ) except (OSError, ValueError) as error: parser.error(str(error)) From 103a62f19e5fc479164647a79b68bf8c9573f01f Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang Date: Fri, 11 Sep 2026 19:04:47 -0700 Subject: [PATCH 11/16] Preserve long paths in usage output --- .../standalone/src/cu_cli/commands/analyze.py | 5 ++++- cu-cli/packages/standalone/tests/unit/test_cli.py | 11 +++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/cu-cli/packages/standalone/src/cu_cli/commands/analyze.py b/cu-cli/packages/standalone/src/cu_cli/commands/analyze.py index 4e07f55..de89a5c 100644 --- a/cu-cli/packages/standalone/src/cu_cli/commands/analyze.py +++ b/cu-cli/packages/standalone/src/cu_cli/commands/analyze.py @@ -178,7 +178,10 @@ def _write_markdown_stdout(result, *, input_url: str) -> None: def _print_usage(usage, *, input_ref: str) -> None: """Render request usage to stderr without changing data written to stdout.""" console.print("\n") - console.print(f"[bold cyan]Usage:[/bold cyan] {_esc(input_ref)}") + console.print( + f"[bold cyan]Usage:[/bold cyan] {_esc(input_ref)}", + soft_wrap=True, + ) if usage is None: console.print("[dim]usage details were not returned by the service.[/dim]") return diff --git a/cu-cli/packages/standalone/tests/unit/test_cli.py b/cu-cli/packages/standalone/tests/unit/test_cli.py index e5231a5..611a0e9 100644 --- a/cu-cli/packages/standalone/tests/unit/test_cli.py +++ b/cu-cli/packages/standalone/tests/unit/test_cli.py @@ -1626,12 +1626,19 @@ def test_analyze_usage_prints_inline_usage_to_stderr_without_changing_json(monke def test_analyze_usage_title_is_colored(monkeypatch): printed = [] - monkeypatch.setattr(analyze_module.console, "print", printed.append) + monkeypatch.setattr( + analyze_module.console, + "print", + lambda value, **kwargs: printed.append((value, kwargs)), + ) monkeypatch.setattr(analyze_module.console, "print_json", lambda **_kwargs: None) analyze_module._print_usage({}, input_ref="sample.pdf") - assert printed == ["\n", "[bold cyan]Usage:[/bold cyan] sample.pdf"] + assert printed == [ + ("\n", {}), + ("[bold cyan]Usage:[/bold cyan] sample.pdf", {"soft_wrap": True}), + ] def test_analyze_usage_prints_lro_usage_for_each_batch_input(monkeypatch): From 4a42fffb056ff8e158ece5b9b6e235db1c7b08a5 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang Date: Mon, 14 Sep 2026 13:46:44 -0700 Subject: [PATCH 12/16] Prepare core and CLI beta 3 releases --- cu-cli/CHANGELOG.md | 9 +++++++++ cu-cli/packages/azure-cli-extension/HISTORY.rst | 2 +- cu-cli/packages/azure-cli-extension/pyproject.toml | 2 +- cu-cli/packages/core/CHANGELOG.md | 7 +++++++ cu-cli/packages/core/pyproject.toml | 2 +- cu-cli/packages/standalone/pyproject.toml | 4 ++-- 6 files changed, 21 insertions(+), 5 deletions(-) diff --git a/cu-cli/CHANGELOG.md b/cu-cli/CHANGELOG.md index 186673c..15e7e75 100644 --- a/cu-cli/CHANGELOG.md +++ b/cu-cli/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +## 0.1.0b3 (2026-09-14) + +### Bugs Fixed + +- Routed Content Understanding SDK integration through `cu-cli-core` so the + standalone CLI and Azure CLI extension use one compatible SDK boundary. +- Preserved long input paths in analysis usage output without inserting hard + line breaks. + ## 0.1.0b2 (2026-09-11) ### Features Added diff --git a/cu-cli/packages/azure-cli-extension/HISTORY.rst b/cu-cli/packages/azure-cli-extension/HISTORY.rst index 3a2fcdd..3b8f6a2 100644 --- a/cu-cli/packages/azure-cli-extension/HISTORY.rst +++ b/cu-cli/packages/azure-cli-extension/HISTORY.rst @@ -5,7 +5,7 @@ Release History +++++++++++++++++++++ * Correct loading of the Azure AI Content Understanding SDK installed through - the shared ``cu-cli-core`` package in isolated Azure CLI environments. + the shared ``cu-cli-core`` 0.1.0b3 package in isolated Azure CLI environments. 0.1.0b1 (2026-09-11) +++++++++++++++++++++ diff --git a/cu-cli/packages/azure-cli-extension/pyproject.toml b/cu-cli/packages/azure-cli-extension/pyproject.toml index 5b57247..b61de71 100644 --- a/cu-cli/packages/azure-cli-extension/pyproject.toml +++ b/cu-cli/packages/azure-cli-extension/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", ] dependencies = [ - "cu-cli-core>=0.1.0b2,<0.2.0", + "cu-cli-core>=0.1.0b3,<0.2.0", "azure-mgmt-cognitiveservices>=13.6.0,<14.0.0", ] diff --git a/cu-cli/packages/core/CHANGELOG.md b/cu-cli/packages/core/CHANGELOG.md index 0455f9a..6507f22 100644 --- a/cu-cli/packages/core/CHANGELOG.md +++ b/cu-cli/packages/core/CHANGELOG.md @@ -1,5 +1,12 @@ # Release History +## 0.1.0b3 (2026-09-14) + +### Features Added + +- Added shared LLM-ready analysis result rendering for official Content + Understanding command-line frontends. + ## 0.1.0b2 (2026-09-11) ### Features Added diff --git a/cu-cli/packages/core/pyproject.toml b/cu-cli/packages/core/pyproject.toml index 7d0a145..db23999 100644 --- a/cu-cli/packages/core/pyproject.toml +++ b/cu-cli/packages/core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "cu-cli-core" -version = "0.1.0b2" +version = "0.1.0b3" description = "Framework-neutral command contracts and operations for Azure Content Understanding CLIs." readme = "README.md" requires-python = ">=3.10" diff --git a/cu-cli/packages/standalone/pyproject.toml b/cu-cli/packages/standalone/pyproject.toml index 06caa0d..c0a2bb4 100644 --- a/cu-cli/packages/standalone/pyproject.toml +++ b/cu-cli/packages/standalone/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "cu-cli" -version = "0.1.0b2" +version = "0.1.0b3" description = "Run Azure Content Understanding prebuilt analyzers, and/or author, validate, and run custom analyzers from the terminal — advanced document layout, industry-leading OCR, and grounded field extraction with confidence, all backed by Azure Content Understanding." readme = "README.md" requires-python = ">=3.10" @@ -27,7 +27,7 @@ classifiers = [ "Topic :: Text Processing", ] dependencies = [ - "cu-cli-core>=0.1.0b2,<0.2.0", + "cu-cli-core>=0.1.0b3,<0.2.0", "azure-identity>=1.19", # Management-plane clients used by `cu analyzer copy` to resolve # Foundry endpoint URLs / account names / ARM IDs to canonical From b3c2a1fc7fbfdd4edd929c36a0d1af8fe168575e Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang Date: Mon, 14 Sep 2026 13:53:41 -0700 Subject: [PATCH 13/16] Make CLI release notes user-facing --- cu-cli/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cu-cli/CHANGELOG.md b/cu-cli/CHANGELOG.md index 15e7e75..879311f 100644 --- a/cu-cli/CHANGELOG.md +++ b/cu-cli/CHANGELOG.md @@ -6,8 +6,8 @@ ### Bugs Fixed -- Routed Content Understanding SDK integration through `cu-cli-core` so the - standalone CLI and Azure CLI extension use one compatible SDK boundary. +- Fixed LLM-ready Markdown output compatibility with the installed Content + Understanding SDK. - Preserved long input paths in analysis usage output without inserting hard line breaks. From f4546ad29da6ac0da30a1d745433bab03622218f Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang Date: Mon, 14 Sep 2026 14:59:52 -0700 Subject: [PATCH 14/16] Document CI and release checks --- .github/workflows/ci.yml | 12 ++++++++++++ .github/workflows/release.yml | 10 ++++++++++ cu-cli/CONTRIBUTING.md | 4 ++++ 3 files changed, 26 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67164df..e64c726 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ permissions: contents: read jobs: + # Detect which product areas changed so unrelated suites can be skipped while + # workflow edits and ambiguous Git history still trigger every suite. changes: name: detect changed areas runs-on: ubuntu-latest @@ -63,6 +65,8 @@ jobs: echo "${area//_/-}=${matched}" >> "${GITHUB_OUTPUT}" done + # Give non-main branch pushes fast CU CLI feedback without duplicating the + # full pull-request matrix that runs for the same commit. quick-test: name: quick test (ubuntu-latest, py3.12) needs: changes @@ -83,6 +87,8 @@ jobs: - name: Run CI run: bash cu-cli/scripts/ci.sh + # Build and install the extension/core wheel pair in an isolated Azure CLI + # directory, catching missing dependencies and extension loading failures. extension-wheel-smoke: name: extension wheel smoke test (ubuntu-latest, py${{ matrix.python-version }}) needs: changes @@ -115,6 +121,8 @@ jobs: packages/azure-cli-extension/dist/content_understanding-*.whl packages/core/dist/cu_cli_core-*.whl + # Run the upstream Azure CLI command linter and source style checks, catching + # CLI convention violations before the extension is submitted upstream. extension-azdev-linter: name: extension azdev linter (ubuntu-latest, py3.14) needs: changes @@ -144,6 +152,8 @@ jobs: packages/core/dist/cu_cli_core-*.whl packages/azure-cli-extension + # Exercise calibration behavior only when Dynamic HITL changes, independently + # from CU CLI packaging and command tests. dynamic-hitl-test: name: dynamic HITL calibration tests (ubuntu-latest, py3.14) needs: changes @@ -167,6 +177,8 @@ jobs: - name: Run calibration tests run: python -m pytest test_calibration.py -q + # Run the complete CU CLI suite across supported Python versions and operating + # systems, catching platform-specific behavior and packaging regressions. full-test: name: full test (${{ matrix.os }}, py${{ matrix.python-version }}) needs: changes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d5a43e..cd07e6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,6 +50,8 @@ jobs: - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" + # Reject mismatched versions, commits, targets, changelogs, dependency + # bounds, or release notes before building immutable artifacts. - name: Validate release request env: RELEASE_TARGET: ${{ inputs.target }} @@ -76,8 +78,11 @@ jobs: --ref "${GITHUB_REF}" \ --release-notes-output dist/release-notes.md \ ${{ (inputs.package == 'cli' || inputs.package == 'extension') && '--verify-core-on-index' || '' }} + # Re-run the full local suite against the exact requested release commit. - name: Test exact release commit run: bash scripts/ci.sh + # Build only the selected distribution and verify its package metadata; + # extension releases also receive a checksum artifact. - name: Build selected distribution env: PACKAGE: ${{ inputs.package }} @@ -101,17 +106,22 @@ jobs: if [[ "${PACKAGE}" == "extension" ]]; then (cd dist && sha256sum *.whl > "content-understanding-${{ inputs.version }}.sha256") fi + # Install the candidate extension/core wheels in an isolated Azure CLI + # directory to catch unresolved dependencies and command loading failures. - name: Test clean extension installation if: inputs.package == 'extension' run: >- bash scripts/validate_extension_wheel.sh dist/content_understanding-*.whl packages/core/dist/cu_cli_core-*.whl + # Match the Python version used for the upstream Azure CLI tooling checks. - name: Set up Azure CLI validation Python if: inputs.package == 'extension' uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.14" + # Catch Azure CLI command convention and source style violations using the + # same pinned azdev checks exercised by CI. - name: Run Azure CLI extension linter if: inputs.package == 'extension' run: >- diff --git a/cu-cli/CONTRIBUTING.md b/cu-cli/CONTRIBUTING.md index 5987773..88516e5 100644 --- a/cu-cli/CONTRIBUTING.md +++ b/cu-cli/CONTRIBUTING.md @@ -70,6 +70,10 @@ bash scripts/validate_extension_azdev.sh \ packages/azure-cli-extension ``` +The clean wheel installation and pinned `azdev` checks run automatically in +both `.github/workflows/ci.yml` for pull requests and +`.github/workflows/release.yml` before an extension artifact is published. + ## Running checks ```bash From b6fdb08a0be7321b1f0bb1efa80713b651faad59 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang Date: Mon, 14 Sep 2026 15:18:28 -0700 Subject: [PATCH 15/16] Condense CLI beta 3 release notes --- cu-cli/CHANGELOG.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cu-cli/CHANGELOG.md b/cu-cli/CHANGELOG.md index 879311f..21fb605 100644 --- a/cu-cli/CHANGELOG.md +++ b/cu-cli/CHANGELOG.md @@ -6,10 +6,7 @@ ### Bugs Fixed -- Fixed LLM-ready Markdown output compatibility with the installed Content - Understanding SDK. -- Preserved long input paths in analysis usage output without inserting hard - line breaks. +- Minor bug fixes. ## 0.1.0b2 (2026-09-11) From 12a62c1d81473aac400f8588dc074ff26198c2e2 Mon Sep 17 00:00:00 2001 From: Chien Yuan Chang Date: Mon, 14 Sep 2026 15:30:52 -0700 Subject: [PATCH 16/16] Rely on native Azure namespace loading --- .../azext_content_understanding/__init__.py | 10 ---------- cu-cli/scripts/validate_extension_wheel.sh | 14 +++++++++----- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/cu-cli/packages/azure-cli-extension/azext_content_understanding/__init__.py b/cu-cli/packages/azure-cli-extension/azext_content_understanding/__init__.py index a352ca9..20300b7 100644 --- a/cu-cli/packages/azure-cli-extension/azext_content_understanding/__init__.py +++ b/cu-cli/packages/azure-cli-extension/azext_content_understanding/__init__.py @@ -4,23 +4,13 @@ """Azure CLI command loader for the Content Understanding extension.""" from importlib.metadata import version -from pathlib import Path -import azure.ai from azure.cli.core import AzCommandsLoader from ._help import helps as helps # pylint: disable=unused-import,useless-import-alias __version__ = version("content-understanding") -# Azure CLI adds an extension's ``azure`` directory to the namespace package, -# but currently does not do the same for an already imported ``azure.ai``. -# azdev loads that namespace before loading extensions, so expose SDKs bundled -# with this extension explicitly. -_azure_ai_path = str(Path(__file__).resolve().parent.parent / "azure" / "ai") -if Path(_azure_ai_path).is_dir() and _azure_ai_path not in azure.ai.__path__: - azure.ai.__path__.append(_azure_ai_path) - class ContentUnderstandingCommandsLoader(AzCommandsLoader): """Load the native ``az cu`` command surface.""" diff --git a/cu-cli/scripts/validate_extension_wheel.sh b/cu-cli/scripts/validate_extension_wheel.sh index 996618b..85cdad2 100644 --- a/cu-cli/scripts/validate_extension_wheel.sh +++ b/cu-cli/scripts/validate_extension_wheel.sh @@ -42,20 +42,24 @@ export PIP_FIND_LINKS="$(dirname "${core_wheel}")" "${python_bin}" - <<'PY' import os -import sys +from importlib import import_module from pathlib import Path extension_dir = Path(os.environ["AZURE_EXTENSION_DIR"]) / "content-understanding" -sys.path.insert(0, str(extension_dir)) - import azure.ai # Simulate Azure CLI command modules that load this namespace first. -from cu_cli_core import serialization from azure.cli.core import get_default_cli +result = get_default_cli().invoke(["cu", "--help"]) + +sdk = import_module("azure.ai.contentunderstanding") +serialization = import_module("cu_cli_core.serialization") + assert Path(serialization.__file__).is_relative_to(extension_dir) +assert Path(sdk.__file__).is_relative_to(extension_dir) assert hasattr(serialization, "render_llm_input") -raise SystemExit(get_default_cli().invoke(["cu", "--help"])) +assert hasattr(sdk, "ContentUnderstandingClient") +raise SystemExit(result) PY echo "Validated clean Azure CLI installation of $(basename "${extension_wheel}")."