Skip to content

Commit 3e96bf9

Browse files
chore: strengthen CLI quality gates (#17)
* chore: strengthen CLI quality gates * chore: remove semgrep gate --------- Co-authored-by: Alex Kroman <alex@assemblyai.com>
1 parent 42a0b58 commit 3e96bf9

98 files changed

Lines changed: 3806 additions & 1035 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ build/
1010
.coverage
1111
coverage.xml
1212
htmlcov/
13+
mutants/
1314

1415
# Editor/agent local artifacts: keep personal settings local, but track the
1516
# team-shared bits (.claude/settings.json, agents/, skills/).

.importlinter

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
[importlinter]
2+
root_package = aai_cli
3+
include_external_packages = True
4+
5+
[importlinter:contract:1]
6+
name = Core modules do not import command modules
7+
type = forbidden
8+
source_modules =
9+
aai_cli.agent
10+
aai_cli.auth
11+
aai_cli.client
12+
aai_cli.code_gen
13+
aai_cli.config
14+
aai_cli.config_builder
15+
aai_cli.context
16+
aai_cli.environments
17+
aai_cli.errors
18+
aai_cli.follow
19+
aai_cli.help_panels
20+
aai_cli.help_text
21+
aai_cli.init
22+
aai_cli.llm
23+
aai_cli.microphone
24+
aai_cli.output
25+
aai_cli.render
26+
aai_cli.stdio
27+
aai_cli.streaming
28+
aai_cli.theme
29+
aai_cli.transcribe_render
30+
aai_cli.youtube
31+
forbidden_modules =
32+
aai_cli.commands
33+
34+
[importlinter:contract:2]
35+
name = Command modules are independent
36+
type = independence
37+
modules =
38+
aai_cli.commands.account
39+
aai_cli.commands.agent
40+
aai_cli.commands.audit
41+
aai_cli.commands.claude
42+
aai_cli.commands.doctor
43+
aai_cli.commands.init
44+
aai_cli.commands.keys
45+
aai_cli.commands.llm
46+
aai_cli.commands.login
47+
aai_cli.commands.samples
48+
aai_cli.commands.sessions
49+
aai_cli.commands.stream
50+
aai_cli.commands.transcribe
51+
aai_cli.commands.transcripts
52+
53+
[importlinter:contract:3]
54+
name = Library layers do not depend on Rich rendering
55+
type = forbidden
56+
source_modules =
57+
aai_cli.client
58+
aai_cli.config
59+
aai_cli.config_builder
60+
aai_cli.environments
61+
aai_cli.errors
62+
aai_cli.llm
63+
forbidden_modules =
64+
rich

aai_cli/agent/audio.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from typing import Any
88

99
from aai_cli.errors import CLIError
10-
from aai_cli.microphone import _default_rate, _resample, audio_missing_error
10+
from aai_cli.microphone import audio_missing_error, default_rate, resample_pcm16
1111

1212
SAMPLE_RATE = 24000 # Voice Agent native PCM16 mono rate
1313

@@ -19,7 +19,7 @@ def _output_default_rate(device: int | None = None) -> int:
1919
'paramErr' (-50) from forcing an unsupported one; agent audio (24 kHz) is
2020
resampled to it. Falls back to a safe default when the device can't be queried.
2121
"""
22-
return _default_rate("output", device)
22+
return default_rate("output", device)
2323

2424

2525
class NullPlayer:
@@ -141,7 +141,7 @@ def start(self) -> None:
141141
def feed(self, pcm: bytes) -> None:
142142
"""Queue target-rate PCM for playback, resampled to the device rate."""
143143
if self._device_rate != self._target:
144-
pcm, self._out_state = _resample(
144+
pcm, self._out_state = resample_pcm16(
145145
pcm, self._out_state, src_rate=self._target, dst_rate=self._device_rate
146146
)
147147
with self._lock:
@@ -163,7 +163,7 @@ def capture_frames(self) -> Iterator[bytes]:
163163
if chunk is None:
164164
return
165165
if self._device_rate != self._target:
166-
chunk, state = _resample(
166+
chunk, state = resample_pcm16(
167167
chunk, state, src_rate=self._device_rate, dst_rate=self._target
168168
)
169169
yield chunk

aai_cli/agent/session.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -71,41 +71,41 @@ def dispatch(self, event: dict[str, Any]) -> None:
7171
if handler is not None:
7272
handler(self, event)
7373

74-
def _on_session_ready(self, _event: dict[str, Any]) -> None:
74+
def on_session_ready(self, _event: dict[str, Any]) -> None:
7575
with self._lock:
7676
self.ready = True
7777
if self.ready_event is not None:
7878
self.ready_event.set()
7979
self.renderer.connected()
8080

81-
def _on_speech_started(self, _event: dict[str, Any]) -> None:
81+
def on_speech_started(self, _event: dict[str, Any]) -> None:
8282
if self.full_duplex:
8383
self.player.flush()
8484

85-
def _on_user_delta(self, event: dict[str, Any]) -> None:
85+
def on_user_delta(self, event: dict[str, Any]) -> None:
8686
self.renderer.user_partial(event.get("text", ""))
8787

88-
def _on_user_final(self, event: dict[str, Any]) -> None:
88+
def on_user_final(self, event: dict[str, Any]) -> None:
8989
self._saw_user = True
9090
self.renderer.user_final(event.get("text", ""))
9191

92-
def _on_reply_started(self, _event: dict[str, Any]) -> None:
92+
def on_reply_started(self, _event: dict[str, Any]) -> None:
9393
if not self.full_duplex:
9494
with self._lock:
9595
self.muted = True
9696
self.renderer.reply_started()
9797

98-
def _on_reply_audio(self, event: dict[str, Any]) -> None:
98+
def on_reply_audio(self, event: dict[str, Any]) -> None:
9999
data = event.get("data")
100100
if data:
101101
self.player.enqueue(base64.b64decode(data))
102102

103-
def _on_agent_transcript(self, event: dict[str, Any]) -> None:
103+
def on_agent_transcript(self, event: dict[str, Any]) -> None:
104104
self.renderer.agent_transcript(
105105
event.get("text", ""), interrupted=bool(event.get("interrupted", False))
106106
)
107107

108-
def _on_reply_done(self, event: dict[str, Any]) -> None:
108+
def on_reply_done(self, event: dict[str, Any]) -> None:
109109
if not self.full_duplex:
110110
with self._lock:
111111
self.muted = False
@@ -117,7 +117,7 @@ def _on_reply_done(self, event: dict[str, Any]) -> None:
117117
if self.exit_after_reply and self._saw_user and not interrupted:
118118
self.finished = True
119119

120-
def _raise_error(self, event: dict[str, Any]) -> None:
120+
def raise_error(self, event: dict[str, Any]) -> None:
121121
code = event.get("code", "")
122122
message = event.get("message") or code or "Voice agent error."
123123
if code in _AUTH_ERROR_CODES:
@@ -132,15 +132,15 @@ def _raise_error(self, event: dict[str, Any]) -> None:
132132
# Server event type -> the VoiceAgentSession method that handles it. Types absent
133133
# here (input.speech.stopped, tool.call, anything unrecognized) are ignored.
134134
_EVENT_HANDLERS: dict[str, Callable[[VoiceAgentSession, dict[str, Any]], None]] = {
135-
"session.ready": VoiceAgentSession._on_session_ready,
136-
"input.speech.started": VoiceAgentSession._on_speech_started,
137-
"transcript.user.delta": VoiceAgentSession._on_user_delta,
138-
"transcript.user": VoiceAgentSession._on_user_final,
139-
"reply.started": VoiceAgentSession._on_reply_started,
140-
"reply.audio": VoiceAgentSession._on_reply_audio,
141-
"transcript.agent": VoiceAgentSession._on_agent_transcript,
142-
"reply.done": VoiceAgentSession._on_reply_done,
143-
"session.error": VoiceAgentSession._raise_error,
135+
"session.ready": VoiceAgentSession.on_session_ready,
136+
"input.speech.started": VoiceAgentSession.on_speech_started,
137+
"transcript.user.delta": VoiceAgentSession.on_user_delta,
138+
"transcript.user": VoiceAgentSession.on_user_final,
139+
"reply.started": VoiceAgentSession.on_reply_started,
140+
"reply.audio": VoiceAgentSession.on_reply_audio,
141+
"transcript.agent": VoiceAgentSession.on_agent_transcript,
142+
"reply.done": VoiceAgentSession.on_reply_done,
143+
"session.error": VoiceAgentSession.raise_error,
144144
}
145145

146146

aai_cli/auth/ams.py

Lines changed: 63 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,60 @@
11
from __future__ import annotations
22

3-
from typing import Any, cast
4-
53
import httpx2 as httpx
4+
from pydantic import TypeAdapter, ValidationError
65

76
from aai_cli.auth import endpoints
87
from aai_cli.errors import APIError, NotAuthenticated
98

109
_TIMEOUT = 30.0
10+
_HTTP_ERROR_MIN_STATUS = 400
11+
_JSON_OBJECT: TypeAdapter[dict[str, object]] = TypeAdapter(dict[str, object])
12+
_JSON_OBJECTS: TypeAdapter[list[dict[str, object]]] = TypeAdapter(list[dict[str, object]])
1113

1214

1315
def _detail(resp: httpx.Response) -> str:
16+
fallback = resp.text or f"HTTP {resp.status_code}"
1417
try:
15-
body = resp.json()
16-
if isinstance(body, dict) and "detail" in body:
17-
return str(body["detail"])
18-
except Exception: # noqa: BLE001,S110 - non-JSON error body; pass is intentional
19-
pass
20-
return resp.text or f"HTTP {resp.status_code}"
18+
body: object = resp.json()
19+
mapping = _JSON_OBJECT.validate_python(body)
20+
if "detail" in mapping:
21+
return str(mapping["detail"])
22+
except (TypeError, ValueError, ValidationError):
23+
return fallback
24+
return fallback
2125

2226

2327
def _raise_for_error(resp: httpx.Response) -> None:
2428
if resp.status_code in (401, 403):
2529
raise NotAuthenticated(f"AMS rejected the login ({resp.status_code}): {_detail(resp)}")
26-
if resp.status_code >= 400:
30+
if resp.status_code >= _HTTP_ERROR_MIN_STATUS:
2731
raise APIError(f"AMS request failed ({resp.status_code}): {_detail(resp)}")
2832

2933

30-
def _json_or_raise(resp: httpx.Response) -> Any:
34+
def _json_or_raise(resp: httpx.Response) -> object:
3135
_raise_for_error(resp)
32-
return resp.json()
36+
data: object = resp.json()
37+
return data
38+
39+
40+
def _json_object_or_raise(resp: httpx.Response) -> dict[str, object]:
41+
data = _json_or_raise(resp)
42+
try:
43+
return _JSON_OBJECT.validate_python(data)
44+
except ValidationError as exc:
45+
raise APIError(
46+
f"AMS request returned unexpected JSON: expected object, got {type(data).__name__}."
47+
) from exc
48+
49+
50+
def _json_object_list_or_raise(resp: httpx.Response) -> list[dict[str, object]]:
51+
data = _json_or_raise(resp)
52+
try:
53+
return _JSON_OBJECTS.validate_python(data)
54+
except ValidationError as exc:
55+
raise APIError(
56+
f"AMS request returned unexpected JSON: expected list of objects, got {type(data).__name__}."
57+
) from exc
3358

3459

3560
def _client(session_jwt: str | None = None) -> httpx.Client:
@@ -38,17 +63,17 @@ def _client(session_jwt: str | None = None) -> httpx.Client:
3863
return httpx.Client(base_url=endpoints.ams_base(), timeout=_TIMEOUT, cookies=cookies)
3964

4065

41-
def discover(token: str) -> dict[str, Any]:
66+
def discover(token: str) -> dict[str, object]:
4267
"""POST /v2/auth/discover with a discovery_oauth token -> {orgs, email, IST}."""
4368
with _client() as client:
4469
resp = client.post(
4570
"/v2/auth/discover",
4671
json={"token": token, "token_type": "discovery_oauth"},
4772
)
48-
return cast(dict[str, Any], _json_or_raise(resp))
73+
return _json_object_or_raise(resp)
4974

5075

51-
def exchange(intermediate_session_token: str, organization_id: str) -> dict[str, Any]:
76+
def exchange(intermediate_session_token: str, organization_id: str) -> dict[str, object]:
5277
"""POST /v2/auth/exchange -> SignedInResponse {account, session_jwt, session_token}."""
5378
with _client() as client:
5479
resp = client.post(
@@ -58,55 +83,55 @@ def exchange(intermediate_session_token: str, organization_id: str) -> dict[str,
5883
"organization_id": organization_id,
5984
},
6085
)
61-
return cast(dict[str, Any], _json_or_raise(resp))
86+
return _json_object_or_raise(resp)
6287

6388

64-
def list_projects(account_id: int, session_jwt: str) -> list[dict[str, Any]]:
89+
def list_projects(account_id: int, session_jwt: str) -> list[dict[str, object]]:
6590
"""GET /v1/users/accounts/{id}/projects -> [{project, tokens[]}]."""
6691
with _client(session_jwt) as client:
6792
resp = client.get(f"/v1/users/accounts/{account_id}/projects")
68-
return cast(list[dict[str, Any]], _json_or_raise(resp))
93+
return _json_object_list_or_raise(resp)
6994

7095

7196
def create_token(
7297
account_id: int, project_id: int, token_name: str, session_jwt: str
73-
) -> dict[str, Any]:
98+
) -> dict[str, object]:
7499
"""POST /v1/users/accounts/{id}/tokens -> TokenSchema incl. `api_key`."""
75100
with _client(session_jwt) as client:
76101
resp = client.post(
77102
f"/v1/users/accounts/{account_id}/tokens",
78103
json={"project_id": project_id, "token_name": token_name},
79104
)
80-
return cast(dict[str, Any], _json_or_raise(resp))
105+
return _json_object_or_raise(resp)
81106

82107

83-
def get_balance(session_jwt: str) -> dict[str, Any]:
108+
def get_balance(session_jwt: str) -> dict[str, object]:
84109
"""GET /v2/billing/balance -> {account_id, balance_in_cents, ...}."""
85110
with _client(session_jwt) as client:
86111
resp = client.get("/v2/billing/balance")
87-
return cast(dict[str, Any], _json_or_raise(resp))
112+
return _json_object_or_raise(resp)
88113

89114

90115
def get_usage(
91116
session_jwt: str,
92117
starting_on: str,
93118
ending_before: str,
94119
window_size: str | None = None,
95-
) -> dict[str, Any]:
120+
) -> dict[str, object]:
96121
"""POST /v2/billing/usage (ISO dates) -> {usage_items: [...]}."""
97-
body: dict[str, Any] = {"starting_on": starting_on, "ending_before": ending_before}
122+
body: dict[str, object] = {"starting_on": starting_on, "ending_before": ending_before}
98123
if window_size:
99124
body["window_size"] = window_size
100125
with _client(session_jwt) as client:
101126
resp = client.post("/v2/billing/usage", json=body)
102-
return cast(dict[str, Any], _json_or_raise(resp))
127+
return _json_object_or_raise(resp)
103128

104129

105-
def get_rate_limits(account_id: int, session_jwt: str) -> dict[str, Any]:
130+
def get_rate_limits(account_id: int, session_jwt: str) -> dict[str, object]:
106131
"""GET /v1/users/accounts/{id}/rate-limits -> {rate_limits: [...]}."""
107132
with _client(session_jwt) as client:
108133
resp = client.get(f"/v1/users/accounts/{account_id}/rate-limits")
109-
return cast(dict[str, Any], _json_or_raise(resp))
134+
return _json_object_or_raise(resp)
110135

111136

112137
def rename_token(account_id: int, token_id: int, token_name: str, session_jwt: str) -> None:
@@ -119,24 +144,28 @@ def rename_token(account_id: int, token_id: int, token_name: str, session_jwt: s
119144
_raise_for_error(resp)
120145

121146

122-
def list_streaming(session_jwt: str, **filters: Any) -> dict[str, Any]:
147+
def list_streaming(session_jwt: str, **filters: object) -> dict[str, object]:
123148
"""GET /v1/users/streaming -> {page_details, data: [StreamingSessionSchema]}."""
124-
params = {k: v for k, v in filters.items() if v is not None}
149+
params = {
150+
key: value for key, value in filters.items() if isinstance(value, str | int | float | bool)
151+
}
125152
with _client(session_jwt) as client:
126153
resp = client.get("/v1/users/streaming", params=params)
127-
return cast(dict[str, Any], _json_or_raise(resp))
154+
return _json_object_or_raise(resp)
128155

129156

130-
def get_streaming(session_id: str, session_jwt: str) -> dict[str, Any]:
157+
def get_streaming(session_id: str, session_jwt: str) -> dict[str, object]:
131158
"""GET /v1/users/streaming/{session_id} -> StreamingSessionSchema."""
132159
with _client(session_jwt) as client:
133160
resp = client.get(f"/v1/users/streaming/{session_id}")
134-
return cast(dict[str, Any], _json_or_raise(resp))
161+
return _json_object_or_raise(resp)
135162

136163

137-
def list_audit_logs(session_jwt: str, **filters: Any) -> dict[str, Any]:
164+
def list_audit_logs(session_jwt: str, **filters: object) -> dict[str, object]:
138165
"""GET /v2/user/audit-logs -> {page_details, data: [AuditLogResponse]}."""
139-
params = {k: v for k, v in filters.items() if v is not None}
166+
params = {
167+
key: value for key, value in filters.items() if isinstance(value, str | int | float | bool)
168+
}
140169
with _client(session_jwt) as client:
141170
resp = client.get("/v2/user/audit-logs", params=params)
142-
return cast(dict[str, Any], _json_or_raise(resp))
171+
return _json_object_or_raise(resp)

0 commit comments

Comments
 (0)