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
3 changes: 3 additions & 0 deletions cu-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

### Features Added

- Added result limits, continuation tokens, and exact or prefix ID filtering to
`cu analyzer list`, while preserving complete array output for unlimited JSON
queries.
- 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
24 changes: 24 additions & 0 deletions cu-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,32 @@ List the prebuilt analyzers available to the configured resource:

```bash
cu analyzer list

# Limit output and filter analyzer IDs by a case-sensitive prefix.
cu analyzer list --id-prefix prebuilt-tax --limit 25

# Retrieve one exact analyzer ID without scanning the analyzer collection.
cu analyzer list --id prebuilt-layout --json
```

With `--limit`, JSON output is an object containing `items` and
`continuationToken`. Pass the token back with the same filters and sort order to
read the next result page:

```bash
cu analyzer list \
--id-prefix prebuilt-tax \
--limit 25 \
--continuation-token <TOKEN> \
--json
```

Without `--limit`, `--json` retains the original complete JSON array output.
The service currently exposes collection paging but no list filter or sort
parameters, so prefix filtering and sorting require the CLI to read all service
pages. Continuation results reflect the current analyzer collection and are not
a snapshot.

Start with the `prebuilt-layout` content extraction analyzer. It extracts text,
paragraphs, tables, figures, and document structure without requiring a language
model or embeddings model. `-a` is the short form of `--analyzer`:
Expand Down
41 changes: 41 additions & 0 deletions cu-cli/docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -552,13 +552,54 @@ Hyphens are reserved for service-provided prebuilt analyzer IDs.
# List analyzers available on the selected resource.
cu analyzer list

# Return one analyzer by exact ID. This uses the analyzer GET operation.
cu analyzer list --id invoice_v1 --json

# Return up to 20 matching custom analyzers.
cu analyzer list \
--id-prefix invoice_ \
--kind custom \
--sort-by analyzerId \
--limit 20 \
--json

# Continue the same query with the token returned by the previous command.
cu analyzer list \
--id-prefix invoice_ \
--kind custom \
--sort-by analyzerId \
--limit 20 \
--continuation-token <TOKEN> \
--json

# Print one analyzer definition.
cu analyzer show invoice_v1

