runtime-sdk 0.4.49__py3-none-win_amd64.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.
runtime_sdk/github.py ADDED
@@ -0,0 +1,229 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import re
5
+ import time
6
+ import webbrowser
7
+
8
+ from runtime_sdk import output as cli_output, terminal
9
+ from runtime_sdk.auth import authenticated_client
10
+ from runtime_sdk.client import RuntimeAPIError
11
+ from runtime_sdk.config import RuntimeConfig
12
+
13
+
14
+ def _coerce_github_repo(repo: str | None) -> str | None:
15
+ text = str(repo or "").strip()
16
+ if not text:
17
+ return None
18
+ if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", text):
19
+ raise RuntimeAPIError("repo must be in owner/name format")
20
+ owner, name = text.split("/", 1)
21
+ return f"{owner}/{name}"
22
+
23
+
24
+ def _coerce_git_branch(branch: str | None) -> str | None:
25
+ text = str(branch or "").strip()
26
+ if not text:
27
+ return None
28
+ if text == "@":
29
+ raise RuntimeAPIError("branch name is invalid")
30
+ if (
31
+ text.startswith("-")
32
+ or text.endswith("/")
33
+ or text.endswith(".")
34
+ or text.endswith(".lock")
35
+ ):
36
+ raise RuntimeAPIError("branch name is invalid")
37
+ if ".." in text or "//" in text or "@{" in text:
38
+ raise RuntimeAPIError("branch name is invalid")
39
+ if any(ch in text for ch in (" ", "~", "^", ":", "?", "*", "[", "\\")):
40
+ raise RuntimeAPIError("branch name is invalid")
41
+ if any(ord(ch) < 32 or ord(ch) == 127 for ch in text):
42
+ raise RuntimeAPIError("branch name is invalid")
43
+ return text
44
+
45
+
46
+ def handle_github_list(config: RuntimeConfig) -> int:
47
+ if (err := cli_output.require_auth(config)) is not None:
48
+ return err
49
+ client = authenticated_client(config)
50
+ return cli_output.report_success(client.list_github_installations())
51
+
52
+
53
+ def handle_github_disconnect(config: RuntimeConfig, ref: str | None) -> int:
54
+ if (err := cli_output.require_auth(config)) is not None:
55
+ return err
56
+ resolved_ref = str(ref or "").strip()
57
+ if not resolved_ref:
58
+ if not cli_output.interactive():
59
+ return cli_output.report_error(
60
+ "github owner or installation id is required"
61
+ )
62
+ resolved_ref = cli_output.prompt_text("GitHub owner or installation id")
63
+ if not resolved_ref:
64
+ return cli_output.report_error(
65
+ "github owner or installation id is required"
66
+ )
67
+
68
+ client = authenticated_client(config)
69
+ installation_id: int | None = None
70
+ owner = resolved_ref
71
+
72
+ try:
73
+ installation_id = int(resolved_ref)
74
+ except ValueError:
75
+ payload = client.list_github_installations()
76
+ installations = payload.get("installations") or []
77
+ target_owner = resolved_ref.casefold()
78
+ match = next(
79
+ (
80
+ entry
81
+ for entry in installations
82
+ if isinstance(entry, dict)
83
+ and str(entry.get("github_account_login") or "").strip().casefold()
84
+ == target_owner
85
+ ),
86
+ None,
87
+ )
88
+ if match is None:
89
+ return cli_output.report_error(
90
+ f"github installation not found: {resolved_ref}"
91
+ )
92
+ try:
93
+ installation_id = int(match.get("id"))
94
+ except (TypeError, ValueError):
95
+ return cli_output.report_error(
96
+ f"github installation is missing an id: {resolved_ref}"
97
+ )
98
+ owner = str(match.get("github_account_login") or resolved_ref)
99
+
100
+ if installation_id is None or installation_id <= 0:
101
+ return cli_output.report_error("installation id must be a positive number")
102
+
103
+ client.disconnect_github_installation(installation_id)
104
+ return cli_output.report_success(
105
+ {"id": installation_id, "owner": owner, "deleted": True}
106
+ )
107
+
108
+
109
+ def handle_integrate_github(
110
+ config: RuntimeConfig,
111
+ provider: str,
112
+ *,
113
+ no_open: bool = False,
114
+ timeout_seconds: int = 180,
115
+ ) -> int:
116
+ if (err := cli_output.require_auth(config)) is not None:
117
+ return err
118
+ if provider != "github":
119
+ return cli_output.report_error(f"unsupported integration provider: {provider}")
120
+
121
+ client = authenticated_client(config)
122
+ payload = client.start_github_connect()
123
+ install_url = str(payload.get("install_url") or "").strip()
124
+ session_id = int(payload.get("session_id") or 0)
125
+ if not install_url or session_id <= 0:
126
+ return cli_output.report_error("server did not return a GitHub install session")
127
+
128
+ opened = False
129
+ if not no_open:
130
+ with contextlib.suppress(Exception):
131
+ opened = bool(webbrowser.open(install_url))
132
+
133
+ if cli_output.interactive():
134
+ action = (
135
+ "opened in your browser" if opened else "copy this URL into your browser"
136
+ )
137
+ print(f"{action}: {install_url}")
138
+ print(
139
+ "Choose the GitHub account or organization and repository access. Waiting for completion..."
140
+ )
141
+
142
+ deadline = time.time() + max(1, int(timeout_seconds))
143
+ last_status = "pending"
144
+ while time.time() < deadline:
145
+ session = client.get_github_connect_session(session_id)
146
+ status = str(session.get("status") or "pending")
147
+ last_status = status
148
+ if status == "completed":
149
+ installation = session.get("installation") or {}
150
+ return cli_output.report_success(
151
+ {
152
+ "installation": installation,
153
+ "install_url": install_url,
154
+ "session_id": session_id,
155
+ }
156
+ )
157
+ if status in {"failed", "expired"}:
158
+ return cli_output.report_error(
159
+ str(session.get("error") or f"github connect {status}")
160
+ )
161
+ time.sleep(2)
162
+
163
+ return cli_output.report_error(
164
+ f"timed out waiting for GitHub install to finish (last status: {last_status})"
165
+ )
166
+
167
+
168
+ def handle_switch(
169
+ config: RuntimeConfig,
170
+ repo: str | None,
171
+ branch: str | None,
172
+ *,
173
+ create_branch: bool = False,
174
+ base_branch: str | None = None,
175
+ ) -> int:
176
+ if (err := cli_output.require_auth(config)) is not None:
177
+ return err
178
+ if base_branch is not None and not create_branch:
179
+ return cli_output.report_error("--from can only be used together with -c")
180
+
181
+ try:
182
+ resolved_repo = _coerce_github_repo(repo)
183
+ resolved_branch = _coerce_git_branch(branch)
184
+ except RuntimeAPIError as exc:
185
+ return cli_output.report_error(str(exc), status_code=exc.status_code)
186
+
187
+ if resolved_repo is None:
188
+ if not cli_output.interactive():
189
+ return cli_output.report_error("--repo is required")
190
+ resolved_repo = cli_output.prompt_text("GitHub repo (owner/name)")
191
+ try:
192
+ resolved_repo = _coerce_github_repo(resolved_repo)
193
+ except RuntimeAPIError as exc:
194
+ return cli_output.report_error(str(exc), status_code=exc.status_code)
195
+ if resolved_repo is None:
196
+ return cli_output.report_error("repo is required")
197
+
198
+ if resolved_branch is None:
199
+ if not cli_output.interactive():
200
+ return cli_output.report_error("branch is required")
201
+ prompt = "New branch name" if create_branch else "Branch name"
202
+ resolved_branch = cli_output.prompt_text(prompt)
203
+ try:
204
+ resolved_branch = _coerce_git_branch(resolved_branch)
205
+ except RuntimeAPIError as exc:
206
+ return cli_output.report_error(str(exc), status_code=exc.status_code)
207
+ if resolved_branch is None:
208
+ return cli_output.report_error("branch is required")
209
+
210
+ client = authenticated_client(config)
211
+ try:
212
+ payload = client.switch_workspace(
213
+ repo=resolved_repo,
214
+ branch=resolved_branch,
215
+ create=create_branch,
216
+ base_branch=base_branch,
217
+ )
218
+ except RuntimeAPIError as exc:
219
+ return cli_output.report_error(str(exc), status_code=exc.status_code)
220
+
221
+ computer = payload.get("computer") or {}
222
+ computer_id = str(computer.get("id") or "").strip()
223
+ if not computer_id:
224
+ return cli_output.report_error("server did not return a computer id")
225
+
226
+ code = cli_output.report_success(payload)
227
+ if code != 0 or not cli_output.interactive():
228
+ return code
229
+ return terminal.enter_computer(config, computer_id)
runtime_sdk/goals.py ADDED
@@ -0,0 +1,368 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import time
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+ from runtime_sdk import computers, output as cli_output
10
+ from runtime_sdk.auth import authenticated_client
11
+ from runtime_sdk.client import RuntimeAPIError, RuntimeClient
12
+ from runtime_sdk.config import RuntimeConfig
13
+
14
+
15
+ GOAL_WATCH_DIR = "/home/runtime/.runtime/email-goals"
16
+
17
+
18
+ def _goal_emit(tag: str, message: Any) -> None:
19
+ text = str(message or "").rstrip()
20
+ if not text:
21
+ return
22
+ lines = text.splitlines() or [text]
23
+ for index, line in enumerate(lines):
24
+ prefix = f"[{tag}] " if index == 0 else " " * (len(tag) + 3)
25
+ print(prefix + line, flush=True)
26
+
27
+
28
+ def _goal_parse_time(value: Any) -> float:
29
+ if not value:
30
+ return 0.0
31
+ text = str(value)
32
+ match = re.match(r"^(.*T\d\d:\d\d:\d\d)(?:\.(\d+))?(Z|[+-]\d\d:\d\d)?$", text)
33
+ if match:
34
+ fraction = match.group(2) or ""
35
+ suffix = match.group(3) or ""
36
+ text = match.group(1)
37
+ if fraction:
38
+ text += "." + fraction[:6].ljust(6, "0")
39
+ text += suffix
40
+ try:
41
+ return datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
42
+ except ValueError:
43
+ return 0.0
44
+
45
+
46
+ def _goal_read_text(client: RuntimeClient, computer_id: str, path: str) -> str | None:
47
+ try:
48
+ return client.read_file(computer_id, path).decode("utf-8", errors="replace")
49
+ except RuntimeAPIError as exc:
50
+ if exc.status_code == 404:
51
+ return None
52
+ raise
53
+
54
+
55
+ def _goal_read_json(
56
+ client: RuntimeClient, computer_id: str, path: str
57
+ ) -> dict[str, Any] | None:
58
+ text = _goal_read_text(client, computer_id, path)
59
+ if not text:
60
+ return None
61
+ try:
62
+ payload = json.loads(text)
63
+ except ValueError:
64
+ return None
65
+ return payload if isinstance(payload, dict) else None
66
+
67
+
68
+ def _goal_list_logs(client: RuntimeClient, computer_id: str) -> list[dict[str, Any]]:
69
+ try:
70
+ payload = client.list_files(computer_id, GOAL_WATCH_DIR)
71
+ except RuntimeAPIError as exc:
72
+ if exc.status_code == 404:
73
+ return []
74
+ raise
75
+ entries = payload.get("entries") or []
76
+ logs = [
77
+ entry
78
+ for entry in entries
79
+ if isinstance(entry, dict) and str(entry.get("name") or "").endswith(".log")
80
+ ]
81
+ return sorted(logs, key=lambda entry: _goal_parse_time(entry.get("mtime")))
82
+
83
+
84
+ def _goal_run_json(
85
+ client: RuntimeClient, computer_id: str, command: str
86
+ ) -> dict[str, Any] | None:
87
+ try:
88
+ result = client.run_command(
89
+ computer_id,
90
+ command,
91
+ uid=1000,
92
+ gid=1000,
93
+ cwd="/home/runtime",
94
+ shell="/bin/bash",
95
+ )
96
+ except RuntimeAPIError:
97
+ return None
98
+ if int(result.get("exit_code") or 0) != 0:
99
+ return None
100
+ try:
101
+ payload = json.loads(result.get("stdout") or "{}")
102
+ except ValueError:
103
+ return None
104
+ return payload if isinstance(payload, dict) else None
105
+
106
+
107
+ def _goal_run_text(client: RuntimeClient, computer_id: str, command: str) -> str:
108
+ try:
109
+ result = client.run_command(
110
+ computer_id,
111
+ command,
112
+ uid=1000,
113
+ gid=1000,
114
+ cwd="/home/runtime",
115
+ shell="/bin/bash",
116
+ )
117
+ except RuntimeAPIError:
118
+ return ""
119
+ if int(result.get("exit_code") or 0) != 0:
120
+ return ""
121
+ return str(result.get("stdout") or "").strip()
122
+
123
+
124
+ def _goal_current_email_ids(client: RuntimeClient, computer_id: str) -> set[str]:
125
+ payload = _goal_run_json(client, computer_id, "runtime-env email inbox --json")
126
+ emails = payload.get("emails") if isinstance(payload, dict) else []
127
+ return {
128
+ str(item.get("id") or "")
129
+ for item in emails or []
130
+ if isinstance(item, dict) and item.get("id")
131
+ }
132
+
133
+
134
+ def _goal_render_log_line(line: str, state: dict[str, Any]) -> None:
135
+ text = str(line or "").strip()
136
+ if not text:
137
+ return
138
+ mappings = (
139
+ ("assistant final_answer: ", "agent-final"),
140
+ ("assistant commentary: ", "agent"),
141
+ ("assistant analysis: ", "agent"),
142
+ ("assistant message: ", "agent"),
143
+ ("assistant delta: ", "agent"),
144
+ ("started command: ", "cmd"),
145
+ ("command: ", "cmd"),
146
+ ("command output delta: ", "out"),
147
+ ("command output: ", "out"),
148
+ ("file change: ", "file"),
149
+ ("mcp tool: ", "mcp"),
150
+ ("token usage: ", "usage"),
151
+ ("codex turn started", "codex"),
152
+ ("codex turn completed: ", "codex"),
153
+ )
154
+ for prefix, tag in mappings:
155
+ if text.startswith(prefix):
156
+ state["native_codex_stream_seen"] = True
157
+ value = text[len(prefix) :] if text != prefix.strip() else text
158
+ if tag == "cmd" and value.startswith("command: "):
159
+ value = value[len("command: ") :]
160
+ _goal_emit(tag, value)
161
+ return
162
+ _goal_emit("goal", text)
163
+
164
+
165
+ def _goal_wait_for_target(
166
+ client: RuntimeClient,
167
+ computer_id: str,
168
+ *,
169
+ mode: str,
170
+ email_id: str | None,
171
+ baseline: set[str],
172
+ deadline: float | None,
173
+ ) -> str:
174
+ if email_id:
175
+ _goal_emit("watch", f"waiting for {email_id}")
176
+ while True:
177
+ logs = _goal_list_logs(client, computer_id)
178
+ if any(entry.get("name") == f"{email_id}.log" for entry in logs):
179
+ return email_id
180
+ _goal_check_timeout(deadline)
181
+ time.sleep(0.75)
182
+
183
+ _goal_emit(
184
+ "watch",
185
+ "waiting for next email goal"
186
+ if mode == "next"
187
+ else "waiting for latest email goal",
188
+ )
189
+ while True:
190
+ logs = _goal_list_logs(client, computer_id)
191
+ if mode == "next":
192
+ logs = [
193
+ entry for entry in logs if str(entry.get("name") or "") not in baseline
194
+ ]
195
+ if logs:
196
+ return str(logs[-1].get("name") or "").removesuffix(".log")
197
+ _goal_check_timeout(deadline)
198
+ time.sleep(0.75)
199
+
200
+
201
+ def _goal_check_timeout(deadline: float | None) -> None:
202
+ if deadline is not None and time.time() >= deadline:
203
+ _goal_emit("watch", "timeout reached")
204
+ raise TimeoutError
205
+
206
+
207
+ def _goal_poll_mail(
208
+ client: RuntimeClient,
209
+ computer_id: str,
210
+ seen: set[str],
211
+ target_email_id: str,
212
+ started_at: float,
213
+ ) -> None:
214
+ payload = _goal_run_json(client, computer_id, "runtime-env email inbox --json")
215
+ emails = payload.get("emails") if isinstance(payload, dict) else []
216
+ for item in sorted(
217
+ (email for email in emails or [] if isinstance(email, dict)),
218
+ key=lambda email: _goal_parse_time(email.get("created_at")),
219
+ ):
220
+ item_id = str(item.get("id") or "")
221
+ if not item_id or item_id in seen:
222
+ continue
223
+ created = _goal_parse_time(item.get("created_at"))
224
+ if item_id != target_email_id and created and created < started_at - 5:
225
+ seen.add(item_id)
226
+ continue
227
+ seen.add(item_id)
228
+ direction = item.get("direction") or "email"
229
+ status = item.get("status") or ""
230
+ subject = item.get("subject") or ""
231
+ _goal_emit("mail", f"{direction} {status} {item_id}: {subject}".strip())
232
+
233
+
234
+ def _goal_poll_service(
235
+ client: RuntimeClient, computer_id: str, last_status: str
236
+ ) -> str:
237
+ status = _goal_run_text(client, computer_id, "runtime-env service status")
238
+ if status and status != last_status:
239
+ _goal_emit("service", status)
240
+ return status
241
+ return last_status
242
+
243
+
244
+ def _goal_describe(client: RuntimeClient, computer_id: str, email_id: str) -> None:
245
+ metadata = _goal_read_json(client, computer_id, f"{GOAL_WATCH_DIR}/{email_id}.json")
246
+ if not metadata:
247
+ return
248
+ sender = metadata.get("from") or ""
249
+ to = ", ".join(metadata.get("to") or [])
250
+ subject = metadata.get("subject") or ""
251
+ _goal_emit("mail", f"received {email_id} from {sender} to {to}: {subject}")
252
+
253
+
254
+ def _goal_watch_polling(
255
+ client: RuntimeClient,
256
+ computer_id: str,
257
+ *,
258
+ mode: str,
259
+ email_id: str | None,
260
+ timeout: int,
261
+ ) -> int:
262
+ started_at = time.time()
263
+ deadline = started_at + timeout if timeout > 0 else None
264
+ baseline_logs = (
265
+ {str(entry.get("name") or "") for entry in _goal_list_logs(client, computer_id)}
266
+ if mode == "next"
267
+ else set()
268
+ )
269
+ seen_emails = (
270
+ _goal_current_email_ids(client, computer_id) if mode == "next" else set()
271
+ )
272
+ state: dict[str, Any] = {"native_codex_stream_seen": False}
273
+
274
+ try:
275
+ target_email_id = _goal_wait_for_target(
276
+ client,
277
+ computer_id,
278
+ mode=mode,
279
+ email_id=email_id,
280
+ baseline=baseline_logs,
281
+ deadline=deadline,
282
+ )
283
+ except TimeoutError:
284
+ return 124
285
+
286
+ target_log = f"{GOAL_WATCH_DIR}/{target_email_id}.log"
287
+ target_response = f"{GOAL_WATCH_DIR}/{target_email_id}.response.md"
288
+ target_started_at = time.time()
289
+ offset = 0
290
+ response_printed = False
291
+ done_seen_at: float | None = None
292
+ last_mail_poll = 0.0
293
+ last_service_poll = 0.0
294
+ last_service_status = ""
295
+
296
+ _goal_emit("goal", f"watching {target_email_id}")
297
+ _goal_describe(client, computer_id, target_email_id)
298
+
299
+ while True:
300
+ try:
301
+ _goal_check_timeout(deadline)
302
+ except TimeoutError:
303
+ return 124
304
+ now = time.time()
305
+
306
+ text = _goal_read_text(client, computer_id, target_log) or ""
307
+ if len(text) < offset:
308
+ offset = 0
309
+ if len(text) > offset:
310
+ chunk = text[offset:]
311
+ offset = len(text)
312
+ for line in chunk.splitlines():
313
+ _goal_render_log_line(line, state)
314
+
315
+ if now - last_mail_poll >= 2:
316
+ _goal_poll_mail(
317
+ client, computer_id, seen_emails, target_email_id, target_started_at
318
+ )
319
+ last_mail_poll = now
320
+
321
+ if now - last_service_poll >= 2:
322
+ last_service_status = _goal_poll_service(
323
+ client, computer_id, last_service_status
324
+ )
325
+ last_service_poll = now
326
+
327
+ if not response_printed:
328
+ response = (
329
+ _goal_read_text(client, computer_id, target_response) or ""
330
+ ).strip()
331
+ if response:
332
+ _goal_emit("done", response)
333
+ response_printed = True
334
+ done_seen_at = now
335
+
336
+ if done_seen_at is not None and now - done_seen_at >= 3:
337
+ return 0
338
+
339
+ time.sleep(0.75)
340
+
341
+
342
+ def handle_goal_watch(
343
+ config: RuntimeConfig,
344
+ computer_id: str | None,
345
+ *,
346
+ latest: bool,
347
+ next_goal: bool,
348
+ email_id: str | None,
349
+ timeout: int,
350
+ ) -> int:
351
+ if (err := cli_output.require_auth(config)) is not None:
352
+ return err
353
+ if not computer_id:
354
+ return cli_output.report_error("computer id is required")
355
+ if latest and next_goal:
356
+ return cli_output.report_error("--latest and --next cannot be used together")
357
+ if email_id and next_goal:
358
+ return cli_output.report_error("--email-id and --next cannot be used together")
359
+ if timeout < 0:
360
+ return cli_output.report_error("--timeout must be non-negative")
361
+
362
+ mode = "email" if email_id else "next" if next_goal else "latest"
363
+
364
+ client = authenticated_client(config)
365
+ computer_id = computers.resolve_computer_ref(client, computer_id)
366
+ return _goal_watch_polling(
367
+ client, computer_id, mode=mode, email_id=email_id, timeout=timeout
368
+ )
runtime_sdk/output.py ADDED
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import getpass
4
+ import json
5
+ import os
6
+ import sys
7
+ from typing import Any
8
+
9
+ from runtime_sdk.auth import has_credentials
10
+ from runtime_sdk.config import RuntimeConfig
11
+
12
+
13
+ def interactive() -> bool:
14
+ if os.environ.get("RUNTIME_NO_TTY"):
15
+ return False
16
+ try:
17
+ return sys.stdin.isatty() and sys.stdout.isatty()
18
+ except (AttributeError, ValueError):
19
+ return False
20
+
21
+
22
+ def write_json(payload: dict[str, Any], *, error: bool = False) -> int:
23
+ stream = sys.stderr if error else sys.stdout
24
+ stream.write(json.dumps(payload) + "\n")
25
+ stream.flush()
26
+ return 1 if error else 0
27
+
28
+
29
+ def report_error(message: str, *, status_code: int | None = None) -> int:
30
+ payload: dict[str, Any] = {"success": False, "error": message}
31
+ if status_code is not None:
32
+ payload["status_code"] = status_code
33
+ return write_json(payload, error=True)
34
+
35
+
36
+ def report_success(payload: dict[str, Any]) -> int:
37
+ return write_json({"success": True, **payload})
38
+
39
+
40
+ def require_auth(config: RuntimeConfig) -> int | None:
41
+ if has_credentials(config):
42
+ return None
43
+ return report_error("not logged in; run runtime login", status_code=401)
44
+
45
+
46
+ def require_interactive_session(config: RuntimeConfig) -> int | None:
47
+ if has_credentials(config, interactive=True):
48
+ return None
49
+ return report_error(
50
+ "interactive WorkOS login required; run runtime login", status_code=401
51
+ )
52
+
53
+
54
+ def prompt_text(message: str, *, default: str | None = None) -> str | None:
55
+ suffix = f" [{default}]" if default else ""
56
+ answer = input(f"{message}{suffix}: ")
57
+ if not answer and default is not None:
58
+ answer = default
59
+ return answer.strip() or None
60
+
61
+
62
+ def prompt_password(message: str) -> str | None:
63
+ answer = getpass.getpass(f"{message}: ")
64
+ return answer.strip() or None
65
+
66
+
67
+ def prompt_typed_confirm(message: str, expected: str) -> bool:
68
+ answer = prompt_text(f"{message} (type {expected!r} to confirm)") or ""
69
+ return answer.strip().lower() == expected.strip().lower()