Skip to content

Commit bcc708e

Browse files
committed
feat: add Python interface to Codex CLI (run_exec) with robust errors; tests, docs
1 parent 8524734 commit bcc708e

4 files changed

Lines changed: 161 additions & 4 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,23 @@ A minimal Python library scaffold using `uv` with Python 3.13+.
1313
- Git: `git@github.com:gersmann/codex-python.git`
1414
- URL: https://github.com/gersmann/codex-python
1515

16+
## Usage
17+
18+
Basic non-interactive execution via Codex CLI:
19+
20+
```
21+
from codex import run_exec
22+
23+
out = run_exec("explain this repo")
24+
print(out)
25+
```
26+
27+
Options:
28+
29+
- Choose model: `run_exec("...", model="gpt-4.1")`
30+
- Full auto: `run_exec("scaffold a cli", full_auto=True)`
31+
- Run in another dir: `run_exec("...", cd="/path/to/project")`
32+
1633
### Install uv
1734

1835
- macOS (Homebrew): `brew install uv`

codex/__init__.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
"""codex
22
3-
Minimal Python library package.
3+
Python interface for the Codex CLI.
44
5-
This package is intentionally lightweight; extend as needed.
5+
Usage:
6+
from codex import run_exec
7+
output = run_exec("explain this codebase to me")
68
"""
79

8-
__all__ = ["__version__"]
10+
from .api import CodexError, CodexNotFoundError, CodexProcessError, find_binary, run_exec
11+
12+
__all__ = [
13+
"__version__",
14+
"CodexError",
15+
"CodexNotFoundError",
16+
"CodexProcessError",
17+
"find_binary",
18+
"run_exec",
19+
]
920

1021
# Managed by Hatch via pyproject.toml [tool.hatch.version]
1122
__version__ = "0.1.0"
12-

codex/api.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import shutil
5+
import subprocess
6+
from dataclasses import dataclass
7+
from typing import Iterable, Mapping, Optional, Sequence
8+
9+
10+
class CodexError(Exception):
11+
"""Base exception for codex-python."""
12+
13+
14+
class CodexNotFoundError(CodexError):
15+
"""Raised when the 'codex' binary cannot be found or executed."""
16+
17+
def __init__(self, executable: str = "codex") -> None:
18+
super().__init__(
19+
f"Codex CLI not found: '{executable}'.\n"
20+
"Install from https://github.com/openai/codex or ensure it is on PATH."
21+
)
22+
self.executable = executable
23+
24+
25+
@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+
41+
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
48+
49+
50+
def run_exec(
51+
prompt: str,
52+
*,
53+
model: Optional[str] = None,
54+
full_auto: bool = False,
55+
cd: Optional[str] = None,
56+
timeout: Optional[float] = None,
57+
env: Optional[Mapping[str, str]] = None,
58+
executable: str = "codex",
59+
extra_args: Optional[Iterable[str]] = None,
60+
) -> str:
61+
"""
62+
Run `codex exec` with the given prompt and return stdout as text.
63+
64+
- Raises CodexNotFoundError if the binary is unavailable.
65+
- Raises CodexProcessError on non‑zero exit with captured stdout/stderr.
66+
"""
67+
bin_path = find_binary(executable)
68+
69+
cmd: list[str] = [bin_path]
70+
71+
if cd:
72+
cmd.extend(["--cd", cd])
73+
if model:
74+
cmd.extend(["-m", model])
75+
if full_auto:
76+
cmd.append("--full-auto")
77+
if extra_args:
78+
cmd.extend(list(extra_args))
79+
80+
cmd.extend(["exec", prompt])
81+
82+
completed = subprocess.run(
83+
cmd,
84+
capture_output=True,
85+
text=True,
86+
timeout=timeout,
87+
env={**os.environ, **(dict(env) if env else {})},
88+
check=False,
89+
)
90+
91+
stdout = completed.stdout or ""
92+
stderr = completed.stderr or ""
93+
if completed.returncode != 0:
94+
raise CodexProcessError(
95+
returncode=completed.returncode,
96+
cmd=tuple(cmd),
97+
stdout=stdout,
98+
stderr=stderr,
99+
)
100+
return stdout
101+

tests/test_api.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import os
2+
import stat
3+
import tempfile
4+
from pathlib import Path
5+
6+
import pytest
7+
8+
from codex.api import CodexNotFoundError, run_exec
9+
10+
11+
def test_missing_binary_raises():
12+
with pytest.raises(CodexNotFoundError):
13+
run_exec("hello", executable="codex-does-not-exist-xyz")
14+
15+
16+
def test_runs_with_dummy_binary(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
17+
# Create a dummy 'codex' executable that echoes args and succeeds
18+
bin_dir = tmp_path / "bin"
19+
bin_dir.mkdir()
20+
codex_path = bin_dir / "codex"
21+
codex_path.write_text("""#!/bin/sh\necho \"[dummy] $@\"\n""")
22+
codex_path.chmod(codex_path.stat().st_mode | stat.S_IXUSR)
23+
24+
# Prepend our dummy bin to PATH
25+
monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}" + os.environ.get("PATH", ""))
26+
27+
out = run_exec("hello world", executable="codex")
28+
assert "[dummy] exec hello world" in out
29+

0 commit comments

Comments
 (0)