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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cu-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 12 additions & 1 deletion cu-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`.

Expand All @@ -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. |
Expand Down
12 changes: 12 additions & 0 deletions cu-cli/docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
40 changes: 40 additions & 0 deletions cu-cli/packages/core/src/cu_cli_core/command_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions cu-cli/packages/core/src/cu_cli_core/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 42 additions & 1 deletion cu-cli/packages/core/src/cu_cli_core/operations/analyzers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
25 changes: 24 additions & 1 deletion cu-cli/packages/core/tests/test_command_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from cu_cli_core.command_spec import (
ANALYZER_LIST,
ANALYZER_SHOW,
ANALYZER_UPDATE,
COMMAND_SPECS,
DEFAULTS_SET,
PROFILE_COPY,
Expand All @@ -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

Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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")
Expand Down
42 changes: 42 additions & 0 deletions cu-cli/packages/core/tests/test_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from cu_cli_core.contracts import (
AnalyzerShowRequest,
AnalyzerUpdateRequest,
BatchReport,
FileOutcome,
OutcomeStatus,
Expand All @@ -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(
(
Expand Down
Loading
Loading