# Delete a custom analyzer after confirmation.
cu analyzer delete invoice_v1
```

`--id` and `--id-prefix` are mutually exclusive, and ID matching is
case-sensitive. A limited JSON result has this shape:

```json
{
"items": [],
"continuationToken": "<TOKEN>"
}
```

Repeat the same `--id`, `--id-prefix`, `--kind`, and `--sort-by` values when
using a continuation token. The page size may change. A missing
`continuationToken` value means there are no more matches. Without `--limit`,
the command reads all pages and preserves the original JSON array output.

The Content Understanding list API currently provides `nextLink` paging but no
server-side analyzer ID filter or sort parameters. The CLI therefore reads all
service pages before applying prefix, kind, and sort options. Continuation is a
stable CLI cursor over the current collection, not a service snapshot; analyzers
created, deleted, or modified between commands can affect later pages.

### Create a schema

Generate a starter schema:
Expand Down
28 changes: 27 additions & 1 deletion cu-cli/packages/core/src/cu_cli_core/command_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,18 @@ def _profile_name_arguments(option_help: str) -> tuple[ArgumentSpec, ...]:
operation="cu_cli_core.operations.analyzers#list_analyzers",
request_type="cu_cli_core.contracts#AnalyzerListRequest",
arguments=(
ArgumentSpec(
"--id",
field="analyzer_id",
parser_name="analyzer_id",
help="Return the analyzer with this exact ID.",
),
ArgumentSpec(
"--id-prefix",
field="id_prefix",
parser_name="id_prefix",
help="Filter analyzer IDs by a case-sensitive prefix.",
),
ArgumentSpec(
"--kind",
field="kind",
Expand All @@ -402,11 +414,25 @@ def _profile_name_arguments(option_help: str) -> tuple[ArgumentSpec, ...]:
default="analyzerId",
choices=("analyzerId", "createdAt", "lastModifiedAt"),
),
ArgumentSpec(
"--limit",
field="limit",
parser_name="limit",
help="Maximum number of analyzers to return.",
value_type=ArgumentValueType.INTEGER,
minimum=1,
),
ArgumentSpec(
"--continuation-token",
field="continuation_token",
parser_name="continuation_token",
help="Continue a limited query using the token from the previous result.",
),
ArgumentSpec(
"--json",
field="json",
parser_name="json_output",
help="Write the complete result as JSON.",
help="Write JSON; limited results include a continuation token.",
value_type=ArgumentValueType.BOOLEAN,
classification=SurfaceClassification.FRONTEND_PRESENTATION,
),
Expand Down
25 changes: 25 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 @@ -127,12 +127,37 @@ def __post_init__(self) -> None:
class AnalyzerListRequest:
kind: str = "all"
sort_by: str = "analyzerId"
analyzer_id: str | None = None
id_prefix: str | None = None
limit: int | None = None
continuation_token: str | None = None

def __post_init__(self) -> None:
if self.kind not in {"all", "prebuilt", "custom"}:
raise ValueError(f"invalid analyzer kind: {self.kind}")
if self.sort_by not in {"analyzerId", "createdAt", "lastModifiedAt"}:
raise ValueError(f"invalid analyzer sort field: {self.sort_by}")
if self.analyzer_id is not None and self.id_prefix is not None:
raise ValueError("--id and --id-prefix cannot be used together.")
for field_name in ("analyzer_id", "id_prefix", "continuation_token"):
value = getattr(self, field_name)
if value is None:
continue
normalized = value.strip()
if not normalized:
option = field_name.replace("analyzer_id", "id").replace("_", "-")
raise ValueError(f"--{option} cannot be empty.")
object.__setattr__(self, field_name, normalized)
if self.limit is not None and self.limit < 1:
raise ValueError("--limit must be greater than zero.")
if self.continuation_token is not None and self.limit is None:
raise ValueError("--continuation-token requires --limit.")


@dataclass(frozen=True)
class AnalyzerListResult:
items: tuple[Any, ...]
continuation_token: str | None = None


@dataclass(frozen=True)
Expand Down
134 changes: 125 additions & 9 deletions 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,23 @@

from __future__ import annotations

import base64
import binascii
import hashlib
import json
from typing import Any

from ..errors import ConflictError, NotFoundError, ServiceError
from ..contracts import AnalyzerListResult
from ..errors import ConflictError, NotFoundError, ServiceError, ValidationError


_CONTINUATION_TOKEN_VERSION = 1
_MAX_CONTINUATION_TOKEN_LENGTH = 4096
_SORT_KEYS = {
"analyzerId": ("analyzer_id", "analyzerId"),
"createdAt": ("created_at", "createdAt"),
"lastModifiedAt": ("last_modified_at", "lastModifiedAt"),
}


def _value(analyzer: Any, *keys: str) -> str:
Expand All @@ -28,16 +42,118 @@ def list_analyzers(
*,
kind: str = "all",
sort_by: str = "analyzerId",
) -> list[Any]:
items = list(client.list_analyzers())
analyzer_id: str | None = None,
id_prefix: str | None = None,
limit: int | None = None,
continuation_token: str | None = None,
) -> AnalyzerListResult:
query_fingerprint = _query_fingerprint(kind, sort_by, analyzer_id, id_prefix)
cursor = (
_decode_continuation_token(continuation_token, query_fingerprint)
if continuation_token is not None
else None
)

if analyzer_id is None:
items = list(client.list_analyzers())
else:
from azure.core.exceptions import ResourceNotFoundError

try:
items = [client.get_analyzer(analyzer_id)]
except ResourceNotFoundError:
items = []

if kind != "all":
items = [item for item in items if analyzer_kind(item) == kind]
sort_keys = {
"analyzerId": ("analyzer_id", "analyzerId"),
"createdAt": ("created_at", "createdAt"),
"lastModifiedAt": ("last_modified_at", "lastModifiedAt"),
}
return sorted(items, key=lambda item: _value(item, *sort_keys[sort_by]))
if id_prefix is not None:
items = [
item
for item in items
if _value(item, "analyzer_id", "analyzerId").startswith(id_prefix)
]

items.sort(key=lambda item: _sort_key(item, sort_by))
if cursor is not None:
items = [item for item in items if _sort_key(item, sort_by) > cursor]

page_items = items if limit is None else items[:limit]
next_token = None
if limit is not None and len(items) > limit:
next_token = _encode_continuation_token(
_sort_key(page_items[-1], sort_by),
query_fingerprint,
)
return AnalyzerListResult(tuple(page_items), next_token)


def _sort_key(analyzer: Any, sort_by: str) -> tuple[str, str]:
analyzer_id = _value(analyzer, "analyzer_id", "analyzerId")
return _value(analyzer, *_SORT_KEYS[sort_by]), analyzer_id


def _query_fingerprint(
kind: str,
sort_by: str,
analyzer_id: str | None,
id_prefix: str | None,
) -> str:
query = json.dumps(
{
"analyzerId": analyzer_id,
"idPrefix": id_prefix,
"kind": kind,
"sortBy": sort_by,
},
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(query).hexdigest()


def _encode_continuation_token(cursor: tuple[str, str], query_fingerprint: str) -> str:
payload = json.dumps(
{
"version": _CONTINUATION_TOKEN_VERSION,
"query": query_fingerprint,
"after": list(cursor),
},
separators=(",", ":"),
).encode("utf-8")
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")


def _decode_continuation_token(
token: str,
query_fingerprint: str,
) -> tuple[str, str]:
if len(token) > _MAX_CONTINUATION_TOKEN_LENGTH:
raise ValidationError("invalid continuation token.")
try:
padding = "=" * (-len(token) % 4)
decoded = base64.b64decode(
(token + padding).encode("ascii"),
altchars=b"-_",
validate=True,
)
payload = json.loads(decoded.decode("utf-8"))
except (binascii.Error, UnicodeError, ValueError):
raise ValidationError("invalid continuation token.") from None

if not isinstance(payload, dict) or payload.get("version") != _CONTINUATION_TOKEN_VERSION:
raise ValidationError("invalid continuation token.")
if payload.get("query") != query_fingerprint:
raise ValidationError(
"continuation token does not match the current analyzer filters and sort order."
)
cursor = payload.get("after")
if (
not isinstance(cursor, list)
or len(cursor) != 2
or not all(isinstance(value, str) for value in cursor)
):
raise ValidationError("invalid continuation token.")
return cursor[0], cursor[1]


def analyzer_kind(analyzer: Any) -> str:
Expand Down
10 changes: 9 additions & 1 deletion cu-cli/packages/core/tests/test_command_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,11 +199,19 @@ def test_defaults_set_does_not_implicitly_select_profile_mappings():
def test_frontend_presentation_arguments_are_not_bound_to_core_request():
request = build_request(
ANALYZER_LIST,
{"kind": "custom", "sort_by": "createdAt", "json_output": True},
{
"id_prefix": "invoice_",
"kind": "custom",
"sort_by": "createdAt",
"limit": 25,
"json_output": True,
},
)

assert request.id_prefix == "invoice_"
assert request.kind == "custom"
assert request.sort_by == "createdAt"
assert request.limit == 25


@pytest.mark.parametrize(
Expand Down
Loading
Loading