diff --git a/cu-cli/CHANGELOG.md b/cu-cli/CHANGELOG.md index ae49137..805edc4 100644 --- a/cu-cli/CHANGELOG.md +++ b/cu-cli/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features Added +- Added `cu analyzer update` to change analyzer descriptions and tags without + forwarding schema, configuration, or model fields. - Added HTTPS and Azure Blob SAS URL inputs to `cu analyze` through repeatable `--url` options or standalone positional shortcuts, including URL-based analysis for large audio and video files without downloading them through the CLI. diff --git a/cu-cli/README.md b/cu-cli/README.md index 2c09492..f951eba 100644 --- a/cu-cli/README.md +++ b/cu-cli/README.md @@ -228,6 +228,12 @@ cu analyzer schema create \ # then create the analyzer. cu analyzer create --name invoice_v1 --schema ./invoice-schema.json +# Update mutable metadata without changing the analyzer schema. +cu analyzer update invoice_v1 \ + --description "Extract invoice header and totals" \ + --tag owner=finance \ + --tag environment=production + # Run the analyzer against the sample and summarize whether fields were returned # and any confidence values supplied by the service. This is not an accuracy # benchmark and does not compare the result with labeled ground truth. @@ -236,6 +242,11 @@ cu analyzer test invoice_v1 ./invoice.pdf cu analyze ./invoice.pdf --analyzer invoice_v1 --json ``` +`cu analyzer update` requires `--description`, at least one repeatable +`--tag KEY=VALUE`, or both. Each tag assignment sets or replaces that key while +preserving other existing tags. The command does not accept or update analyzer +schemas, configuration, or model settings. + Schema generation preserves existing files by default. Pass `--force` only when you intentionally want to replace the selected `--output-file`. @@ -250,7 +261,7 @@ Further reading: | Command | Purpose | | --- | --- | | `cu analyze` | Analyze local files or HTTPS URLs and return analyzer results. | -| `cu analyzer` | List, show, create, copy, delete, and test analyzers; create and validate local analyzer schemas. | +| `cu analyzer` | List, show, create, update, copy, delete, and test analyzers; create and validate local analyzer schemas. | | `cu defaults` | Read or configure Content Understanding defaults that map models to deployments. | | `cu profile` | Manage local CU CLI endpoint, authentication, API, and model settings. | | `cu infra generate` | Generate an azd/Bicep project used to provision a Microsoft Foundry resource and configure Content Understanding. Run `azd up` to provision it. | diff --git a/cu-cli/docs/usage-guide.md b/cu-cli/docs/usage-guide.md index 370e450..7bc6796 100644 --- a/cu-cli/docs/usage-guide.md +++ b/cu-cli/docs/usage-guide.md @@ -555,10 +555,22 @@ cu analyzer list # Print one analyzer definition. cu analyzer show invoice_v1 +# Update the description and set two tags. +cu analyzer update invoice_v1 \ + --description "Extract invoice header and totals" \ + --tag owner=finance \ + --tag environment=production + # Delete a custom analyzer after confirmation. cu analyzer delete invoice_v1 ``` +`cu analyzer update` requires `--description`, at least one repeatable +`--tag KEY=VALUE`, or both. Tag assignments set or replace the named keys and +preserve other existing tags. The command sends only mutable metadata and does +not accept `--schema`; analyzer schemas, configuration, and model settings are +never forwarded in its update request. + ### Create a schema Generate a starter schema: 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 a469e4a..26d1b5a 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 @@ -448,6 +448,45 @@ def _profile_name_arguments(option_help: str) -> tuple[ArgumentSpec, ...]: service_options=_SERVICE_OPTIONS, ) +ANALYZER_UPDATE = CommandSpec( + path=("analyzer", "update"), + help="Update an analyzer description or tags.", + operation="cu_cli_core.operations.analyzers#update_analyzer", + request_type="cu_cli_core.contracts#AnalyzerUpdateRequest", + arguments=( + ArgumentSpec( + "--name", + aliases=("-n", "-a"), + field="name", + parser_name="analyzer_name", + help="Analyzer name.", + required=True, + ), + ArgumentSpec( + "ANALYZER_NAME", + field="name", + parser_name="positional_analyzer_name", + help="Standalone positional shortcut for --name.", + classification=SurfaceClassification.STANDALONE_SHORTCUT, + ), + ArgumentSpec( + "--description", + field="description", + parser_name="description", + help="New analyzer description.", + ), + ArgumentSpec( + "--tag", + field="tag_assignments", + parser_name="tag_assignments", + help="Set a tag as KEY=VALUE; repeat for multiple tags.", + repeatable=True, + metavar="KEY=VALUE", + ), + ), + service_options=_SERVICE_OPTIONS, +) + ANALYZER_DELETE = CommandSpec( path=("analyzer", "delete"), help="Delete an analyzer.", @@ -1086,6 +1125,7 @@ def _profile_name_arguments(option_help: str) -> tuple[ArgumentSpec, ...]: ANALYZER_LIST, ANALYZER_SHOW, ANALYZER_CREATE, + ANALYZER_UPDATE, ANALYZER_COPY, ANALYZER_DELETE, ANALYZER_VALIDATE, diff --git a/cu-cli/packages/core/src/cu_cli_core/contracts.py b/cu-cli/packages/core/src/cu_cli_core/contracts.py index dee72bb..1a438d8 100644 --- a/cu-cli/packages/core/src/cu_cli_core/contracts.py +++ b/cu-cli/packages/core/src/cu_cli_core/contracts.py @@ -148,6 +148,42 @@ def __post_init__(self) -> None: object.__setattr__(self, "schema", Path(self.schema)) +@dataclass(frozen=True) +class AnalyzerUpdateRequest: + name: str + description: str | None = None + tag_assignments: tuple[str, ...] = () + + def __post_init__(self) -> None: + normalized = self.name.strip() + if not normalized: + raise ValueError("analyzer name cannot be empty.") + object.__setattr__(self, "name", normalized) + + assignments = tuple(self.tag_assignments) + if self.description is None and not assignments: + raise ValueError("provide at least one metadata change: --description or --tag.") + + keys: set[str] = set() + for assignment in assignments: + key, separator, _ = assignment.partition("=") + normalized_key = key.strip() + if not separator or not normalized_key: + raise ValueError("invalid --tag value; expected KEY=VALUE with a non-empty key.") + if key != normalized_key: + raise ValueError( + "invalid --tag value; tag keys cannot start or end with whitespace." + ) + if key in keys: + raise ValueError(f"duplicate tag key '{key}'.") + keys.add(key) + object.__setattr__(self, "tag_assignments", assignments) + + @property + def tags(self) -> dict[str, str]: + return dict(assignment.split("=", 1) for assignment in self.tag_assignments) + + @dataclass(frozen=True) class AnalyzerDeleteRequest: name: str diff --git a/cu-cli/packages/core/src/cu_cli_core/operations/analyzers.py b/cu-cli/packages/core/src/cu_cli_core/operations/analyzers.py index 992cdc7..befad9b 100644 --- a/cu-cli/packages/core/src/cu_cli_core/operations/analyzers.py +++ b/cu-cli/packages/core/src/cu_cli_core/operations/analyzers.py @@ -5,9 +5,10 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any -from ..errors import ConflictError, NotFoundError, ServiceError +from ..errors import ConflictError, NotFoundError, ServiceError, ValidationError def _value(analyzer: Any, *keys: str) -> str: @@ -72,6 +73,46 @@ def create_analyzer(client: Any, analyzer_id: str, body: dict[str, Any]) -> Any: return result +def update_analyzer( + client: Any, + analyzer_id: str, + *, + description: str | None = None, + tags: Mapping[str, str] | None = None, +) -> Any: + from azure.core.exceptions import ResourceNotFoundError + + patch: dict[str, Any] = {} + if description is not None: + patch["description"] = description + if not patch: + if not tags: + raise ValidationError("provide at least one metadata change: --description or --tag.") + + try: + if tags: + # The service replaces the tags object, so preserve keys that the user did not set. + analyzer = client.get_analyzer(analyzer_id) + existing_tags = ( + analyzer.get("tags") + if isinstance(analyzer, Mapping) + else getattr(analyzer, "tags", None) + ) + if existing_tags is None and hasattr(analyzer, "as_dict"): + value = analyzer.as_dict() + if isinstance(value, dict): + existing_tags = value.get("tags") + patch["tags"] = {**dict(existing_tags or {}), **dict(tags)} + return client.update_analyzer(analyzer_id, patch) + except ResourceNotFoundError as exc: + raise NotFoundError( + f"analyzer '{analyzer_id}' was not found; nothing was updated.", + hint="Check the analyzer name and selected profile or endpoint, then run " + "`cu analyzer list --info`.", + status_code=404, + ) from exc + + def delete_analyzer(client: Any, analyzer_id: str) -> None: from azure.core.exceptions import ResourceNotFoundError diff --git a/cu-cli/packages/core/tests/test_command_spec.py b/cu-cli/packages/core/tests/test_command_spec.py index 0666de6..3603d9e 100644 --- a/cu-cli/packages/core/tests/test_command_spec.py +++ b/cu-cli/packages/core/tests/test_command_spec.py @@ -14,6 +14,7 @@ from cu_cli_core.command_spec import ( ANALYZER_LIST, ANALYZER_SHOW, + ANALYZER_UPDATE, COMMAND_SPECS, DEFAULTS_SET, PROFILE_COPY, @@ -26,7 +27,7 @@ get_command_spec, resolve_identifier, ) -from cu_cli_core.contracts import AnalyzerShowRequest +from cu_cli_core.contracts import AnalyzerShowRequest, AnalyzerUpdateRequest pytestmark = pytest.mark.unit @@ -90,6 +91,12 @@ def test_analyzer_show_canonical_and_positional_forms_bind_identically(): }, {"name": "invoice-v1", "schema": Path("schema.json")}, ), + ( + ("analyzer", "update"), + {"analyzer_name": "invoice-v1", "description": "Updated"}, + {"positional_analyzer_name": "invoice-v1", "description": "Updated"}, + {"name": "invoice-v1", "description": "Updated"}, + ), ( ("analyzer", "delete"), {"analyzer_name": "invoice-v1"}, @@ -137,6 +144,22 @@ def test_analyzer_show_builds_normalized_typed_request(): assert request == AnalyzerShowRequest(name="invoice-v1") +def test_analyzer_update_builds_typed_metadata_request(): + request = build_request( + ANALYZER_UPDATE, + { + "analyzer_name": " invoice-v1 ", + "tag_assignments": ("owner=cu-cli",), + }, + ) + + assert request == AnalyzerUpdateRequest( + name="invoice-v1", + tag_assignments=("owner=cu-cli",), + ) + assert request.tags == {"owner": "cu-cli"} + + def test_analyze_named_urls_bind_as_repeatable_common_strings(): spec = get_command_spec("analyze") argument = next(argument for argument in spec.arguments if argument.name == "--url") diff --git a/cu-cli/packages/core/tests/test_contracts.py b/cu-cli/packages/core/tests/test_contracts.py index b762184..c909fec 100644 --- a/cu-cli/packages/core/tests/test_contracts.py +++ b/cu-cli/packages/core/tests/test_contracts.py @@ -7,6 +7,7 @@ from cu_cli_core.contracts import ( AnalyzerShowRequest, + AnalyzerUpdateRequest, BatchReport, FileOutcome, OutcomeStatus, @@ -21,6 +22,47 @@ def test_analyzer_show_rejects_empty_name(name): AnalyzerShowRequest(name) +def test_analyzer_update_parses_repeated_tags_without_losing_equals(): + request = AnalyzerUpdateRequest( + " invoice_v1 ", + tag_assignments=("owner=cu-cli", "query=a=b"), + ) + + assert request.name == "invoice_v1" + assert request.tags == {"owner": "cu-cli", "query": "a=b"} + + +@pytest.mark.parametrize( + ("tag_assignments", "message"), + [ + (("owner",), "expected KEY=VALUE"), + (("=cu-cli",), "non-empty key"), + ((" owner=cu-cli",), "cannot start or end with whitespace"), + (("owner =cu-cli",), "cannot start or end with whitespace"), + (("owner=one", "owner=two"), "duplicate tag key 'owner'"), + ], +) +def test_analyzer_update_rejects_invalid_tags(tag_assignments, message): + with pytest.raises(ValueError, match=message): + AnalyzerUpdateRequest("invoice_v1", tag_assignments=tag_assignments) + + +def test_analyzer_update_requires_a_metadata_change(): + with pytest.raises(ValueError, match="at least one metadata change"): + AnalyzerUpdateRequest("invoice_v1") + + +def test_analyzer_update_allows_clearing_description_and_empty_tag_values(): + request = AnalyzerUpdateRequest( + "invoice_v1", + description="", + tag_assignments=("owner=",), + ) + + assert request.description == "" + assert request.tags == {"owner": ""} + + def test_batch_report_counts_each_outcome_status(): report = BatchReport( ( diff --git a/cu-cli/packages/core/tests/test_operations.py b/cu-cli/packages/core/tests/test_operations.py index d7e0a89..71b8b71 100644 --- a/cu-cli/packages/core/tests/test_operations.py +++ b/cu-cli/packages/core/tests/test_operations.py @@ -2,10 +2,13 @@ # Licensed under the MIT license. from unittest.mock import Mock +from types import SimpleNamespace import pytest +from azure.core.exceptions import ResourceNotFoundError -from cu_cli_core.operations.analyzers import get_analyzer +from cu_cli_core.errors import NotFoundError, ValidationError +from cu_cli_core.operations.analyzers import get_analyzer, update_analyzer pytestmark = pytest.mark.unit @@ -18,3 +21,103 @@ def test_get_analyzer_delegates_to_injected_client(): client.get_analyzer.assert_called_once_with("invoice-v1") assert result == {"analyzerId": "invoice-v1"} + + +@pytest.mark.parametrize( + ("description", "tags", "existing_tags", "expected_patch"), + [ + ("Updated", None, None, {"description": "Updated"}), + ( + None, + {"owner": "cu-cli"}, + {"scenario": "existing"}, + {"tags": {"owner": "cu-cli", "scenario": "existing"}}, + ), + ( + "Updated", + {"owner": "cu-cli"}, + None, + {"description": "Updated", "tags": {"owner": "cu-cli"}}, + ), + ], +) +def test_update_analyzer_sends_only_requested_metadata( + description, + tags, + existing_tags, + expected_patch, +): + client = Mock() + client.get_analyzer.return_value = SimpleNamespace(tags=existing_tags) + client.update_analyzer.return_value = {"analyzerId": "invoice_v1"} + + result = update_analyzer( + client, + "invoice_v1", + description=description, + tags=tags, + ) + + client.update_analyzer.assert_called_once_with("invoice_v1", expected_patch) + if tags: + client.get_analyzer.assert_called_once_with("invoice_v1") + else: + client.get_analyzer.assert_not_called() + assert set(expected_patch) <= {"description", "tags"} + assert "fieldSchema" not in expected_patch + assert result == {"analyzerId": "invoice_v1"} + + +def test_update_analyzer_rejects_noop_before_service_call(): + client = Mock() + + with pytest.raises(ValidationError, match="at least one metadata change"): + update_analyzer(client, "invoice_v1") + + client.update_analyzer.assert_not_called() + + +def test_update_analyzer_translates_missing_analyzer(): + client = Mock() + client.update_analyzer.side_effect = ResourceNotFoundError("not found") + + with pytest.raises(NotFoundError, match="nothing was updated") as exc_info: + update_analyzer(client, "missing", description="Updated") + + assert "analyzer list --info" in (exc_info.value.hint or "") + + +def test_update_analyzer_translates_missing_analyzer_before_tag_merge(): + client = Mock() + client.get_analyzer.side_effect = ResourceNotFoundError("not found") + + with pytest.raises(NotFoundError, match="nothing was updated"): + update_analyzer(client, "missing", tags={"owner": "cu-cli"}) + + client.update_analyzer.assert_not_called() + + +def test_update_analyzer_preserves_tags_from_mapping_analyzer(): + client = Mock() + client.get_analyzer.return_value = {"tags": {"scenario": "existing"}} + + update_analyzer(client, "invoice_v1", tags={"owner": "cu-cli"}) + + client.update_analyzer.assert_called_once_with( + "invoice_v1", + {"tags": {"scenario": "existing", "owner": "cu-cli"}}, + ) + + +def test_update_analyzer_preserves_tags_from_as_dict_analyzer(): + client = Mock() + client.get_analyzer.return_value = SimpleNamespace( + as_dict=lambda: {"tags": {"scenario": "existing"}} + ) + + update_analyzer(client, "invoice_v1", tags={"owner": "cu-cli"}) + + client.update_analyzer.assert_called_once_with( + "invoice_v1", + {"tags": {"scenario": "existing", "owner": "cu-cli"}}, + ) diff --git a/cu-cli/packages/standalone/src/cu_cli/commands/analyzer.py b/cu-cli/packages/standalone/src/cu_cli/commands/analyzer.py index f22988b..23ff796 100644 --- a/cu-cli/packages/standalone/src/cu_cli/commands/analyzer.py +++ b/cu-cli/packages/standalone/src/cu_cli/commands/analyzer.py @@ -3,7 +3,7 @@ """``cu analyzer`` — manage and author custom analyzers. -MVP command surface: ``list``, ``show``, ``create``, ``delete``, +MVP command surface: ``list``, ``show``, ``create``, ``update``, ``delete``, ``test``, ``validate``, and ``schema create``. The ``validate`` and default ``schema create`` paths are **LLM-free and offline** — they give coding agents a deterministic author->validate loop with no service round-trips. @@ -36,6 +36,7 @@ ANALYZER_SCHEMA_CREATE, ANALYZER_SHOW, ANALYZER_TEST, + ANALYZER_UPDATE, ANALYZER_VALIDATE, CommandBindingError, build_request, @@ -167,7 +168,7 @@ def _require_custom_analyzer_id(analyzer_id: str) -> None: @click.group("analyzer", help="Manage analyzers, which define how Content Understanding processes files. " - "List, show, create, copy, delete, and test analyzers, or create and validate " + "List, show, create, update, copy, delete, and test analyzers, or create and validate " "local analyzer schemas.", epilog="[bold cyan]Common commands:[/bold cyan]\n\n" "[bold green]cu analyzer list[/bold green]\n\n" @@ -319,6 +320,58 @@ def cmd_create( calling_timer.print() +@analyzer_group.command( + "update", + help=ANALYZER_UPDATE.help, + epilog=common_commands( + ( + "cu analyzer update ANALYZER_NAME --description DESCRIPTION", + "Update an analyzer description.", + ), + ( + "cu analyzer update ANALYZER_NAME --tag KEY=VALUE --tag KEY=VALUE", + "Set one or more analyzer tags.", + ), + ), +) +@with_command_arguments(ANALYZER_UPDATE) +@with_auth_options +@friendly_errors +def cmd_update( + positional_analyzer_name, analyzer_name, description, tag_assignments, + endpoint, api_key, api_version, entra, profile_name, + show_runtime_context, show_calling_time +) -> None: + try: + request = build_request( + ANALYZER_UPDATE, + { + "positional_analyzer_name": positional_analyzer_name, + "analyzer_name": analyzer_name, + "description": description, + "tag_assignments": tag_assignments, + }, + ) + except CommandBindingError as exc: + raise CuCliError(str(exc), exit_code=VALIDATION_FAILURE) from exc + client = _client(endpoint, api_key, api_version, entra, profile_name, show_runtime_context) + with calling_time(show_calling_time) as calling_timer: + result = resolve_identifier(ANALYZER_UPDATE.operation)( + client, + request.name, + description=request.description, + tags=request.tags or None, + ) + changed = [] + if request.description is not None: + changed.append("description") + if request.tags: + changed.append("tags") + final_id = getattr(result, "analyzer_id", request.name) + console.print(f"[green]ok[/green] updated analyzer: {final_id} ({', '.join(changed)})") + calling_timer.print() + + @analyzer_group.command( "delete", help="Delete an analyzer.", diff --git a/cu-cli/packages/standalone/src/cu_cli/core/analyzers.py b/cu-cli/packages/standalone/src/cu_cli/core/analyzers.py index 67de7c9..3f2c3e4 100644 --- a/cu-cli/packages/standalone/src/cu_cli/core/analyzers.py +++ b/cu-cli/packages/standalone/src/cu_cli/core/analyzers.py @@ -15,6 +15,7 @@ delete_analyzer, get_analyzer, list_analyzers, + update_analyzer, ) __all__ = [ @@ -27,4 +28,5 @@ "get_copy_source_analyzer", "list_analyzers", "preflight_dependencies_on_target", + "update_analyzer", ] diff --git a/cu-cli/packages/standalone/tests/conftest.py b/cu-cli/packages/standalone/tests/conftest.py index 651d34b..04fd7e7 100644 --- a/cu-cli/packages/standalone/tests/conftest.py +++ b/cu-cli/packages/standalone/tests/conftest.py @@ -34,6 +34,8 @@ def _isolate_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): # patched to the isolated path above and never mutates the real config. if rec_mode in {"live", "record"}: monkeypatch.setenv("AZURE_CONFIG_DIR", str(original_home / ".azure")) + if os.name == "nt": + monkeypatch.setenv("USERPROFILE", str(original_home)) else: monkeypatch.setenv("AZURE_CONFIG_DIR", str(home / ".azure")) # Strip any CU_* env so tests control precedence explicitly, but preserve diff --git a/cu-cli/packages/standalone/tests/integration/recordings/README.md b/cu-cli/packages/standalone/tests/integration/recordings/README.md index 744d3a9..3aba8c2 100644 --- a/cu-cli/packages/standalone/tests/integration/recordings/README.md +++ b/cu-cli/packages/standalone/tests/integration/recordings/README.md @@ -1,7 +1,7 @@ # Cassettes (record / playback) Sanitized HTTP recordings for cloud-gated commands, replayed by -`tests/test_cloud_playback.py` in **playback** mode (default, offline, CI). +`tests/integration/` in **playback** mode (default, offline, CI). Modes are selected via env (not stripped by the test env isolation): @@ -19,7 +19,7 @@ Modes are selected via env (not stripped by the test env isolation): Regenerate against a live endpoint: CU_TEST_REC_MODE=record CU_TEST_REC_ENDPOINT=https://.services.ai.azure.com/ \ - CU_TEST_REC_KEY= pytest tests/test_cloud_playback.py + CU_TEST_REC_KEY= pytest tests/integration/ Cassettes are host-agnostic (matched on method + path + query) and have all secrets and real hostnames scrubbed, so they are safe to commit. @@ -39,6 +39,8 @@ secrets and real hostnames scrubbed, so they are safe to commit. - `analyzer_create.yaml` - `analyzer_show.yaml` - `analyzer_delete.yaml` +- Update analyzer metadata without changing its schema + - `analyzer_update_lifecycle.yaml` - Analyzer copy lifecycle tests are live/record-only. Same-resource copy needs `CU_TEST_REC_COPY_SOURCE_ID` set to a stable, ready custom analyzer. Cross-resource copy needs two Azure resources. Set the corresponding `CU_TEST_REC_*` values and run diff --git a/cu-cli/packages/standalone/tests/integration/recordings/analyzer_update_lifecycle.yaml b/cu-cli/packages/standalone/tests/integration/recordings/analyzer_update_lifecycle.yaml new file mode 100644 index 0000000..692f0e1 --- /dev/null +++ b/cu-cli/packages/standalone/tests/integration/recordings/analyzer_update_lifecycle.yaml @@ -0,0 +1,669 @@ +interactions: +- request: + body: + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '294' + Content-Type: + - application/json + User-Agent: + - cu-cli/0.1.0b1 azsdk-python-ai-contentunderstanding/1.2.0b3 Python/3.11.9 + (Windows-10-10.0.26200-SP0) + authorization: + - REDACTED + x-ms-client-request-id: + - REDACTED + method: PUT + uri: https://sanitized.services.ai.azure.com/contentunderstanding/analyzers/cu_cli_update_test_v1?api-version=2025-11-01 + response: + body: + string: '{"analyzerId":"cu_cli_update_test_v1","createdAt":"2026-09-08T05:07:47Z","lastModifiedAt":"2026-09-08T05:07:47Z","baseAnalyzerId":"prebuilt-document","config":{"returnDetails":false,"enableOcr":true,"enableLayout":true,"enableFormula":true,"enableFigureDescription":false,"enableFigureAnalysis":false,"chartFormat":"chartjs","tableFormat":"html","enableSegment":false,"omitContent":false,"segmentPerPage":false,"annotationFormat":"markdown"},"fieldSchema":{"fields":{"vendor_name":{"type":"string","method":"extract","description":"Full + legal vendor name from the invoice header."}}},"warnings":[],"status":"creating","processingLocation":"geography","supportedModels":{"completion":["gpt-4o","gpt-4o-mini","gpt-4.1","gpt-4.1-mini","gpt-4.1-nano","gpt-5","gpt-5-mini","gpt-5-nano","gpt-5.1","gpt-5.2","gpt-5.4","gpt-5.4-mini","gpt-5.5"],"embedding":["text-embedding-3-small","text-embedding-3-large","text-embedding-ada-002"]},"models":{"completion":"gpt-5.2","embedding":"prebuilt-analyzer-embedding"}}' + headers: + Content-Type: + - application/json + Date: + - Tue, 08 Sep 2026 05:07:49 GMT + Operation-Location: + - https://sanitized.services.ai.azure.com/contentunderstanding/analyzers/cu_cli_update_test_v1/operations/7e0d8ba0-7b89-4592-b656-e6517341ea99?api-version=2025-11-01 + Request-Id: + - 7e0d8ba0-7b89-4592-b656-e6517341ea99 + Server: + - istio-envoy + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + api-supported-versions: + - 2025-11-01,2026-06-01-preview + apim-request-id: + - 7e0d8ba0-7b89-4592-b656-e6517341ea99 + x-envoy-upstream-service-time: + - '2438' + x-ms-region: + - East US 2 EUAP + x-ms-request-id: + - REDACTED + status: + code: 201 + message: Created +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - cu-cli/0.1.0b1 azsdk-python-ai-contentunderstanding/1.2.0b3 Python/3.11.9 + (Windows-10-10.0.26200-SP0) + authorization: + - REDACTED + x-ms-client-request-id: + - REDACTED + method: GET + uri: https://sanitized.services.ai.azure.com/contentunderstanding/analyzers/cu_cli_update_test_v1/operations/7e0d8ba0-7b89-4592-b656-e6517341ea99?api-version=2025-11-01 + response: + body: + string: '{"id":"7e0d8ba0-7b89-4592-b656-e6517341ea99","status":"Running","result":{"analyzerId":"cu_cli_update_test_v1","createdAt":"2026-09-08T05:07:47Z","lastModifiedAt":"2026-09-08T05:07:47Z","baseAnalyzerId":"prebuilt-document","config":{"returnDetails":false,"enableOcr":true,"enableLayout":true,"enableFormula":true,"enableFigureDescription":false,"enableFigureAnalysis":false,"chartFormat":"chartjs","tableFormat":"html","enableSegment":false,"omitContent":false,"segmentPerPage":false,"annotationFormat":"markdown"},"fieldSchema":{"fields":{"vendor_name":{"type":"string","method":"extract","description":"Full + legal vendor name from the invoice header."}}},"warnings":[],"status":"creating","processingLocation":"geography","supportedModels":{"completion":["gpt-4o","gpt-4o-mini","gpt-4.1","gpt-4.1-mini","gpt-4.1-nano","gpt-5","gpt-5-mini","gpt-5-nano","gpt-5.1","gpt-5.2","gpt-5.4","gpt-5.4-mini","gpt-5.5"],"embedding":["text-embedding-3-small","text-embedding-3-large","text-embedding-ada-002"]},"models":{"completion":"gpt-5.2","embedding":"prebuilt-analyzer-embedding"}}}' + headers: + Content-Type: + - application/json + Date: + - Tue, 08 Sep 2026 05:07:51 GMT + Request-Id: + - 696f68fd-b009-4b67-8087-14e0827a1c4c + Server: + - istio-envoy + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + api-supported-versions: + - 2025-11-01,2026-06-01-preview + apim-request-id: + - 696f68fd-b009-4b67-8087-14e0827a1c4c + x-envoy-upstream-service-time: + - '52' + x-ms-region: + - East US 2 EUAP + x-ms-request-id: + - REDACTED + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - cu-cli/0.1.0b1 azsdk-python-ai-contentunderstanding/1.2.0b3 Python/3.11.9 + (Windows-10-10.0.26200-SP0) + authorization: + - REDACTED + x-ms-client-request-id: + - REDACTED + method: GET + uri: https://sanitized.services.ai.azure.com/contentunderstanding/analyzers/cu_cli_update_test_v1/operations/7e0d8ba0-7b89-4592-b656-e6517341ea99?api-version=2025-11-01 + response: + body: + string: '{"id":"7e0d8ba0-7b89-4592-b656-e6517341ea99","status":"Succeeded","result":{"analyzerId":"cu_cli_update_test_v1","createdAt":"2026-09-08T05:07:47Z","lastModifiedAt":"2026-09-08T05:07:47Z","baseAnalyzerId":"prebuilt-document","config":{"returnDetails":false,"enableOcr":true,"enableLayout":true,"enableFormula":true,"enableFigureDescription":false,"enableFigureAnalysis":false,"chartFormat":"chartjs","tableFormat":"html","enableSegment":false,"omitContent":false,"segmentPerPage":false,"annotationFormat":"markdown"},"fieldSchema":{"fields":{"vendor_name":{"type":"string","method":"extract","description":"Full + legal vendor name from the invoice header."}}},"warnings":[],"status":"ready","processingLocation":"geography","supportedModels":{"completion":["gpt-4o","gpt-4o-mini","gpt-4.1","gpt-4.1-mini","gpt-4.1-nano","gpt-5","gpt-5-mini","gpt-5-nano","gpt-5.1","gpt-5.2","gpt-5.4","gpt-5.4-mini","gpt-5.5"],"embedding":["text-embedding-3-small","text-embedding-3-large","text-embedding-ada-002"]},"models":{"completion":"gpt-5.2","embedding":"prebuilt-analyzer-embedding"}}}' + headers: + Content-Type: + - application/json + Date: + - Tue, 08 Sep 2026 05:07:53 GMT + Request-Id: + - eee3fefe-911c-4658-a97e-6da1830d571e + Server: + - istio-envoy + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + api-supported-versions: + - 2025-11-01,2026-06-01-preview + apim-request-id: + - eee3fefe-911c-4658-a97e-6da1830d571e + x-envoy-upstream-service-time: + - '44' + x-ms-region: + - East US 2 EUAP + x-ms-request-id: + - REDACTED + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - cu-cli/0.1.0b1 azsdk-python-ai-contentunderstanding/1.2.0b3 Python/3.11.9 + (Windows-10-10.0.26200-SP0) + authorization: + - REDACTED + x-ms-client-request-id: + - REDACTED + method: GET + uri: https://sanitized.services.ai.azure.com/contentunderstanding/analyzers/cu_cli_update_test_v1?api-version=2025-11-01 + response: + body: + string: '{"analyzerId":"cu_cli_update_test_v1","createdAt":"2026-09-08T05:07:47Z","lastModifiedAt":"2026-09-08T05:07:47Z","baseAnalyzerId":"prebuilt-document","config":{"returnDetails":false,"enableOcr":true,"enableLayout":true,"enableFormula":true,"enableFigureDescription":false,"enableFigureAnalysis":false,"chartFormat":"chartjs","tableFormat":"html","enableSegment":false,"omitContent":false,"segmentPerPage":false,"annotationFormat":"markdown"},"fieldSchema":{"fields":{"vendor_name":{"type":"string","method":"extract","description":"Full + legal vendor name from the invoice header."}}},"warnings":[],"status":"ready","processingLocation":"geography","supportedModels":{"completion":["gpt-4o","gpt-4o-mini","gpt-4.1","gpt-4.1-mini","gpt-4.1-nano","gpt-5","gpt-5-mini","gpt-5-nano","gpt-5.1","gpt-5.2","gpt-5.4","gpt-5.4-mini","gpt-5.5"],"embedding":["text-embedding-3-small","text-embedding-3-large","text-embedding-ada-002"]},"models":{"completion":"gpt-5.2","embedding":"prebuilt-analyzer-embedding"}}' + headers: + Content-Type: + - application/json + Date: + - Tue, 08 Sep 2026 05:07:54 GMT + Request-Id: + - a2dd6800-1136-4615-8e03-54340769d988 + Server: + - istio-envoy + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + api-supported-versions: + - 2025-11-01,2026-06-01-preview + apim-request-id: + - a2dd6800-1136-4615-8e03-54340769d988 + x-envoy-upstream-service-time: + - '19' + x-ms-region: + - East US 2 EUAP + x-ms-request-id: + - REDACTED + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - cu-cli/0.1.0b1 azsdk-python-ai-contentunderstanding/1.2.0b3 Python/3.11.9 + (Windows-10-10.0.26200-SP0) + authorization: + - REDACTED + x-ms-client-request-id: + - REDACTED + method: GET + uri: https://sanitized.services.ai.azure.com/contentunderstanding/analyzers/cu_cli_update_test_v1?api-version=2025-11-01 + response: + body: + string: '{"analyzerId":"cu_cli_update_test_v1","createdAt":"2026-09-08T05:07:47Z","lastModifiedAt":"2026-09-08T05:07:47Z","baseAnalyzerId":"prebuilt-document","config":{"returnDetails":false,"enableOcr":true,"enableLayout":true,"enableFormula":true,"enableFigureDescription":false,"enableFigureAnalysis":false,"chartFormat":"chartjs","tableFormat":"html","enableSegment":false,"omitContent":false,"segmentPerPage":false,"annotationFormat":"markdown"},"fieldSchema":{"fields":{"vendor_name":{"type":"string","method":"extract","description":"Full + legal vendor name from the invoice header."}}},"warnings":[],"status":"ready","processingLocation":"geography","supportedModels":{"completion":["gpt-4o","gpt-4o-mini","gpt-4.1","gpt-4.1-mini","gpt-4.1-nano","gpt-5","gpt-5-mini","gpt-5-nano","gpt-5.1","gpt-5.2","gpt-5.4","gpt-5.4-mini","gpt-5.5"],"embedding":["text-embedding-3-small","text-embedding-3-large","text-embedding-ada-002"]},"models":{"completion":"gpt-5.2","embedding":"prebuilt-analyzer-embedding"}}' + headers: + Content-Type: + - application/json + Date: + - Tue, 08 Sep 2026 05:07:57 GMT + Request-Id: + - 9a525da8-a1a7-437b-a08d-99c7049e9da0 + Server: + - istio-envoy + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + api-supported-versions: + - 2025-11-01,2026-06-01-preview + apim-request-id: + - 9a525da8-a1a7-437b-a08d-99c7049e9da0 + x-envoy-upstream-service-time: + - '17' + x-ms-region: + - East US 2 EUAP + x-ms-request-id: + - REDACTED + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - cu-cli/0.1.0b1 azsdk-python-ai-contentunderstanding/1.2.0b3 Python/3.11.9 + (Windows-10-10.0.26200-SP0) + authorization: + - REDACTED + x-ms-client-request-id: + - REDACTED + method: GET + uri: https://sanitized.services.ai.azure.com/contentunderstanding/analyzers/cu_cli_update_test_v1?api-version=2025-11-01 + response: + body: + string: '{"analyzerId":"cu_cli_update_test_v1","createdAt":"2026-09-08T05:07:47Z","lastModifiedAt":"2026-09-08T05:07:47Z","baseAnalyzerId":"prebuilt-document","config":{"returnDetails":false,"enableOcr":true,"enableLayout":true,"enableFormula":true,"enableFigureDescription":false,"enableFigureAnalysis":false,"chartFormat":"chartjs","tableFormat":"html","enableSegment":false,"omitContent":false,"segmentPerPage":false,"annotationFormat":"markdown"},"fieldSchema":{"fields":{"vendor_name":{"type":"string","method":"extract","description":"Full + legal vendor name from the invoice header."}}},"warnings":[],"status":"ready","processingLocation":"geography","supportedModels":{"completion":["gpt-4o","gpt-4o-mini","gpt-4.1","gpt-4.1-mini","gpt-4.1-nano","gpt-5","gpt-5-mini","gpt-5-nano","gpt-5.1","gpt-5.2","gpt-5.4","gpt-5.4-mini","gpt-5.5"],"embedding":["text-embedding-3-small","text-embedding-3-large","text-embedding-ada-002"]},"models":{"completion":"gpt-5.2","embedding":"prebuilt-analyzer-embedding"}}' + headers: + Content-Type: + - application/json + Date: + - Tue, 08 Sep 2026 05:08:00 GMT + Request-Id: + - 3c1ff8a7-8a57-45a5-bcf9-866dc0f4072e + Server: + - istio-envoy + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + api-supported-versions: + - 2025-11-01,2026-06-01-preview + apim-request-id: + - 3c1ff8a7-8a57-45a5-bcf9-866dc0f4072e + x-envoy-upstream-service-time: + - '22' + x-ms-region: + - East US 2 EUAP + x-ms-request-id: + - REDACTED + status: + code: 200 + message: OK +- request: + body: + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '125' + Content-Type: + - application/merge-patch+json + User-Agent: + - cu-cli/0.1.0b1 azsdk-python-ai-contentunderstanding/1.2.0b3 Python/3.11.9 + (Windows-10-10.0.26200-SP0) + authorization: + - REDACTED + x-ms-client-request-id: + - REDACTED + method: PATCH + uri: https://sanitized.services.ai.azure.com/contentunderstanding/analyzers/cu_cli_update_test_v1?api-version=2025-11-01 + response: + body: + string: '{"analyzerId":"cu_cli_update_test_v1","description":"Updated by CU + CLI analyzer-update validation","tags":{"owner":"cu-cli","scenario":"update-validation"},"createdAt":"2026-09-08T05:07:47Z","lastModifiedAt":"2026-09-08T05:08:03Z","baseAnalyzerId":"prebuilt-document","config":{"returnDetails":false,"enableOcr":true,"enableLayout":true,"enableFormula":true,"enableFigureDescription":false,"enableFigureAnalysis":false,"chartFormat":"chartjs","tableFormat":"html","enableSegment":false,"omitContent":false,"segmentPerPage":false,"annotationFormat":"markdown"},"fieldSchema":{"fields":{"vendor_name":{"type":"string","method":"extract","description":"Full + legal vendor name from the invoice header."}}},"warnings":[],"status":"ready","processingLocation":"geography","supportedModels":{"completion":["gpt-4o","gpt-4o-mini","gpt-4.1","gpt-4.1-mini","gpt-4.1-nano","gpt-5","gpt-5-mini","gpt-5-nano","gpt-5.1","gpt-5.2","gpt-5.4","gpt-5.4-mini","gpt-5.5"],"embedding":["text-embedding-3-small","text-embedding-3-large","text-embedding-ada-002"]},"models":{"completion":"gpt-5.2","embedding":"prebuilt-analyzer-embedding"}}' + headers: + Content-Type: + - application/json + Date: + - Tue, 08 Sep 2026 05:08:02 GMT + Request-Id: + - 26da9676-dc4e-4187-872d-ee98b7f22df9 + Server: + - istio-envoy + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + api-supported-versions: + - 2025-11-01,2026-06-01-preview + apim-request-id: + - 26da9676-dc4e-4187-872d-ee98b7f22df9 + x-envoy-upstream-service-time: + - '46' + x-ms-region: + - East US 2 EUAP + x-ms-request-id: + - REDACTED + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - cu-cli/0.1.0b1 azsdk-python-ai-contentunderstanding/1.2.0b3 Python/3.11.9 + (Windows-10-10.0.26200-SP0) + authorization: + - REDACTED + x-ms-client-request-id: + - REDACTED + method: GET + uri: https://sanitized.services.ai.azure.com/contentunderstanding/analyzers/cu_cli_update_test_v1?api-version=2025-11-01 + response: + body: + string: '{"analyzerId":"cu_cli_update_test_v1","description":"Updated by CU + CLI analyzer-update validation","tags":{"owner":"cu-cli","scenario":"update-validation"},"createdAt":"2026-09-08T05:07:47Z","lastModifiedAt":"2026-09-08T05:08:03Z","baseAnalyzerId":"prebuilt-document","config":{"returnDetails":false,"enableOcr":true,"enableLayout":true,"enableFormula":true,"enableFigureDescription":false,"enableFigureAnalysis":false,"chartFormat":"chartjs","tableFormat":"html","enableSegment":false,"omitContent":false,"segmentPerPage":false,"annotationFormat":"markdown"},"fieldSchema":{"fields":{"vendor_name":{"type":"string","method":"extract","description":"Full + legal vendor name from the invoice header."}}},"warnings":[],"status":"ready","processingLocation":"geography","supportedModels":{"completion":["gpt-4o","gpt-4o-mini","gpt-4.1","gpt-4.1-mini","gpt-4.1-nano","gpt-5","gpt-5-mini","gpt-5-nano","gpt-5.1","gpt-5.2","gpt-5.4","gpt-5.4-mini","gpt-5.5"],"embedding":["text-embedding-3-small","text-embedding-3-large","text-embedding-ada-002"]},"models":{"completion":"gpt-5.2","embedding":"prebuilt-analyzer-embedding"}}' + headers: + Content-Type: + - application/json + Date: + - Tue, 08 Sep 2026 05:08:24 GMT + Request-Id: + - fbfc8630-c332-4f19-b3de-6f969d304e2f + Server: + - istio-envoy + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + api-supported-versions: + - 2025-11-01,2026-06-01-preview + apim-request-id: + - fbfc8630-c332-4f19-b3de-6f969d304e2f + x-envoy-upstream-service-time: + - '26' + x-ms-region: + - East US 2 EUAP + x-ms-request-id: + - REDACTED + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - cu-cli/0.1.0b1 azsdk-python-ai-contentunderstanding/1.2.0b3 Python/3.11.9 + (Windows-10-10.0.26200-SP0) + authorization: + - REDACTED + x-ms-client-request-id: + - REDACTED + method: GET + uri: https://sanitized.services.ai.azure.com/contentunderstanding/analyzers/cu_cli_update_test_v1?api-version=2025-11-01 + response: + body: + string: '{"analyzerId":"cu_cli_update_test_v1","description":"Updated by CU + CLI analyzer-update validation","tags":{"owner":"cu-cli","scenario":"update-validation"},"createdAt":"2026-09-08T05:07:47Z","lastModifiedAt":"2026-09-08T05:08:03Z","baseAnalyzerId":"prebuilt-document","config":{"returnDetails":false,"enableOcr":true,"enableLayout":true,"enableFormula":true,"enableFigureDescription":false,"enableFigureAnalysis":false,"chartFormat":"chartjs","tableFormat":"html","enableSegment":false,"omitContent":false,"segmentPerPage":false,"annotationFormat":"markdown"},"fieldSchema":{"fields":{"vendor_name":{"type":"string","method":"extract","description":"Full + legal vendor name from the invoice header."}}},"warnings":[],"status":"ready","processingLocation":"geography","supportedModels":{"completion":["gpt-4o","gpt-4o-mini","gpt-4.1","gpt-4.1-mini","gpt-4.1-nano","gpt-5","gpt-5-mini","gpt-5-nano","gpt-5.1","gpt-5.2","gpt-5.4","gpt-5.4-mini","gpt-5.5"],"embedding":["text-embedding-3-small","text-embedding-3-large","text-embedding-ada-002"]},"models":{"completion":"gpt-5.2","embedding":"prebuilt-analyzer-embedding"}}' + headers: + Content-Type: + - application/json + Date: + - Tue, 08 Sep 2026 05:08:27 GMT + Request-Id: + - dab747a4-fff2-4ee9-bc1c-41081f00ae2a + Server: + - istio-envoy + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + api-supported-versions: + - 2025-11-01,2026-06-01-preview + apim-request-id: + - dab747a4-fff2-4ee9-bc1c-41081f00ae2a + x-envoy-upstream-service-time: + - '29' + x-ms-region: + - East US 2 EUAP + x-ms-request-id: + - REDACTED + status: + code: 200 + message: OK +- request: + body: + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '64' + Content-Type: + - application/merge-patch+json + User-Agent: + - cu-cli/0.1.0b1 azsdk-python-ai-contentunderstanding/1.2.0b3 Python/3.11.9 + (Windows-10-10.0.26200-SP0) + authorization: + - REDACTED + x-ms-client-request-id: + - REDACTED + method: PATCH + uri: https://sanitized.services.ai.azure.com/contentunderstanding/analyzers/cu_cli_update_test_v1?api-version=2025-11-01 + response: + body: + string: '{"analyzerId":"cu_cli_update_test_v1","description":"Updated by CU + CLI analyzer-update validation","tags":{"owner":"platform","scenario":"update-validation"},"createdAt":"2026-09-08T05:07:47Z","lastModifiedAt":"2026-09-08T05:08:29Z","baseAnalyzerId":"prebuilt-document","config":{"returnDetails":false,"enableOcr":true,"enableLayout":true,"enableFormula":true,"enableFigureDescription":false,"enableFigureAnalysis":false,"chartFormat":"chartjs","tableFormat":"html","enableSegment":false,"omitContent":false,"segmentPerPage":false,"annotationFormat":"markdown"},"fieldSchema":{"fields":{"vendor_name":{"type":"string","method":"extract","description":"Full + legal vendor name from the invoice header."}}},"warnings":[],"status":"ready","processingLocation":"geography","supportedModels":{"completion":["gpt-4o","gpt-4o-mini","gpt-4.1","gpt-4.1-mini","gpt-4.1-nano","gpt-5","gpt-5-mini","gpt-5-nano","gpt-5.1","gpt-5.2","gpt-5.4","gpt-5.4-mini","gpt-5.5"],"embedding":["text-embedding-3-small","text-embedding-3-large","text-embedding-ada-002"]},"models":{"completion":"gpt-5.2","embedding":"prebuilt-analyzer-embedding"}}' + headers: + Content-Type: + - application/json + Date: + - Tue, 08 Sep 2026 05:08:29 GMT + Request-Id: + - 127ec9bb-dc6d-400b-b55f-3123dd372342 + Server: + - istio-envoy + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + api-supported-versions: + - 2025-11-01,2026-06-01-preview + apim-request-id: + - 127ec9bb-dc6d-400b-b55f-3123dd372342 + x-envoy-upstream-service-time: + - '40' + x-ms-region: + - East US 2 EUAP + x-ms-request-id: + - REDACTED + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - cu-cli/0.1.0b1 azsdk-python-ai-contentunderstanding/1.2.0b3 Python/3.11.9 + (Windows-10-10.0.26200-SP0) + authorization: + - REDACTED + x-ms-client-request-id: + - REDACTED + method: GET + uri: https://sanitized.services.ai.azure.com/contentunderstanding/analyzers/cu_cli_update_test_v1?api-version=2025-11-01 + response: + body: + string: '{"analyzerId":"cu_cli_update_test_v1","description":"Updated by CU + CLI analyzer-update validation","tags":{"owner":"platform","scenario":"update-validation"},"createdAt":"2026-09-08T05:07:47Z","lastModifiedAt":"2026-09-08T05:08:29Z","baseAnalyzerId":"prebuilt-document","config":{"returnDetails":false,"enableOcr":true,"enableLayout":true,"enableFormula":true,"enableFigureDescription":false,"enableFigureAnalysis":false,"chartFormat":"chartjs","tableFormat":"html","enableSegment":false,"omitContent":false,"segmentPerPage":false,"annotationFormat":"markdown"},"fieldSchema":{"fields":{"vendor_name":{"type":"string","method":"extract","description":"Full + legal vendor name from the invoice header."}}},"warnings":[],"status":"ready","processingLocation":"geography","supportedModels":{"completion":["gpt-4o","gpt-4o-mini","gpt-4.1","gpt-4.1-mini","gpt-4.1-nano","gpt-5","gpt-5-mini","gpt-5-nano","gpt-5.1","gpt-5.2","gpt-5.4","gpt-5.4-mini","gpt-5.5"],"embedding":["text-embedding-3-small","text-embedding-3-large","text-embedding-ada-002"]},"models":{"completion":"gpt-5.2","embedding":"prebuilt-analyzer-embedding"}}' + headers: + Content-Type: + - application/json + Date: + - Tue, 08 Sep 2026 05:08:32 GMT + Request-Id: + - abb9f2e7-a621-42c6-8fd9-6178f759d971 + Server: + - istio-envoy + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + api-supported-versions: + - 2025-11-01,2026-06-01-preview + apim-request-id: + - abb9f2e7-a621-42c6-8fd9-6178f759d971 + x-envoy-upstream-service-time: + - '29' + x-ms-region: + - East US 2 EUAP + x-ms-request-id: + - REDACTED + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - cu-cli/0.1.0b1 azsdk-python-ai-contentunderstanding/1.2.0b3 Python/3.11.9 + (Windows-10-10.0.26200-SP0) + authorization: + - REDACTED + x-ms-client-request-id: + - REDACTED + method: GET + uri: https://sanitized.services.ai.azure.com/contentunderstanding/analyzers/cu_cli_update_test_v1?api-version=2025-11-01 + response: + body: + string: '{"analyzerId":"cu_cli_update_test_v1","description":"Updated by CU + CLI analyzer-update validation","tags":{"owner":"platform","scenario":"update-validation"},"createdAt":"2026-09-08T05:07:47Z","lastModifiedAt":"2026-09-08T05:08:29Z","baseAnalyzerId":"prebuilt-document","config":{"returnDetails":false,"enableOcr":true,"enableLayout":true,"enableFormula":true,"enableFigureDescription":false,"enableFigureAnalysis":false,"chartFormat":"chartjs","tableFormat":"html","enableSegment":false,"omitContent":false,"segmentPerPage":false,"annotationFormat":"markdown"},"fieldSchema":{"fields":{"vendor_name":{"type":"string","method":"extract","description":"Full + legal vendor name from the invoice header."}}},"warnings":[],"status":"ready","processingLocation":"geography","supportedModels":{"completion":["gpt-4o","gpt-4o-mini","gpt-4.1","gpt-4.1-mini","gpt-4.1-nano","gpt-5","gpt-5-mini","gpt-5-nano","gpt-5.1","gpt-5.2","gpt-5.4","gpt-5.4-mini","gpt-5.5"],"embedding":["text-embedding-3-small","text-embedding-3-large","text-embedding-ada-002"]},"models":{"completion":"gpt-5.2","embedding":"prebuilt-analyzer-embedding"}}' + headers: + Content-Type: + - application/json + Date: + - Tue, 08 Sep 2026 05:08:34 GMT + Request-Id: + - 8b094708-fd2a-4d41-bd1b-a0c32310a066 + Server: + - istio-envoy + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Transfer-Encoding: + - chunked + X-Content-Type-Options: + - nosniff + api-supported-versions: + - 2025-11-01,2026-06-01-preview + apim-request-id: + - 8b094708-fd2a-4d41-bd1b-a0c32310a066 + x-envoy-upstream-service-time: + - '26' + x-ms-region: + - East US 2 EUAP + x-ms-request-id: + - REDACTED + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '0' + User-Agent: + - cu-cli/0.1.0b1 azsdk-python-ai-contentunderstanding/1.2.0b3 Python/3.11.9 + (Windows-10-10.0.26200-SP0) + authorization: + - REDACTED + x-ms-client-request-id: + - REDACTED + method: DELETE + uri: https://sanitized.services.ai.azure.com/contentunderstanding/analyzers/cu_cli_update_test_v1?api-version=2025-11-01 + response: + body: + string: '' + headers: + Date: + - Tue, 08 Sep 2026 05:08:37 GMT + Request-Id: + - b216fe21-935f-43d8-9619-0df0b6e74e8f + Server: + - istio-envoy + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + X-Content-Type-Options: + - nosniff + api-supported-versions: + - 2025-11-01,2026-06-01-preview + apim-request-id: + - b216fe21-935f-43d8-9619-0df0b6e74e8f + x-envoy-upstream-service-time: + - '77' + x-ms-region: + - East US 2 EUAP + x-ms-request-id: + - REDACTED + status: + code: 204 + message: No Content +version: 1 diff --git a/cu-cli/packages/standalone/tests/integration/test_analyzer.py b/cu-cli/packages/standalone/tests/integration/test_analyzer.py index 681328d..ca5a533 100644 --- a/cu-cli/packages/standalone/tests/integration/test_analyzer.py +++ b/cu-cli/packages/standalone/tests/integration/test_analyzer.py @@ -106,6 +106,7 @@ def _write_schema(path: str = "schema.json", analyzer_id: str = "cu_cli_test_v1" _ROUTE_TARGET_ID = "cu_cli_route_target_v1" _TEMPLATE_CLASSIFIER_ID = "cu_cli_tmpl_classifier_v1" _COPY_TARGET_ID = "cu_cli_copy_target_v1" +_UPDATE_ANALYZER_ID = "cu_cli_update_test_v1" def _build_classifier_schema_with_routing(path: str, route_target_id: str) -> Path: @@ -176,6 +177,76 @@ def test_scenario_3_analyzer_lifecycle_create_show_delete(cloud_project): assert res.exit_code == 0, res.output +def test_analyzer_update_metadata_preserves_schema(cloud_project): + _write_schema("update-schema.json", analyzer_id=_UPDATE_ANALYZER_ID) + created = False + with use_cassette("analyzer_update_lifecycle"): + try: + res = _run( + "analyzer", + "create", + _UPDATE_ANALYZER_ID, + "--schema", + "update-schema.json", + ) + _require_create_success(res) + created = True + + before = _run("analyzer", "show", _UPDATE_ANALYZER_ID) + assert before.exit_code == 0, before.output + before_payload = json.loads(before.output[before.output.find("{"):]) + + updated = _run( + "analyzer", + "update", + _UPDATE_ANALYZER_ID, + "--description", + "Updated by CU CLI analyzer-update validation", + "--tag", + "owner=cu-cli", + "--tag", + "scenario=update-validation", + ) + assert updated.exit_code == 0, updated.output + + after = _run("analyzer", "show", _UPDATE_ANALYZER_ID) + assert after.exit_code == 0, after.output + after_payload = json.loads(after.output[after.output.find("{"):]) + + assert after_payload["description"] == ( + "Updated by CU CLI analyzer-update validation" + ) + assert after_payload["tags"] == { + "owner": "cu-cli", + "scenario": "update-validation", + } + assert after_payload["fieldSchema"] == before_payload["fieldSchema"] + + retagged = _run( + "analyzer", + "update", + _UPDATE_ANALYZER_ID, + "--tag", + "owner=platform", + ) + assert retagged.exit_code == 0, retagged.output + + retagged_result = _run("analyzer", "show", _UPDATE_ANALYZER_ID) + assert retagged_result.exit_code == 0, retagged_result.output + retagged_payload = json.loads( + retagged_result.output[retagged_result.output.find("{"):] + ) + assert retagged_payload["tags"] == { + "owner": "platform", + "scenario": "update-validation", + } + assert retagged_payload["fieldSchema"] == before_payload["fieldSchema"] + finally: + if created: + deleted = _run("analyzer", "delete", _UPDATE_ANALYZER_ID, "--yes") + assert deleted.exit_code == 0, deleted.output + + @pytest.mark.skipif( mode() == "playback", reason="copy lifecycle requires a live custom source analyzer", diff --git a/cu-cli/packages/standalone/tests/unit/commands/test_analyzer_contract.py b/cu-cli/packages/standalone/tests/unit/commands/test_analyzer_contract.py index 1b0e8f7..201f13a 100644 --- a/cu-cli/packages/standalone/tests/unit/commands/test_analyzer_contract.py +++ b/cu-cli/packages/standalone/tests/unit/commands/test_analyzer_contract.py @@ -121,6 +121,150 @@ def test_analyzer_create_rejects_duplicate_name_before_client(monkeypatch): assert "provide name only once" in result.output +@pytest.mark.parametrize( + ("metadata_args", "expected_description", "expected_tags"), + [ + (("--description", "Updated"), "Updated", None), + (("--tag", "owner=cu-cli"), None, {"owner": "cu-cli"}), + ( + ( + "--description", + "Updated", + "--tag", + "owner=cu-cli", + "--tag", + "scenario=validation", + ), + "Updated", + {"owner": "cu-cli", "scenario": "validation"}, + ), + ], +) +def test_analyzer_update_supports_metadata_combinations( + monkeypatch, + metadata_args, + expected_description, + expected_tags, +): + monkeypatch.setattr( + "cu_cli.commands.analyzer._client", + lambda *_args, **_kwargs: object(), + ) + captured = {} + + def update(_client, analyzer_id, *, description, tags): + captured.update( + analyzer_id=analyzer_id, + description=description, + tags=tags, + ) + return SimpleNamespace(analyzer_id=analyzer_id) + + monkeypatch.setattr("cu_cli_core.operations.analyzers.update_analyzer", update) + + result = _run("analyzer", "update", "invoice_v1", *metadata_args) + + assert result.exit_code == 0, result.output + assert captured == { + "analyzer_id": "invoice_v1", + "description": expected_description, + "tags": expected_tags, + } + + +@pytest.mark.parametrize( + ("metadata_args", "message"), + [ + ((), "at least one metadata change"), + (("--tag", "owner"), "expected KEY=VALUE"), + (("--tag", "=cu-cli"), "non-empty key"), + (("--tag", " owner=cu-cli"), "cannot start or end with whitespace"), + (("--tag", "owner=one", "--tag", "owner=two"), "duplicate tag key"), + ], +) +def test_analyzer_update_rejects_invalid_input_before_client( + monkeypatch, + metadata_args, + message, +): + monkeypatch.setattr( + "cu_cli.commands.analyzer._client", + lambda *_args, **_kwargs: pytest.fail("client must not build"), + ) + + result = _run("analyzer", "update", "invoice_v1", *metadata_args) + + assert result.exit_code == 2 + assert message in result.output + + +def test_analyzer_update_help_is_metadata_only(): + result = _run("analyzer", "update", "--help") + + assert result.exit_code == 0, result.output + assert "--description" in result.output + assert "--tag" in result.output + assert "--schema" not in result.output + + +def test_analyzer_update_routes_standard_service_options(monkeypatch): + captured = {} + + def fake_client( + endpoint, + api_key, + api_version, + entra, + profile_name, + show_runtime_context, + ): + captured.update( + endpoint=endpoint, + api_key=api_key, + api_version=api_version, + entra=entra, + profile_name=profile_name, + show_runtime_context=show_runtime_context, + ) + return object() + + monkeypatch.setattr("cu_cli.commands.analyzer._client", fake_client) + monkeypatch.setattr( + "cu_cli_core.operations.analyzers.update_analyzer", + lambda _client, analyzer_id, **_kwargs: SimpleNamespace(analyzer_id=analyzer_id), + ) + + result = _run( + "analyzer", + "update", + "invoice_v1", + "--description", + "Updated", + "--endpoint", + "https://override.example/", + "--api-version", + "2026-06-01-preview", + "--auth-mode", + "login", + "--profile", + "prod", + "--info", + "--time", + ) + + assert result.exit_code == 0, result.output + assert captured == { + "endpoint": "https://override.example/", + "api_key": None, + "api_version": "2026-06-01-preview", + "entra": "login", + "profile_name": "prod", + "show_runtime_context": True, + } + assert "CU service calling time:" in result.output + assert "Total command time:" in result.output + + @pytest.mark.parametrize( "selector", [ diff --git a/cu-cli/packages/standalone/tests/unit/test_command_spec.py b/cu-cli/packages/standalone/tests/unit/test_command_spec.py index e970045..f989eb9 100644 --- a/cu-cli/packages/standalone/tests/unit/test_command_spec.py +++ b/cu-cli/packages/standalone/tests/unit/test_command_spec.py @@ -17,6 +17,7 @@ from cu_cli.cli import main from cu_cli_core.command_spec import ( ANALYZER_SHOW, + ANALYZER_UPDATE, COMMAND_SPECS, CommandBindingError, SurfaceClassification, @@ -32,6 +33,7 @@ def test_registry_uses_lazy_identifiers_and_unique_paths(): assert get_command_spec("analyzer", "show") is ANALYZER_SHOW + assert get_command_spec("analyzer", "update") is ANALYZER_UPDATE assert len({spec.path for spec in COMMAND_SPECS}) == len(COMMAND_SPECS) assert ANALYZER_SHOW.operation == "cu_cli_core.operations.analyzers#get_analyzer" assert ANALYZER_SHOW.request_type == "cu_cli_core.contracts#AnalyzerShowRequest" @@ -127,3 +129,14 @@ def test_analyzer_show_help_exposes_canonical_and_positional_forms(): assert "--name" in result.output assert "-n" in result.output assert "-a" in result.output + + +def test_analyzer_update_help_exposes_repeatable_metadata_options(): + result = CliRunner().invoke(main, ["analyzer", "update", "--help"]) + + assert result.exit_code == 0, result.output + assert "[ANALYZER_NAME]" in result.output + assert "--description" in result.output + assert "--tag" in result.output + assert "KEY=VALUE" in result.output + assert "--schema" not in result.output