codex-python-sdk 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.
@@ -0,0 +1,607 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ import sys
6
+ from typing import Any, Iterator, TextIO
7
+
8
+ from ._shared import first_nonempty_text
9
+ from .types import ResponseEvent
10
+
11
+
12
+ class ExecStyleRenderer:
13
+ """Render `ResponseEvent` stream in a compact CLI style similar to `codex exec`."""
14
+
15
+ def __init__(
16
+ self,
17
+ *,
18
+ stream: TextIO | None = None,
19
+ show_reasoning: bool = True,
20
+ show_system: bool = False,
21
+ show_tool_output: bool = True,
22
+ show_turn_summary: bool = True,
23
+ color: str = "auto",
24
+ ) -> None:
25
+ self.stream = stream or sys.stdout
26
+ self.show_reasoning = show_reasoning
27
+ self.show_system = show_system
28
+ self.show_tool_output = show_tool_output
29
+ self.show_turn_summary = show_turn_summary
30
+
31
+ self._in_assistant_line = False
32
+ self._in_reasoning_line = False
33
+ self._in_tool_output = False
34
+ self._tool_items: dict[str, str] = {}
35
+ self._turn_tool_count = 0
36
+ self._turn_error_count = 0
37
+ self._seen_deprecation_warnings: set[str] = set()
38
+
39
+ self._color_mode = self._normalize_color_mode(color)
40
+ self._use_color = self._compute_use_color(self._color_mode, self.stream)
41
+ self._palette = self._palette_for(self._color_mode)
42
+
43
+ def render(self, event: ResponseEvent) -> None:
44
+ method = event.type
45
+ params = event.raw.get("params") if isinstance(event.raw.get("params"), dict) else {}
46
+
47
+ if method.startswith("codex/event/"):
48
+ self._render_codex_event(method, params, event)
49
+ return
50
+
51
+ if self._render_lifecycle_event(method, params, event):
52
+ return
53
+ if self._render_item_event(method, params, event):
54
+ return
55
+ if self._render_account_event(method, params, event):
56
+ return
57
+
58
+ if self.show_system:
59
+ self._close_inline_sections()
60
+ msg = event.message_text or event.text_delta or ""
61
+ tail = f" {msg}" if msg else ""
62
+ self._println(f"{self._tone('system', '[event]')} {method}{tail}")
63
+
64
+ def _render_lifecycle_event(self, method: str, params: dict[str, Any], event: ResponseEvent) -> bool:
65
+ if method == "thread/ready":
66
+ self._close_inline_sections()
67
+ session = event.session_id or "unknown"
68
+ self._println(f"{self._tone('session', 'Session:')} {session}")
69
+ return True
70
+ if method == "thread/started":
71
+ if self.show_system:
72
+ self._close_inline_sections()
73
+ self._println(f"{self._tone('system', '[thread]')} started")
74
+ return True
75
+ if method == "thread/name/updated":
76
+ if self.show_system:
77
+ self._close_inline_sections()
78
+ name = event.thread_name
79
+ if name is None:
80
+ self._println(f"{self._tone('system', '[thread.name]')} (cleared)")
81
+ else:
82
+ self._println(f"{self._tone('system', '[thread.name]')} {name}")
83
+ return True
84
+ if method == "thread/tokenUsage/updated":
85
+ if self.show_system:
86
+ self._close_inline_sections()
87
+ msg = event.message_text or "token usage updated"
88
+ self._println(f"{self._tone('system', '[usage]')} {msg}")
89
+ return True
90
+ if method == "thread/compacted":
91
+ if self.show_system:
92
+ self._close_inline_sections()
93
+ self._println(f"{self._tone('system', '[context]')} compacted")
94
+ return True
95
+ if method == "turn/started":
96
+ self._close_inline_sections()
97
+ self._println("")
98
+ self._println(self._tone("header", "== Turn Started =="))
99
+ self._turn_tool_count = 0
100
+ self._turn_error_count = 0
101
+ self._tool_items.clear()
102
+ return True
103
+ if method == "turn/completed":
104
+ self._close_inline_sections()
105
+ self._println("")
106
+ if self.show_turn_summary:
107
+ status = self._turn_status(params)
108
+ summary = f"status={status}, tools={self._turn_tool_count}, errors={self._turn_error_count}"
109
+ self._println(f"{self._tone('summary', '[summary]')} {summary}")
110
+ self._println(self._tone("header", "== Turn Completed =="))
111
+ return True
112
+ if method == "error":
113
+ self._close_inline_sections()
114
+ msg = event.message_text or first_nonempty_text(params) or "unknown error"
115
+ self._println(f"{self._tone('error', '[error]')} {msg}")
116
+ self._turn_error_count += 1
117
+ return True
118
+ if method == "deprecationNotice":
119
+ self._render_deprecation_warning(method, params, event)
120
+ return True
121
+ if method in ("configWarning", "windows/worldWritableWarning"):
122
+ self._close_inline_sections()
123
+ msg = event.message_text or first_nonempty_text(params) or method
124
+ self._println(f"{self._tone('warning', '[warning]')} {msg}")
125
+ return True
126
+ return False
127
+
128
+ def _render_item_event(self, method: str, params: dict[str, Any], event: ResponseEvent) -> bool:
129
+ if method == "item/started":
130
+ self._render_item_started(params)
131
+ return True
132
+ if method in ("item/reasoning/summaryTextDelta", "item/reasoning/textDelta"):
133
+ self._render_reasoning_delta(event)
134
+ return True
135
+ if method in ("item/commandExecution/outputDelta", "item/fileChange/outputDelta"):
136
+ self._render_tool_output_delta(params, event)
137
+ return True
138
+ if method == "item/commandExecution/terminalInteraction":
139
+ self._render_terminal_interaction(params)
140
+ return True
141
+ if method == "item/mcpToolCall/progress":
142
+ self._render_mcp_progress(params, event)
143
+ return True
144
+ if method == "item/plan/delta":
145
+ self._render_plan_delta(event)
146
+ return True
147
+ if method == "turn/plan/updated":
148
+ self._render_turn_plan(event)
149
+ return True
150
+ if method == "turn/diff/updated":
151
+ self._render_turn_diff(event)
152
+ return True
153
+ if method == "item/reasoning/summaryPartAdded":
154
+ self._render_summary_part(event)
155
+ return True
156
+ if method == "item/agentMessage/delta":
157
+ self._render_agent_delta(event)
158
+ return True
159
+ if method == "item/completed":
160
+ self._render_item_completed(params, event)
161
+ return True
162
+ return False
163
+
164
+ def _render_account_event(self, method: str, params: dict[str, Any], event: ResponseEvent) -> bool:
165
+ if method not in ("account/rateLimits/updated", "account/updated", "authStatusChange"):
166
+ return False
167
+ if self.show_system:
168
+ self._close_inline_sections()
169
+ msg = event.message_text or first_nonempty_text(params) or method
170
+ self._println(f"{self._tone('system', '[account]')} {msg}")
171
+ return True
172
+
173
+ def _render_item_started(self, params: dict[str, Any]) -> None:
174
+ self._close_inline_sections()
175
+ item = params.get("item") if isinstance(params.get("item"), dict) else {}
176
+ item_type = item.get("type")
177
+ item_id = item.get("id") if isinstance(item.get("id"), str) else None
178
+ if item_id:
179
+ self._tool_items[item_id] = self._label_for_item(item)
180
+ if item_type == "commandExecution":
181
+ command = item.get("command")
182
+ if isinstance(command, str) and command:
183
+ self._println(f"{self._tone('tool', '[tool.exec]')} {command}")
184
+ else:
185
+ self._println(self._tone("tool", "[tool.exec] started"))
186
+ self._turn_tool_count += 1
187
+ return
188
+ if item_type == "mcpToolCall":
189
+ server = item.get("server")
190
+ tool = item.get("tool")
191
+ if isinstance(server, str) and isinstance(tool, str):
192
+ self._println(f"{self._tone('tool', '[tool.mcp]')} {server}/{tool}")
193
+ else:
194
+ self._println(self._tone("tool", "[tool.mcp] started"))
195
+ self._turn_tool_count += 1
196
+ return
197
+ if item_type == "fileChange":
198
+ self._println(self._tone("tool", "[tool.patch] applying file changes"))
199
+ self._turn_tool_count += 1
200
+ return
201
+ if item_type == "webSearch":
202
+ query = item.get("query")
203
+ if isinstance(query, str) and query:
204
+ self._println(f"{self._tone('tool', '[tool.web]')} {query}")
205
+ else:
206
+ self._println(self._tone("tool", "[tool.web] searching"))
207
+ self._turn_tool_count += 1
208
+ return
209
+ if item_type == "plan":
210
+ if self.show_system:
211
+ self._println(self._tone("plan", "[plan] started"))
212
+ return
213
+ if item_type == "reasoning":
214
+ if self.show_reasoning and self.show_system:
215
+ self._println(self._tone("thinking", "[thinking] started"))
216
+ return
217
+ if self.show_system:
218
+ self._println(f"{self._tone('system', '[item]')} {item_type or 'unknown'} started")
219
+
220
+ def _render_reasoning_delta(self, event: ResponseEvent) -> None:
221
+ if not self.show_reasoning:
222
+ return
223
+ delta = event.text_delta or event.message_text
224
+ if not delta:
225
+ return
226
+ self._close_assistant()
227
+ self._close_tool_output()
228
+ if not self._in_reasoning_line:
229
+ self._print(self._tone("thinking", "[thinking] "))
230
+ self._in_reasoning_line = True
231
+ self._print(delta)
232
+
233
+ def _render_tool_output_delta(self, params: dict[str, Any], event: ResponseEvent) -> None:
234
+ if not self.show_tool_output:
235
+ return
236
+ delta = event.text_delta or event.message_text
237
+ if not delta:
238
+ return
239
+ self._close_assistant()
240
+ self._close_reasoning()
241
+ if not self._in_tool_output:
242
+ label = self._label_for_item_id(params.get("itemId"))
243
+ self._println(self._tone("tool_output", f"[tool.output:{label}]"))
244
+ self._in_tool_output = True
245
+ self._print(delta)
246
+
247
+ def _render_terminal_interaction(self, params: dict[str, Any]) -> None:
248
+ self._close_inline_sections()
249
+ user_stdin = params.get("stdin")
250
+ if isinstance(user_stdin, str) and user_stdin:
251
+ self._println(f"{self._tone('tool', '[tool.stdin]')} {user_stdin.rstrip()}")
252
+ elif self.show_system:
253
+ self._println(self._tone("tool", "[tool.stdin] input sent"))
254
+
255
+ def _render_mcp_progress(self, params: dict[str, Any], event: ResponseEvent) -> None:
256
+ msg = event.message_text or first_nonempty_text(params)
257
+ if not msg:
258
+ return
259
+ self._close_inline_sections()
260
+ self._println(f"{self._tone('tool', '[tool.mcp.progress]')} {msg}")
261
+
262
+ def _render_plan_delta(self, event: ResponseEvent) -> None:
263
+ if not self.show_system:
264
+ return
265
+ delta = event.text_delta or event.message_text
266
+ if not delta:
267
+ return
268
+ self._close_inline_sections()
269
+ self._println(f"{self._tone('plan', '[plan]')} {delta}")
270
+
271
+ def _render_turn_plan(self, event: ResponseEvent) -> None:
272
+ if not self.show_system:
273
+ return
274
+ msg = event.message_text
275
+ if not msg:
276
+ return
277
+ self._close_inline_sections()
278
+ self._println(f"{self._tone('plan', '[turn.plan]')} {msg}")
279
+
280
+ def _render_turn_diff(self, event: ResponseEvent) -> None:
281
+ if not self.show_system:
282
+ return
283
+ msg = event.message_text
284
+ if not msg:
285
+ return
286
+ self._close_inline_sections()
287
+ self._println(f"{self._tone('system', '[turn.diff]')} {msg}")
288
+
289
+ def _render_summary_part(self, event: ResponseEvent) -> None:
290
+ if not self.show_reasoning:
291
+ return
292
+ self._close_assistant()
293
+ self._close_tool_output()
294
+ self._close_reasoning()
295
+ idx = event.summary_index
296
+ if idx is None:
297
+ self._println(self._tone("thinking", "[thinking] summary part added"))
298
+ else:
299
+ self._println(self._tone("thinking", f"[thinking] summary part #{idx}"))
300
+
301
+ def _render_agent_delta(self, event: ResponseEvent) -> None:
302
+ delta = event.text_delta or event.message_text
303
+ if not delta:
304
+ return
305
+ self._close_reasoning()
306
+ self._close_tool_output()
307
+ if not self._in_assistant_line:
308
+ self._print(self._tone("assistant", "Assistant: "))
309
+ self._in_assistant_line = True
310
+ self._print(delta)
311
+
312
+ def _render_item_completed(self, params: dict[str, Any], event: ResponseEvent) -> None:
313
+ item = params.get("item") if isinstance(params.get("item"), dict) else {}
314
+ if item.get("type") == "agentMessage":
315
+ if not self._in_assistant_line and event.message_text:
316
+ self._close_reasoning()
317
+ self._close_tool_output()
318
+ self._print(self._tone("assistant", "Assistant: "))
319
+ self._print(event.message_text)
320
+ self._in_assistant_line = True
321
+ return
322
+ if item.get("type") == "commandExecution":
323
+ self._close_inline_sections()
324
+ status = item.get("status")
325
+ exit_code = item.get("exitCode")
326
+ duration_ms = item.get("durationMs")
327
+ status_text = str(status) if isinstance(status, str) else "completed"
328
+ suffix: list[str] = [status_text]
329
+ if isinstance(exit_code, int):
330
+ suffix.append(f"exit={exit_code}")
331
+ if isinstance(duration_ms, int):
332
+ suffix.append(f"{duration_ms}ms")
333
+ self._println(f"{self._tone('tool', '[tool.exec.done]')} {', '.join(suffix)}")
334
+ return
335
+ if item.get("type") == "mcpToolCall":
336
+ self._close_inline_sections()
337
+ status = item.get("status")
338
+ status_text = str(status) if isinstance(status, str) else "completed"
339
+ self._println(f"{self._tone('tool', '[tool.mcp.done]')} {status_text}")
340
+ return
341
+ if item.get("type") == "fileChange":
342
+ self._close_inline_sections()
343
+ status = item.get("status")
344
+ status_text = str(status) if isinstance(status, str) else "completed"
345
+ self._println(f"{self._tone('tool', '[tool.patch.done]')} {status_text}")
346
+ return
347
+ if self.show_system:
348
+ self._close_inline_sections()
349
+ self._println(f"{self._tone('system', '[item]')} {item.get('type', 'unknown')} completed")
350
+
351
+ def finish(self) -> None:
352
+ self._close_inline_sections()
353
+
354
+ def _close_inline_sections(self) -> None:
355
+ self._close_assistant()
356
+ self._close_reasoning()
357
+ self._close_tool_output()
358
+
359
+ def _close_assistant(self) -> None:
360
+ if self._in_assistant_line:
361
+ self._println("")
362
+ self._in_assistant_line = False
363
+
364
+ def _close_reasoning(self) -> None:
365
+ if self._in_reasoning_line:
366
+ self._println("")
367
+ self._in_reasoning_line = False
368
+
369
+ def _close_tool_output(self) -> None:
370
+ if self._in_tool_output:
371
+ self._println("")
372
+ self._in_tool_output = False
373
+
374
+ def _print(self, text: str) -> None:
375
+ self.stream.write(text)
376
+ self.stream.flush()
377
+
378
+ def _println(self, text: str) -> None:
379
+ self.stream.write(text + "\n")
380
+ self.stream.flush()
381
+
382
+ def _label_for_item(self, item: dict[str, Any]) -> str:
383
+ item_type = item.get("type")
384
+ if item_type == "commandExecution":
385
+ return "exec"
386
+ if item_type == "fileChange":
387
+ return "patch"
388
+ if item_type == "webSearch":
389
+ return "web"
390
+ if item_type == "mcpToolCall":
391
+ server = item.get("server")
392
+ tool = item.get("tool")
393
+ if isinstance(server, str) and isinstance(tool, str):
394
+ return f"mcp:{server}/{tool}"
395
+ return "mcp"
396
+ if isinstance(item_type, str) and item_type:
397
+ return item_type
398
+ return "tool"
399
+
400
+ def _label_for_item_id(self, item_id: Any) -> str:
401
+ if isinstance(item_id, str):
402
+ return self._tool_items.get(item_id, "tool")
403
+ return "tool"
404
+
405
+ @staticmethod
406
+ def _turn_status(params: dict[str, Any]) -> str:
407
+ turn = params.get("turn")
408
+ if isinstance(turn, dict):
409
+ status = turn.get("status")
410
+ if isinstance(status, str) and status:
411
+ return status
412
+ return "completed"
413
+
414
+ def _render_codex_event(self, method: str, params: dict[str, Any], event: ResponseEvent) -> None:
415
+ suffix = method.removeprefix("codex/event/")
416
+
417
+ # `codex/event/*` frequently duplicates canonical `item/*` events.
418
+ if suffix in {
419
+ "reasoning_content_delta",
420
+ "agent_reasoning_delta",
421
+ "agent_message_content_delta",
422
+ "item_started",
423
+ "item_completed",
424
+ "task_started",
425
+ "task_complete",
426
+ }:
427
+ return
428
+
429
+ if suffix == "deprecation_notice":
430
+ self._render_deprecation_warning(method, params, event)
431
+ return
432
+
433
+ if suffix == "stream_error":
434
+ self._close_inline_sections()
435
+ msg = event.message_text or first_nonempty_text(params) or suffix
436
+ self._println(f"{self._tone('error', '[error]')} {msg}")
437
+ self._turn_error_count += 1
438
+ return
439
+
440
+ if self.show_system:
441
+ self._close_inline_sections()
442
+ msg = event.message_text or event.text_delta or first_nonempty_text(params) or ""
443
+ tail = f" {msg}" if msg else ""
444
+ self._println(f"{self._tone('system', f'[codex.{suffix}]')}{tail}")
445
+
446
+ def _render_deprecation_warning(self, source_method: str, params: dict[str, Any], event: ResponseEvent) -> None:
447
+ self._close_inline_sections()
448
+ message = self._format_deprecation_warning(source_method, params, event)
449
+ if not message:
450
+ return
451
+ key = self._normalize_deprecation_key(message)
452
+ if key in self._seen_deprecation_warnings:
453
+ return
454
+ self._seen_deprecation_warnings.add(key)
455
+ self._println(f"{self._tone('warning', '[warning]')} {message}")
456
+
457
+ @staticmethod
458
+ def _normalize_deprecation_key(message: str) -> str:
459
+ return re.sub(r"[^a-z0-9]+", "", message.lower())
460
+
461
+ @staticmethod
462
+ def _find_first_string_for_keys(value: Any, keys: tuple[str, ...]) -> str | None:
463
+ if isinstance(value, dict):
464
+ for key in keys:
465
+ candidate = value.get(key)
466
+ if isinstance(candidate, str) and candidate.strip():
467
+ return candidate.strip()
468
+ for nested in value.values():
469
+ text = ExecStyleRenderer._find_first_string_for_keys(nested, keys)
470
+ if text:
471
+ return text
472
+ elif isinstance(value, list):
473
+ for item in value:
474
+ text = ExecStyleRenderer._find_first_string_for_keys(item, keys)
475
+ if text:
476
+ return text
477
+ return None
478
+
479
+ def _format_deprecation_warning(self, source_method: str, params: dict[str, Any], event: ResponseEvent) -> str | None:
480
+ raw_message = event.message_text or first_nonempty_text(params) or ""
481
+ normalized = self._normalize_deprecation_key(raw_message)
482
+ if normalized and normalized not in {"deprecationnotice", "deprecated"}:
483
+ return raw_message
484
+
485
+ target = self._find_first_string_for_keys(
486
+ params,
487
+ (
488
+ "method",
489
+ "event",
490
+ "name",
491
+ "interface",
492
+ "api",
493
+ "path",
494
+ "deprecated",
495
+ ),
496
+ )
497
+ replacement = self._find_first_string_for_keys(
498
+ params,
499
+ ("replacement", "newMethod", "newEvent", "instead", "use"),
500
+ )
501
+ if target and replacement:
502
+ return f"{target} is deprecated; use {replacement}"
503
+ if target:
504
+ return f"{target} is deprecated"
505
+ return None
506
+
507
+ def _tone(self, tone: str, text: str) -> str:
508
+ if not self._use_color:
509
+ return text
510
+ code = self._palette.get(tone)
511
+ if not code:
512
+ return text
513
+ return f"{code}{text}\x1b[0m"
514
+
515
+ @staticmethod
516
+ def _normalize_color_mode(color: str) -> str:
517
+ mode = str(color or "auto").strip().lower()
518
+ alias = {"on": "soft", "true": "soft", "false": "off", "none": "off"}
519
+ mode = alias.get(mode, mode)
520
+ if mode not in {"auto", "off", "soft", "vivid"}:
521
+ return "auto"
522
+ return mode
523
+
524
+ @staticmethod
525
+ def _compute_use_color(mode: str, stream: TextIO) -> bool:
526
+ if mode == "off":
527
+ return False
528
+ if mode in {"soft", "vivid"}:
529
+ return True
530
+
531
+ if os.environ.get("NO_COLOR") is not None:
532
+ return False
533
+ if os.environ.get("CLICOLOR_FORCE") == "1":
534
+ return True
535
+
536
+ isatty = getattr(stream, "isatty", None)
537
+ if callable(isatty):
538
+ try:
539
+ return bool(isatty())
540
+ except Exception:
541
+ return False
542
+ return False
543
+
544
+ @staticmethod
545
+ def _palette_for(mode: str) -> dict[str, str]:
546
+ if mode == "vivid":
547
+ return {
548
+ "header": "\x1b[1;96m",
549
+ "session": "\x1b[1;36m",
550
+ "assistant": "\x1b[1;32m",
551
+ "thinking": "\x1b[1;35m",
552
+ "tool": "\x1b[1;33m",
553
+ "tool_output": "\x1b[1;94m",
554
+ "summary": "\x1b[1;34m",
555
+ "warning": "\x1b[1;93m",
556
+ "error": "\x1b[1;91m",
557
+ "system": "\x1b[2;37m",
558
+ "plan": "\x1b[1;34m",
559
+ }
560
+ return {
561
+ "header": "\x1b[36m",
562
+ "session": "\x1b[36m",
563
+ "assistant": "\x1b[32m",
564
+ "thinking": "\x1b[35m",
565
+ "tool": "\x1b[33m",
566
+ "tool_output": "\x1b[34m",
567
+ "summary": "\x1b[34m",
568
+ "warning": "\x1b[33m",
569
+ "error": "\x1b[31m",
570
+ "system": "\x1b[2m",
571
+ "plan": "\x1b[34m",
572
+ }
573
+
574
+
575
+ def render_exec_style_events(
576
+ events: Iterator[ResponseEvent],
577
+ *,
578
+ stream: TextIO | None = None,
579
+ show_reasoning: bool = True,
580
+ show_system: bool = False,
581
+ show_tool_output: bool = True,
582
+ show_turn_summary: bool = True,
583
+ color: str = "auto",
584
+ ) -> None:
585
+ """Render streaming events in a compact `codex exec`-like terminal style.
586
+
587
+ Args:
588
+ events: Iterator from ``responses_events``.
589
+ stream: Output stream; defaults to ``sys.stdout``.
590
+ show_reasoning: Whether to show reasoning deltas.
591
+ show_system: Whether to show system events.
592
+ show_tool_output: Whether to show command/file output deltas.
593
+ show_turn_summary: Whether to print turn summary at completion.
594
+ color: ``auto``, ``off``, ``soft``, or ``vivid``.
595
+ """
596
+
597
+ renderer = ExecStyleRenderer(
598
+ stream=stream,
599
+ show_reasoning=show_reasoning,
600
+ show_system=show_system,
601
+ show_tool_output=show_tool_output,
602
+ show_turn_summary=show_turn_summary,
603
+ color=color,
604
+ )
605
+ for event in events:
606
+ renderer.render(event)
607
+ renderer.finish()