11from __future__ import annotations
22
3- import os
4- import shutil
5- import subprocess
63from collections .abc import Iterable , Mapping , Sequence
74from dataclasses import dataclass
5+ from typing import Iterator
6+
7+ from .config import CodexConfig
8+ from .event import Event
9+ from .native import run_exec_collect as native_run_exec_collect
10+ from .native import start_exec_stream as native_start_exec_stream
811
912
1013class CodexError (Exception ):
1114 """Base exception for codex-python."""
1215
1316
14- class CodexNotFoundError (CodexError ):
15- """Raised when the 'codex' binary cannot be found or executed ."""
17+ class CodexNativeError (CodexError ):
18+ """Raised when the native extension is not available or fails ."""
1619
17- def __init__ (self , executable : str = "codex" ) -> None :
20+ def __init__ (self ) -> None :
1821 super ().__init__ (
19- f"Codex CLI not found: ' { executable } '. \n "
20- "Install from https://github.com/openai/codex or ensure it is on PATH ."
22+ "codex_native extension not installed or failed to run. "
23+ "Run `make dev-native` or ensure native wheels are installed ."
2124 )
22- self .executable = executable
2325
2426
2527@dataclass (slots = True )
26- class CodexProcessError (CodexError ):
27- """Raised when the codex process exits with a non‑zero status."""
28-
29- returncode : int
30- cmd : Sequence [str ]
31- stdout : str
32- stderr : str
33-
34- def __str__ (self ) -> str : # pragma: no cover - repr is sufficient
35- return (
36- f"Codex process failed with exit code { self .returncode } .\n "
37- f"Command: { ' ' .join (self .cmd )} \n "
38- f"stderr:\n { self .stderr .strip ()} "
39- )
40-
28+ class Conversation :
29+ """A stateful conversation with Codex, streaming events natively."""
4130
42- def find_binary (executable : str = "codex" ) -> str :
43- """Return the absolute path to the Codex CLI binary or raise if not found."""
44- path = shutil .which (executable )
45- if not path :
46- raise CodexNotFoundError (executable )
47- return path
31+ _stream : Iterable [dict ]
4832
49-
50- def run_exec (
51- prompt : str ,
52- * ,
53- model : str | None = None ,
54- oss : bool = False ,
55- full_auto : bool = False ,
56- cd : str | None = None ,
57- skip_git_repo_check : bool = False ,
58- timeout : float | None = None ,
59- env : Mapping [str , str ] | None = None ,
60- executable : str = "codex" ,
61- extra_args : Iterable [str ] | None = None ,
62- json : bool = False ,
63- ) -> str :
64- """
65- Run `codex exec` with the given prompt and return stdout as text.
66-
67- - Raises CodexNotFoundError if the binary is unavailable.
68- - Raises CodexProcessError on non‑zero exit with captured stdout/stderr.
69- """
70- bin_path = find_binary (executable )
71-
72- cmd : list [str ] = [bin_path ]
73-
74- if cd :
75- cmd .extend (["--cd" , cd ])
76- if model :
77- cmd .extend (["-m" , model ])
78- if oss :
79- cmd .append ("--oss" )
80- if full_auto :
81- cmd .append ("--full-auto" )
82- if skip_git_repo_check :
83- cmd .append ("--skip-git-repo-check" )
84- if extra_args :
85- cmd .extend (list (extra_args ))
86-
87- cmd .append ("exec" )
88- if json :
89- cmd .append ("--json" )
90- cmd .append (prompt )
91-
92- completed = subprocess .run (
93- cmd ,
94- capture_output = True ,
95- text = True ,
96- timeout = timeout ,
97- env = {** os .environ , ** (dict (env ) if env else {})},
98- check = False ,
99- )
100-
101- stdout = completed .stdout or ""
102- stderr = completed .stderr or ""
103- if completed .returncode != 0 :
104- raise CodexProcessError (
105- returncode = completed .returncode ,
106- cmd = tuple (cmd ),
107- stdout = stdout ,
108- stderr = stderr ,
109- )
110- return stdout
33+ def __iter__ (self ) -> Iterator [Event ]:
34+ """Yield `Event` objects from the native stream."""
35+ for item in self ._stream :
36+ yield Event .model_validate (item )
11137
11238
11339@dataclass (slots = True )
11440class CodexClient :
115- """Lightweight, synchronous client for the Codex CLI .
41+ """Lightweight, synchronous client for the native Codex core .
11642
117- Provides defaults for repeated invocations and convenience helpers .
43+ Provides defaults for repeated invocations and conversation management .
11844 """
11945
120- executable : str = "codex"
121- model : str | None = None
122- full_auto : bool = False
123- cd : str | None = None
46+ config : CodexConfig | None = None
47+ load_default_config : bool = True
12448 env : Mapping [str , str ] | None = None
12549 extra_args : Sequence [str ] | None = None
12650
127- def ensure_available (self ) -> str :
128- """Return the resolved binary path or raise CodexNotFoundError."""
129- return find_binary (self .executable )
130-
131- def run (
51+ def start_conversation (
13252 self ,
13353 prompt : str ,
13454 * ,
135- model : str | None = None ,
136- oss : bool | None = None ,
137- full_auto : bool | None = None ,
138- cd : str | None = None ,
139- skip_git_repo_check : bool | None = None ,
140- timeout : float | None = None ,
141- env : Mapping [str , str ] | None = None ,
142- extra_args : Iterable [str ] | None = None ,
143- ) -> str :
144- """Execute `codex exec` and return stdout.
145-
146- Explicit arguments override the client's defaults.
147- """
148- eff_model = model if model is not None else self .model
149- eff_full_auto = full_auto if full_auto is not None else self .full_auto
150- eff_cd = cd if cd is not None else self .cd
151- eff_oss = bool (oss ) if oss is not None else False
152- eff_skip_git = bool (skip_git_repo_check ) if skip_git_repo_check is not None else False
153-
154- # Merge environment overlays; run_exec will merge with os.environ
155- merged_env : Mapping [str , str ] | None
156- if self .env and env :
157- tmp = dict (self .env )
158- tmp .update (env )
159- merged_env = tmp
160- else :
161- merged_env = env or self .env
162-
163- # Compose extra args
164- eff_extra : list [str ] = []
165- if self .extra_args :
166- eff_extra .extend (self .extra_args )
167- if extra_args :
168- eff_extra .extend (list (extra_args ))
169-
170- return run_exec (
55+ config : CodexConfig | None = None ,
56+ load_default_config : bool | None = None ,
57+ ) -> Conversation :
58+ """Start a new conversation and return a streaming iterator over events."""
59+ eff_config = config if config is not None else self .config
60+ eff_load_default_config = (
61+ load_default_config
62+ if load_default_config is not None
63+ else self .load_default_config
64+ )
65+
66+ try :
67+ stream = native_start_exec_stream (
68+ prompt ,
69+ config_overrides = eff_config .to_dict () if eff_config else None ,
70+ load_default_config = eff_load_default_config ,
71+ )
72+ return Conversation (_stream = stream )
73+ except RuntimeError as e :
74+ raise CodexNativeError () from e
75+
76+
77+ def run_exec (
78+ prompt : str ,
79+ * ,
80+ config : CodexConfig | None = None ,
81+ load_default_config : bool = True ,
82+ ) -> list [Event ]:
83+ """
84+ Run a prompt through the native Codex engine and return a list of events.
85+
86+ - Raises CodexNativeError if the native extension is unavailable or fails.
87+ """
88+ try :
89+ events = native_run_exec_collect (
17190 prompt ,
172- model = eff_model ,
173- oss = eff_oss ,
174- full_auto = eff_full_auto ,
175- cd = eff_cd ,
176- skip_git_repo_check = eff_skip_git ,
177- timeout = timeout ,
178- env = merged_env ,
179- executable = self .executable ,
180- extra_args = eff_extra ,
91+ config_overrides = config .to_dict () if config else None ,
92+ load_default_config = load_default_config ,
18193 )
94+ return [Event .model_validate (e ) for e in events ]
95+ except RuntimeError as e :
96+ raise CodexNativeError () from e
0 commit comments