convergent-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of convergent-cli might be problematic. Click here for more details.
- convergent_cli/__init__.py +1 -0
- convergent_cli/auth.py +227 -0
- convergent_cli/build_context.py +225 -0
- convergent_cli/calibration_finish.py +310 -0
- convergent_cli/calibration_frontier.py +499 -0
- convergent_cli/calibration_runner.py +1413 -0
- convergent_cli/calibration_send.py +44 -0
- convergent_cli/codex_app_server.py +127 -0
- convergent_cli/command_tree.py +102 -0
- convergent_cli/commands/__init__.py +58 -0
- convergent_cli/commands/agents.py +729 -0
- convergent_cli/commands/auth.py +117 -0
- convergent_cli/commands/calibration.py +3857 -0
- convergent_cli/commands/calibration_labels_load.py +627 -0
- convergent_cli/commands/capabilities.py +85 -0
- convergent_cli/commands/dataset_authoring.py +964 -0
- convergent_cli/commands/dataset_import.py +763 -0
- convergent_cli/commands/datasets.py +678 -0
- convergent_cli/commands/docs.py +120 -0
- convergent_cli/commands/doctor.py +124 -0
- convergent_cli/commands/environments.py +63 -0
- convergent_cli/commands/findings.py +206 -0
- convergent_cli/commands/graders.py +803 -0
- convergent_cli/commands/harness.py +366 -0
- convergent_cli/commands/issues.py +388 -0
- convergent_cli/commands/production.py +173 -0
- convergent_cli/commands/profile.py +86 -0
- convergent_cli/commands/rubrics.py +14 -0
- convergent_cli/commands/runs.py +1763 -0
- convergent_cli/commands/setup.py +968 -0
- convergent_cli/commands/task_qc.py +1041 -0
- convergent_cli/commands/task_qc_reports.py +393 -0
- convergent_cli/commands/verdicts.py +156 -0
- convergent_cli/commands/workspaces.py +170 -0
- convergent_cli/commands/worlds.py +292 -0
- convergent_cli/confirm.py +59 -0
- convergent_cli/control_client.py +141 -0
- convergent_cli/customer_client.py +1272 -0
- convergent_cli/docs_client.py +165 -0
- convergent_cli/errors.py +212 -0
- convergent_cli/harness_run.py +1169 -0
- convergent_cli/harness_tree.py +854 -0
- convergent_cli/installed_agent.py +1071 -0
- convergent_cli/json_files.py +23 -0
- convergent_cli/local_files.py +64 -0
- convergent_cli/main.py +106 -0
- convergent_cli/oauth.py +431 -0
- convergent_cli/output.py +15 -0
- convergent_cli/paging.py +62 -0
- convergent_cli/run_calibration.py +361 -0
- convergent_cli/run_usage.py +99 -0
- convergent_cli/sessions.py +275 -0
- convergent_cli/setup_agents.py +190 -0
- convergent_cli/setup_auth.py +463 -0
- convergent_cli/setup_instrument.py +244 -0
- convergent_cli/setup_mcp.py +525 -0
- convergent_cli/setup_wizard.py +125 -0
- convergent_cli/skill_files/convergent/SKILL.md +25 -0
- convergent_cli/skill_files/convergent-instrument/SKILL.md +15 -0
- convergent_cli/skill_files/convergent-verify/SKILL.md +15 -0
- convergent_cli/skills.py +489 -0
- convergent_cli/state.py +297 -0
- convergent_cli/transport.py +242 -0
- convergent_cli/verdict_feedback.py +161 -0
- convergent_cli/verifier_bench_preflight.py +32 -0
- convergent_cli/world_docker.py +145 -0
- convergent_cli/world_files.py +432 -0
- convergent_cli/world_live.py +660 -0
- convergent_cli/world_mcp.py +109 -0
- convergent_cli/world_probe_requirements.txt +29 -0
- convergent_cli/world_template/Dockerfile +10 -0
- convergent_cli/world_template/README.md +38 -0
- convergent_cli/world_template/convergent.toml +11 -0
- convergent_cli/world_template/examples/default/environment/catalog.json +3 -0
- convergent_cli/world_template/examples/default/instruction.md +1 -0
- convergent_cli/world_template/requirements.txt +1 -0
- convergent_cli/world_template/schemas/catalog.json +5 -0
- convergent_cli/world_template/server.py +25 -0
- convergent_cli-0.1.0.dist-info/METADATA +679 -0
- convergent_cli-0.1.0.dist-info/RECORD +83 -0
- convergent_cli-0.1.0.dist-info/WHEEL +4 -0
- convergent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- convergent_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
convergent_cli/state.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Local profile state for the CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
import stat
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from types import MappingProxyType
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
|
|
16
|
+
from convergent_cli.errors import UsageError
|
|
17
|
+
|
|
18
|
+
SCHEMA_VERSION = 1
|
|
19
|
+
ENV_CONFIG_FILE = "CONVERGENT_CLI_CONFIG"
|
|
20
|
+
ENV_PROFILE = "CONVERGENT_CLI_PROFILE"
|
|
21
|
+
DEFAULT_PROFILE_NAME = "default"
|
|
22
|
+
DEFAULT_ORIGIN = "https://app.convergent.dev"
|
|
23
|
+
|
|
24
|
+
_PROFILE_NAME = re.compile(r"[A-Za-z0-9_-][A-Za-z0-9._-]{0,63}\Z")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfigError(UsageError):
|
|
28
|
+
"""The local cv config cannot be used."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UnknownProfileError(ConfigError):
|
|
32
|
+
"""A profile name was valid but not configured."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class Profile:
|
|
37
|
+
origin: str
|
|
38
|
+
workspace_id: str | None = None
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def parse(cls, name: str, raw: object) -> Profile:
|
|
42
|
+
if not isinstance(raw, dict):
|
|
43
|
+
raise ConfigError(f"profile {name!r} must be an object")
|
|
44
|
+
extra = set(raw) - {"origin", "workspace_id"}
|
|
45
|
+
if extra:
|
|
46
|
+
unsupported = ", ".join(sorted(extra))
|
|
47
|
+
raise ConfigError(f"profile {name!r} has unsupported fields: {unsupported}")
|
|
48
|
+
origin = raw.get("origin")
|
|
49
|
+
if not isinstance(origin, str):
|
|
50
|
+
raise ConfigError(f"profile {name!r} must declare an origin")
|
|
51
|
+
workspace_id = raw.get("workspace_id")
|
|
52
|
+
if workspace_id is not None and not isinstance(workspace_id, str):
|
|
53
|
+
raise ConfigError(f"profile {name!r} workspace_id must be a string")
|
|
54
|
+
return cls(
|
|
55
|
+
origin=validate_origin(origin),
|
|
56
|
+
workspace_id=validate_workspace_id(workspace_id) if workspace_id is not None else None,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def to_json(self) -> dict[str, str]:
|
|
60
|
+
payload = {"origin": self.origin}
|
|
61
|
+
if self.workspace_id is not None:
|
|
62
|
+
payload["workspace_id"] = self.workspace_id
|
|
63
|
+
return payload
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class Config:
|
|
68
|
+
active_profile: str
|
|
69
|
+
profiles: Mapping[str, Profile]
|
|
70
|
+
schema_version: int = SCHEMA_VERSION
|
|
71
|
+
|
|
72
|
+
def __post_init__(self) -> None:
|
|
73
|
+
object.__setattr__(self, "profiles", MappingProxyType(dict(self.profiles)))
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def parse(cls, raw: object) -> Config:
|
|
77
|
+
if not isinstance(raw, dict):
|
|
78
|
+
raise ConfigError("config must be a JSON object")
|
|
79
|
+
extra = set(raw) - {"schema_version", "active_profile", "profiles"}
|
|
80
|
+
if extra:
|
|
81
|
+
raise ConfigError(f"config has unsupported fields: {', '.join(sorted(extra))}")
|
|
82
|
+
|
|
83
|
+
version = raw.get("schema_version")
|
|
84
|
+
if type(version) is int and version > SCHEMA_VERSION:
|
|
85
|
+
raise ConfigError(
|
|
86
|
+
f"config schema version {version} is newer than this cv supports ({SCHEMA_VERSION})"
|
|
87
|
+
)
|
|
88
|
+
if type(version) is not int or version != SCHEMA_VERSION:
|
|
89
|
+
raise ConfigError(f"config schema version must be {SCHEMA_VERSION}")
|
|
90
|
+
|
|
91
|
+
active_profile = raw.get("active_profile")
|
|
92
|
+
if not isinstance(active_profile, str):
|
|
93
|
+
raise ConfigError("active_profile must be a string")
|
|
94
|
+
validate_profile_name(active_profile)
|
|
95
|
+
|
|
96
|
+
raw_profiles = raw.get("profiles")
|
|
97
|
+
if not isinstance(raw_profiles, dict) or not raw_profiles:
|
|
98
|
+
raise ConfigError("profiles must be a non-empty object")
|
|
99
|
+
|
|
100
|
+
profiles = {
|
|
101
|
+
validate_profile_name(name): Profile.parse(name, profile)
|
|
102
|
+
for name, profile in raw_profiles.items()
|
|
103
|
+
if isinstance(name, str)
|
|
104
|
+
}
|
|
105
|
+
if len(profiles) != len(raw_profiles):
|
|
106
|
+
raise ConfigError("profile names must be strings")
|
|
107
|
+
if active_profile not in profiles:
|
|
108
|
+
raise ConfigError(f"active profile {active_profile!r} is not configured")
|
|
109
|
+
return cls(active_profile=active_profile, profiles=profiles)
|
|
110
|
+
|
|
111
|
+
def profile(self, name: str) -> Profile:
|
|
112
|
+
try:
|
|
113
|
+
return self.profiles[validate_profile_name(name)]
|
|
114
|
+
except KeyError as exc:
|
|
115
|
+
known = ", ".join(sorted(self.profiles))
|
|
116
|
+
message = f"no profile named {name!r} (known profiles: {known})"
|
|
117
|
+
raise UnknownProfileError(message) from exc
|
|
118
|
+
|
|
119
|
+
def with_active_profile(self, name: str) -> Config:
|
|
120
|
+
profile_name = validate_profile_name(name)
|
|
121
|
+
if profile_name not in self.profiles:
|
|
122
|
+
known = ", ".join(sorted(self.profiles))
|
|
123
|
+
raise UnknownProfileError(f"no profile named {name!r} (known profiles: {known})")
|
|
124
|
+
return Config(active_profile=profile_name, profiles=self.profiles)
|
|
125
|
+
|
|
126
|
+
def with_profile(self, name: str, profile: Profile) -> Config:
|
|
127
|
+
profile_name = validate_profile_name(name)
|
|
128
|
+
profiles = dict(self.profiles)
|
|
129
|
+
profiles[profile_name] = profile
|
|
130
|
+
return Config(active_profile=self.active_profile, profiles=profiles)
|
|
131
|
+
|
|
132
|
+
def with_profile_origin(self, name: str, origin: str) -> Config:
|
|
133
|
+
profile_name = validate_profile_name(name)
|
|
134
|
+
next_origin = validate_origin(origin)
|
|
135
|
+
existing = self.profiles.get(profile_name)
|
|
136
|
+
workspace_id = (
|
|
137
|
+
existing.workspace_id
|
|
138
|
+
if existing is not None and existing.origin == next_origin
|
|
139
|
+
else None
|
|
140
|
+
)
|
|
141
|
+
return self.with_profile(
|
|
142
|
+
profile_name,
|
|
143
|
+
Profile(origin=next_origin, workspace_id=workspace_id),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def with_profile_workspace(self, name: str, workspace_id: str | None) -> Config:
|
|
147
|
+
profile_name = validate_profile_name(name)
|
|
148
|
+
profile = self.profile(profile_name)
|
|
149
|
+
profiles = dict(self.profiles)
|
|
150
|
+
profiles[profile_name] = replace(
|
|
151
|
+
profile,
|
|
152
|
+
workspace_id=validate_workspace_id(workspace_id) if workspace_id is not None else None,
|
|
153
|
+
)
|
|
154
|
+
return Config(active_profile=self.active_profile, profiles=profiles)
|
|
155
|
+
|
|
156
|
+
def to_json(self) -> dict[str, object]:
|
|
157
|
+
return {
|
|
158
|
+
"schema_version": self.schema_version,
|
|
159
|
+
"active_profile": self.active_profile,
|
|
160
|
+
"profiles": {name: self.profiles[name].to_json() for name in sorted(self.profiles)},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
DEFAULT_PROFILE = Profile(DEFAULT_ORIGIN)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def validate_profile_name(name: str) -> str:
|
|
168
|
+
if not _PROFILE_NAME.fullmatch(name):
|
|
169
|
+
raise ConfigError(
|
|
170
|
+
f"{name!r} is not a usable profile name; use letters, digits, dot, dash, "
|
|
171
|
+
"or underscore, up to 64 characters, and do not start with a dot"
|
|
172
|
+
)
|
|
173
|
+
return name
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def validate_origin(origin: str) -> str:
|
|
177
|
+
stripped = origin.strip().rstrip("/")
|
|
178
|
+
parsed = urlparse(stripped)
|
|
179
|
+
if parsed.scheme != "https" or not parsed.netloc:
|
|
180
|
+
raise ConfigError(f"origin {origin!r} must be an https URL")
|
|
181
|
+
if parsed.username is not None or parsed.password is not None:
|
|
182
|
+
raise ConfigError(f"origin {origin!r} must not include username or password")
|
|
183
|
+
try:
|
|
184
|
+
_ = parsed.port
|
|
185
|
+
except ValueError as exc:
|
|
186
|
+
raise ConfigError(f"origin {origin!r} must include a valid port") from exc
|
|
187
|
+
if not parsed.hostname:
|
|
188
|
+
raise ConfigError(f"origin {origin!r} must include a hostname")
|
|
189
|
+
if parsed.path or parsed.params or parsed.query or parsed.fragment:
|
|
190
|
+
raise ConfigError(f"origin {origin!r} must not include a path, query, or fragment")
|
|
191
|
+
return stripped
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def validate_workspace_id(workspace_id: str) -> str:
|
|
195
|
+
stripped = workspace_id.strip()
|
|
196
|
+
if not stripped:
|
|
197
|
+
raise ConfigError("workspace_id must not be empty")
|
|
198
|
+
if any(character in stripped for character in "\r\n\t"):
|
|
199
|
+
raise ConfigError("workspace_id must not include control whitespace")
|
|
200
|
+
return stripped
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def default_config() -> Config:
|
|
204
|
+
return Config(
|
|
205
|
+
active_profile=DEFAULT_PROFILE_NAME,
|
|
206
|
+
profiles={DEFAULT_PROFILE_NAME: DEFAULT_PROFILE},
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def config_path() -> Path:
|
|
211
|
+
override = os.environ.get(ENV_CONFIG_FILE)
|
|
212
|
+
if override:
|
|
213
|
+
return Path(override).expanduser()
|
|
214
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
215
|
+
base = Path(xdg).expanduser() if xdg else Path.home() / ".config"
|
|
216
|
+
return base / "convergent-cli" / "config.json"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def active_profile_name(config: Config) -> str:
|
|
220
|
+
# Like --profile, the override lasts one invocation and never reaches the file.
|
|
221
|
+
override = os.environ.get(ENV_PROFILE)
|
|
222
|
+
if not override:
|
|
223
|
+
return config.active_profile
|
|
224
|
+
config.profile(override)
|
|
225
|
+
return validate_profile_name(override)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def load_config() -> Config:
|
|
229
|
+
path = config_path()
|
|
230
|
+
if not path.exists():
|
|
231
|
+
return default_config()
|
|
232
|
+
require_safe_file(path)
|
|
233
|
+
try:
|
|
234
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
235
|
+
except json.JSONDecodeError as exc:
|
|
236
|
+
raise ConfigError(f"{path} is not valid JSON ({exc.msg} at line {exc.lineno})") from exc
|
|
237
|
+
return Config.parse(raw)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def save_config(config: Config) -> Path:
|
|
241
|
+
config.profile(config.active_profile)
|
|
242
|
+
path = config_path()
|
|
243
|
+
make_private_dir(path.parent)
|
|
244
|
+
write_private_file(path, json.dumps(config.to_json(), indent=2, sort_keys=True) + "\n")
|
|
245
|
+
return path
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def make_private_dir(path: Path) -> None:
|
|
249
|
+
missing: list[Path] = []
|
|
250
|
+
probe = path
|
|
251
|
+
while not probe.exists():
|
|
252
|
+
missing.append(probe)
|
|
253
|
+
probe = probe.parent
|
|
254
|
+
for directory in reversed(missing):
|
|
255
|
+
directory.mkdir(mode=0o700, exist_ok=True)
|
|
256
|
+
require_safe_dir(path)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def require_safe_dir(path: Path) -> None:
|
|
260
|
+
if not path.exists():
|
|
261
|
+
return
|
|
262
|
+
if not path.is_dir():
|
|
263
|
+
raise ConfigError(f"config directory {path} is not a directory")
|
|
264
|
+
if os.name != "posix":
|
|
265
|
+
return
|
|
266
|
+
mode = path.stat().st_mode
|
|
267
|
+
if mode & (stat.S_IWGRP | stat.S_IWOTH):
|
|
268
|
+
raise ConfigError(f"config directory {path} is writable by the group or others")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def require_safe_file(path: Path) -> None:
|
|
272
|
+
if path.is_symlink():
|
|
273
|
+
raise ConfigError(f"config file {path} is a symlink")
|
|
274
|
+
mode = path.stat().st_mode
|
|
275
|
+
if not stat.S_ISREG(mode):
|
|
276
|
+
raise ConfigError(f"config file {path} is not a regular file")
|
|
277
|
+
if os.name != "posix":
|
|
278
|
+
return
|
|
279
|
+
if mode & (stat.S_IWGRP | stat.S_IWOTH):
|
|
280
|
+
raise ConfigError(f"config file {path} is writable by the group or others")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def write_private_file(path: Path, contents: str) -> None:
|
|
284
|
+
temp = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
|
|
285
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
286
|
+
descriptor = os.open(temp, flags, 0o600)
|
|
287
|
+
try:
|
|
288
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
289
|
+
handle.write(contents)
|
|
290
|
+
handle.flush()
|
|
291
|
+
os.fsync(handle.fileno())
|
|
292
|
+
os.replace(temp, path)
|
|
293
|
+
if os.name == "posix":
|
|
294
|
+
path.chmod(0o600)
|
|
295
|
+
except BaseException:
|
|
296
|
+
temp.unlink(missing_ok=True)
|
|
297
|
+
raise
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Tiny JSON/form HTTP transport for the public CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.client
|
|
6
|
+
import json
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
13
|
+
from typing import Any, Protocol
|
|
14
|
+
|
|
15
|
+
from convergent_cli.errors import ApiError, AuthError, InvalidResponseError, NetworkError
|
|
16
|
+
|
|
17
|
+
_HTTP_TIMEOUT_SECONDS = 15.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class HttpResponse:
|
|
22
|
+
status_code: int
|
|
23
|
+
url: str
|
|
24
|
+
body: bytes
|
|
25
|
+
headers: Mapping[str, str]
|
|
26
|
+
|
|
27
|
+
def json(self) -> object:
|
|
28
|
+
try:
|
|
29
|
+
return json.loads(self.body.decode("utf-8"))
|
|
30
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
31
|
+
raise InvalidResponseError(f"{self.url} did not answer with JSON") from exc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Transport(Protocol):
|
|
35
|
+
def request(
|
|
36
|
+
self,
|
|
37
|
+
method: str,
|
|
38
|
+
url: str,
|
|
39
|
+
*,
|
|
40
|
+
headers: Mapping[str, str] | None = None,
|
|
41
|
+
body: bytes | None = None,
|
|
42
|
+
timeout: float | None = None,
|
|
43
|
+
) -> HttpResponse: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _user_agent() -> str:
|
|
47
|
+
# WorkOS's default authkit.app domain answers 403 to urllib's own user agent.
|
|
48
|
+
try:
|
|
49
|
+
return f"cv/{version('convergent-cli')}"
|
|
50
|
+
except PackageNotFoundError:
|
|
51
|
+
return "cv"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_USER_AGENT = _user_agent()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class UrllibTransport:
|
|
58
|
+
def __init__(self, opener: urllib.request.OpenerDirector | None = None) -> None:
|
|
59
|
+
self._opener = opener or urllib.request.build_opener(_NoRedirectHandler)
|
|
60
|
+
|
|
61
|
+
def request(
|
|
62
|
+
self,
|
|
63
|
+
method: str,
|
|
64
|
+
url: str,
|
|
65
|
+
*,
|
|
66
|
+
headers: Mapping[str, str] | None = None,
|
|
67
|
+
body: bytes | None = None,
|
|
68
|
+
timeout: float | None = None,
|
|
69
|
+
) -> HttpResponse:
|
|
70
|
+
_require_https(url)
|
|
71
|
+
sent = {"User-Agent": _USER_AGENT, **(headers or {})}
|
|
72
|
+
request = urllib.request.Request(url, data=body, headers=sent, method=method)
|
|
73
|
+
try:
|
|
74
|
+
try:
|
|
75
|
+
response = self._opener.open(
|
|
76
|
+
request, timeout=_HTTP_TIMEOUT_SECONDS if timeout is None else timeout
|
|
77
|
+
)
|
|
78
|
+
except urllib.error.HTTPError as exc:
|
|
79
|
+
response = exc
|
|
80
|
+
except urllib.error.URLError as exc:
|
|
81
|
+
raise NetworkError(f"{url} could not be reached: {exc.reason}") from exc
|
|
82
|
+
with response:
|
|
83
|
+
return HttpResponse(
|
|
84
|
+
status_code=response.status,
|
|
85
|
+
url=url,
|
|
86
|
+
body=response.read(),
|
|
87
|
+
headers=dict(response.headers.items()),
|
|
88
|
+
)
|
|
89
|
+
except (http.client.HTTPException, OSError) as exc:
|
|
90
|
+
raise NetworkError(f"{url} failed before a complete response: {exc!r}") from exc
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_json(transport: Transport, url: str, *, headers: Mapping[str, str] | None = None) -> object:
|
|
94
|
+
response = transport.request(
|
|
95
|
+
"GET",
|
|
96
|
+
url,
|
|
97
|
+
headers={"Accept": "application/json", **(headers or {})},
|
|
98
|
+
)
|
|
99
|
+
_require_success(response)
|
|
100
|
+
return response.json()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def post_form(transport: Transport, url: str, data: Mapping[str, str]) -> HttpResponse:
|
|
104
|
+
body = urllib.parse.urlencode(data).encode("utf-8")
|
|
105
|
+
return transport.request(
|
|
106
|
+
"POST",
|
|
107
|
+
url,
|
|
108
|
+
headers={
|
|
109
|
+
"Accept": "application/json",
|
|
110
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
111
|
+
},
|
|
112
|
+
body=body,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def post_json(
|
|
117
|
+
transport: Transport,
|
|
118
|
+
url: str,
|
|
119
|
+
data: Mapping[str, object],
|
|
120
|
+
*,
|
|
121
|
+
headers: Mapping[str, str] | None = None,
|
|
122
|
+
timeout: float | None = None,
|
|
123
|
+
) -> HttpResponse:
|
|
124
|
+
return _send_json(transport, "POST", url, data, headers=headers, timeout=timeout)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def patch_json(
|
|
128
|
+
transport: Transport,
|
|
129
|
+
url: str,
|
|
130
|
+
data: Mapping[str, object],
|
|
131
|
+
*,
|
|
132
|
+
headers: Mapping[str, str] | None = None,
|
|
133
|
+
) -> HttpResponse:
|
|
134
|
+
return _send_json(transport, "PATCH", url, data, headers=headers)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _send_json(
|
|
138
|
+
transport: Transport,
|
|
139
|
+
method: str,
|
|
140
|
+
url: str,
|
|
141
|
+
data: Mapping[str, object],
|
|
142
|
+
*,
|
|
143
|
+
headers: Mapping[str, str] | None,
|
|
144
|
+
timeout: float | None = None,
|
|
145
|
+
) -> HttpResponse:
|
|
146
|
+
body = json.dumps(dict(data), separators=(",", ":")).encode("utf-8")
|
|
147
|
+
extra: dict[str, float] = {}
|
|
148
|
+
if timeout is not None:
|
|
149
|
+
extra["timeout"] = timeout
|
|
150
|
+
return transport.request(
|
|
151
|
+
method,
|
|
152
|
+
url,
|
|
153
|
+
headers={
|
|
154
|
+
"Accept": "application/json",
|
|
155
|
+
"Content-Type": "application/json",
|
|
156
|
+
**(headers or {}),
|
|
157
|
+
},
|
|
158
|
+
body=body,
|
|
159
|
+
**extra,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def put_json(
|
|
164
|
+
transport: Transport,
|
|
165
|
+
url: str,
|
|
166
|
+
data: Mapping[str, object],
|
|
167
|
+
*,
|
|
168
|
+
headers: Mapping[str, str] | None = None,
|
|
169
|
+
timeout: float | None = None,
|
|
170
|
+
) -> HttpResponse:
|
|
171
|
+
body = json.dumps(dict(data), ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
172
|
+
extra: dict[str, float] = {}
|
|
173
|
+
if timeout is not None:
|
|
174
|
+
extra["timeout"] = timeout
|
|
175
|
+
return transport.request(
|
|
176
|
+
"PUT",
|
|
177
|
+
url,
|
|
178
|
+
headers={
|
|
179
|
+
"Accept": "application/json",
|
|
180
|
+
"Content-Type": "application/json",
|
|
181
|
+
**(headers or {}),
|
|
182
|
+
},
|
|
183
|
+
body=body,
|
|
184
|
+
**extra,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def require_json_object(value: object, *, url: str) -> Mapping[str, object]:
|
|
189
|
+
if not isinstance(value, dict):
|
|
190
|
+
raise InvalidResponseError(f"{url} did not answer with a JSON object")
|
|
191
|
+
return value
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _require_success(response: HttpResponse) -> None:
|
|
195
|
+
if 200 <= response.status_code < 300:
|
|
196
|
+
return
|
|
197
|
+
raise ApiError(f"{response.url} answered {response.status_code}{_oauth_error(response)}")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _oauth_error(response: HttpResponse) -> str:
|
|
201
|
+
try:
|
|
202
|
+
body = response.json()
|
|
203
|
+
except AuthError:
|
|
204
|
+
return ""
|
|
205
|
+
code = body.get("error") if isinstance(body, dict) else None
|
|
206
|
+
if not isinstance(code, str) or not code:
|
|
207
|
+
return ""
|
|
208
|
+
shown = "".join(character for character in code if character.isprintable())[:80]
|
|
209
|
+
return f" ({shown})"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _require_https(url: str) -> None:
|
|
213
|
+
parsed = urllib.parse.urlsplit(url)
|
|
214
|
+
if parsed.scheme != "https":
|
|
215
|
+
raise AuthError(
|
|
216
|
+
f"refusing to send an HTTP request over {parsed.scheme or 'an unknown scheme'}"
|
|
217
|
+
)
|
|
218
|
+
if parsed.username is not None or parsed.password is not None:
|
|
219
|
+
raise AuthError("refusing to send an HTTP request to a URL with username or password")
|
|
220
|
+
try:
|
|
221
|
+
_ = parsed.port
|
|
222
|
+
except ValueError as exc:
|
|
223
|
+
raise AuthError("refusing to send an HTTP request to a URL with an invalid port") from exc
|
|
224
|
+
if not parsed.hostname:
|
|
225
|
+
raise AuthError("refusing to send an HTTP request to a URL without a hostname")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
229
|
+
def http_error_301(
|
|
230
|
+
self,
|
|
231
|
+
req: urllib.request.Request,
|
|
232
|
+
fp: Any,
|
|
233
|
+
code: int,
|
|
234
|
+
msg: str,
|
|
235
|
+
headers: Any,
|
|
236
|
+
) -> None:
|
|
237
|
+
raise urllib.error.HTTPError(req.full_url, code, msg, headers, fp)
|
|
238
|
+
|
|
239
|
+
http_error_302 = http_error_301
|
|
240
|
+
http_error_303 = http_error_301
|
|
241
|
+
http_error_307 = http_error_301
|
|
242
|
+
http_error_308 = http_error_301
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Verdict feedback writes shared by ``cv verdicts review`` and ``cv findings review``.
|
|
2
|
+
|
|
3
|
+
Both match one of the product UI's thumbs and post to
|
|
4
|
+
``POST /v1/grades/{grade_id}/feedback``: the scorecard thumbs send no
|
|
5
|
+
``finding_id`` (whole grade), and the finding card thumbs send the
|
|
6
|
+
``finding_id`` of one finding. Neither reads the finding first; the
|
|
7
|
+
route answers 404 for an unknown verdict and 422 for a finding that is not on
|
|
8
|
+
the verdict.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from hashlib import sha256
|
|
17
|
+
from re import fullmatch
|
|
18
|
+
from urllib.parse import quote
|
|
19
|
+
|
|
20
|
+
from convergent_cli import customer_client
|
|
21
|
+
from convergent_cli.confirm import confirm_write
|
|
22
|
+
from convergent_cli.errors import InvalidResponseError, UsageError
|
|
23
|
+
from convergent_cli.output import emit_json
|
|
24
|
+
|
|
25
|
+
GRADES_PATH = "/v1/grades"
|
|
26
|
+
REASON_MAX_BYTES = 500
|
|
27
|
+
_JUDGMENT_TO_FEEDBACK = {"correct": "positive", "incorrect": "negative"}
|
|
28
|
+
_OCCURRENCE_ID_PATTERN = r"occ_[0-9a-f]{64}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def add_review_arguments(parser: argparse.ArgumentParser, *, subject: str) -> None:
|
|
32
|
+
"""The judgment, reason, and format flags both review commands take."""
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--judgment",
|
|
35
|
+
choices=tuple(_JUDGMENT_TO_FEEDBACK),
|
|
36
|
+
required=True,
|
|
37
|
+
help=f"Whether the {subject} is correct (thumbs up) or incorrect (thumbs down).",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--reason",
|
|
41
|
+
required=True,
|
|
42
|
+
help=f"Why the {subject} is correct or incorrect. Required, at most 500 UTF-8 bytes.",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--format",
|
|
46
|
+
choices=("json",),
|
|
47
|
+
default=None,
|
|
48
|
+
help="Print the feedback receipt as JSON.",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def review(args: argparse.Namespace, *, occurrence_id: str | None) -> int:
|
|
53
|
+
"""Confirm, then record feedback on the verdict, or on one finding of it."""
|
|
54
|
+
verdict_id = _require_verdict_id(args.verdict_id)
|
|
55
|
+
if occurrence_id is not None:
|
|
56
|
+
occurrence_id = _require_occurrence_id(occurrence_id)
|
|
57
|
+
reason = _require_reason(args.reason)
|
|
58
|
+
value = _JUDGMENT_TO_FEEDBACK[args.judgment]
|
|
59
|
+
client = customer_client.HostedClient.for_active_profile()
|
|
60
|
+
confirm_write(_prompt(verdict_id, occurrence_id, args.judgment, reason))
|
|
61
|
+
raw_receipt = client.customer_post(
|
|
62
|
+
f"{GRADES_PATH}/{quote(verdict_id, safe='')}/feedback",
|
|
63
|
+
{"value": value, "comment": reason, "finding_id": occurrence_id},
|
|
64
|
+
idempotency_key=_idempotency_key(verdict_id, occurrence_id, value, reason),
|
|
65
|
+
)
|
|
66
|
+
result = _receipt(raw_receipt, verdict_id=verdict_id, occurrence_id=occurrence_id, value=value)
|
|
67
|
+
if args.format == "json":
|
|
68
|
+
emit_json(result)
|
|
69
|
+
else:
|
|
70
|
+
target = f"occurrence {occurrence_id} of verdict" if occurrence_id else "verdict"
|
|
71
|
+
print(
|
|
72
|
+
f"Marked {target} {verdict_id} {args.judgment}: {result['value']} "
|
|
73
|
+
f"feedback {result['feedback_id']} recorded."
|
|
74
|
+
)
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _idempotency_key(verdict_id: str, occurrence_id: str | None, value: str, reason: str) -> str:
|
|
79
|
+
# One key per (verdict, finding, value, reason), so a retry replays its
|
|
80
|
+
# receipt and the same reason at the two levels does not share a key.
|
|
81
|
+
target = f"{verdict_id}\0{occurrence_id}" if occurrence_id else verdict_id
|
|
82
|
+
return sha256(f"review-finding\0{target}\0{value}\0{reason}".encode()).hexdigest()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _require_verdict_id(verdict_id: str) -> str:
|
|
86
|
+
cleaned = verdict_id.strip()
|
|
87
|
+
if not cleaned:
|
|
88
|
+
raise UsageError("verdict id must not be empty")
|
|
89
|
+
return cleaned
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _require_occurrence_id(occurrence_id: str) -> str:
|
|
93
|
+
if fullmatch(_OCCURRENCE_ID_PATTERN, occurrence_id) is None:
|
|
94
|
+
raise UsageError(
|
|
95
|
+
"occurrence id must be an occurrence_id (occ_...) from cv findings list or get"
|
|
96
|
+
)
|
|
97
|
+
return occurrence_id
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _require_reason(reason: str) -> str:
|
|
101
|
+
if not reason.strip():
|
|
102
|
+
raise UsageError("reason must not be empty")
|
|
103
|
+
if len(reason.encode()) > REASON_MAX_BYTES:
|
|
104
|
+
raise UsageError(f"reason must be at most {REASON_MAX_BYTES} UTF-8 bytes")
|
|
105
|
+
return reason
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _prompt(verdict_id: str, occurrence_id: str | None, judgment: str, reason: str) -> str:
|
|
109
|
+
return (
|
|
110
|
+
"This will write one feedback record.\n"
|
|
111
|
+
f"Verdict id: {verdict_id}\n"
|
|
112
|
+
f"Occurrence id: {occurrence_id or 'none (whole verdict)'}\n"
|
|
113
|
+
f"Judgment: {judgment}\n"
|
|
114
|
+
f"Reason: {reason}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _receipt(
|
|
119
|
+
payload: Mapping[str, object],
|
|
120
|
+
*,
|
|
121
|
+
verdict_id: str,
|
|
122
|
+
occurrence_id: str | None,
|
|
123
|
+
value: str,
|
|
124
|
+
) -> dict[str, object]:
|
|
125
|
+
feedback_id = _required_string(payload, "feedback_id")
|
|
126
|
+
if fullmatch(r"vfb_[0-9a-f]{32}", feedback_id) is None:
|
|
127
|
+
raise InvalidResponseError("feedback response returned no usable feedback_id")
|
|
128
|
+
if _required_string(payload, "grade_id") != verdict_id:
|
|
129
|
+
raise InvalidResponseError("feedback response returned the wrong grade_id")
|
|
130
|
+
if payload.get("finding_id") != occurrence_id:
|
|
131
|
+
raise InvalidResponseError("feedback response returned the wrong finding_id")
|
|
132
|
+
if _required_string(payload, "value") != value:
|
|
133
|
+
raise InvalidResponseError("feedback response returned the wrong value")
|
|
134
|
+
comment = payload.get("comment")
|
|
135
|
+
if comment is not None and not isinstance(comment, str):
|
|
136
|
+
raise InvalidResponseError("feedback response returned no usable comment")
|
|
137
|
+
created_at = _required_string(payload, "created_at")
|
|
138
|
+
try:
|
|
139
|
+
parsed_created_at = datetime.fromisoformat(created_at)
|
|
140
|
+
except ValueError as exc:
|
|
141
|
+
raise InvalidResponseError("feedback response returned no usable created_at") from exc
|
|
142
|
+
if parsed_created_at.tzinfo is None:
|
|
143
|
+
raise InvalidResponseError("feedback response returned no usable created_at")
|
|
144
|
+
return {
|
|
145
|
+
"feedback_id": feedback_id,
|
|
146
|
+
"verdict_id": verdict_id,
|
|
147
|
+
"occurrence_id": occurrence_id,
|
|
148
|
+
"value": value,
|
|
149
|
+
"comment": comment,
|
|
150
|
+
"created_at": created_at,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _required_string(payload: Mapping[str, object], field: str) -> str:
|
|
155
|
+
value = payload.get(field)
|
|
156
|
+
if not isinstance(value, str) or not value:
|
|
157
|
+
raise InvalidResponseError(f"feedback response returned no usable {field}")
|
|
158
|
+
return value
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
__all__ = ["add_review_arguments", "review"]
|