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.

Files changed (83) hide show
  1. convergent_cli/__init__.py +1 -0
  2. convergent_cli/auth.py +227 -0
  3. convergent_cli/build_context.py +225 -0
  4. convergent_cli/calibration_finish.py +310 -0
  5. convergent_cli/calibration_frontier.py +499 -0
  6. convergent_cli/calibration_runner.py +1413 -0
  7. convergent_cli/calibration_send.py +44 -0
  8. convergent_cli/codex_app_server.py +127 -0
  9. convergent_cli/command_tree.py +102 -0
  10. convergent_cli/commands/__init__.py +58 -0
  11. convergent_cli/commands/agents.py +729 -0
  12. convergent_cli/commands/auth.py +117 -0
  13. convergent_cli/commands/calibration.py +3857 -0
  14. convergent_cli/commands/calibration_labels_load.py +627 -0
  15. convergent_cli/commands/capabilities.py +85 -0
  16. convergent_cli/commands/dataset_authoring.py +964 -0
  17. convergent_cli/commands/dataset_import.py +763 -0
  18. convergent_cli/commands/datasets.py +678 -0
  19. convergent_cli/commands/docs.py +120 -0
  20. convergent_cli/commands/doctor.py +124 -0
  21. convergent_cli/commands/environments.py +63 -0
  22. convergent_cli/commands/findings.py +206 -0
  23. convergent_cli/commands/graders.py +803 -0
  24. convergent_cli/commands/harness.py +366 -0
  25. convergent_cli/commands/issues.py +388 -0
  26. convergent_cli/commands/production.py +173 -0
  27. convergent_cli/commands/profile.py +86 -0
  28. convergent_cli/commands/rubrics.py +14 -0
  29. convergent_cli/commands/runs.py +1763 -0
  30. convergent_cli/commands/setup.py +968 -0
  31. convergent_cli/commands/task_qc.py +1041 -0
  32. convergent_cli/commands/task_qc_reports.py +393 -0
  33. convergent_cli/commands/verdicts.py +156 -0
  34. convergent_cli/commands/workspaces.py +170 -0
  35. convergent_cli/commands/worlds.py +292 -0
  36. convergent_cli/confirm.py +59 -0
  37. convergent_cli/control_client.py +141 -0
  38. convergent_cli/customer_client.py +1272 -0
  39. convergent_cli/docs_client.py +165 -0
  40. convergent_cli/errors.py +212 -0
  41. convergent_cli/harness_run.py +1169 -0
  42. convergent_cli/harness_tree.py +854 -0
  43. convergent_cli/installed_agent.py +1071 -0
  44. convergent_cli/json_files.py +23 -0
  45. convergent_cli/local_files.py +64 -0
  46. convergent_cli/main.py +106 -0
  47. convergent_cli/oauth.py +431 -0
  48. convergent_cli/output.py +15 -0
  49. convergent_cli/paging.py +62 -0
  50. convergent_cli/run_calibration.py +361 -0
  51. convergent_cli/run_usage.py +99 -0
  52. convergent_cli/sessions.py +275 -0
  53. convergent_cli/setup_agents.py +190 -0
  54. convergent_cli/setup_auth.py +463 -0
  55. convergent_cli/setup_instrument.py +244 -0
  56. convergent_cli/setup_mcp.py +525 -0
  57. convergent_cli/setup_wizard.py +125 -0
  58. convergent_cli/skill_files/convergent/SKILL.md +25 -0
  59. convergent_cli/skill_files/convergent-instrument/SKILL.md +15 -0
  60. convergent_cli/skill_files/convergent-verify/SKILL.md +15 -0
  61. convergent_cli/skills.py +489 -0
  62. convergent_cli/state.py +297 -0
  63. convergent_cli/transport.py +242 -0
  64. convergent_cli/verdict_feedback.py +161 -0
  65. convergent_cli/verifier_bench_preflight.py +32 -0
  66. convergent_cli/world_docker.py +145 -0
  67. convergent_cli/world_files.py +432 -0
  68. convergent_cli/world_live.py +660 -0
  69. convergent_cli/world_mcp.py +109 -0
  70. convergent_cli/world_probe_requirements.txt +29 -0
  71. convergent_cli/world_template/Dockerfile +10 -0
  72. convergent_cli/world_template/README.md +38 -0
  73. convergent_cli/world_template/convergent.toml +11 -0
  74. convergent_cli/world_template/examples/default/environment/catalog.json +3 -0
  75. convergent_cli/world_template/examples/default/instruction.md +1 -0
  76. convergent_cli/world_template/requirements.txt +1 -0
  77. convergent_cli/world_template/schemas/catalog.json +5 -0
  78. convergent_cli/world_template/server.py +25 -0
  79. convergent_cli-0.1.0.dist-info/METADATA +679 -0
  80. convergent_cli-0.1.0.dist-info/RECORD +83 -0
  81. convergent_cli-0.1.0.dist-info/WHEEL +4 -0
  82. convergent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  83. convergent_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -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"]