python-codex 0.1.0__py3-none-any.whl → 0.1.2__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,205 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import hashlib
5
+ import json
6
+ import threading
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+ from pathlib import Path
9
+ from urllib.parse import unquote, urlparse
10
+
11
+ from .portable import (
12
+ DEFAULT_STORAGE_SERVER,
13
+ HEALTHCHECK_PATH,
14
+ STORAGE_API_PREFIX,
15
+ _call_id_from_payload,
16
+ )
17
+
18
+
19
+ class CodexStorageServer:
20
+ def __init__(
21
+ self,
22
+ root: str | Path,
23
+ host: str = "127.0.0.1",
24
+ port: int = 5577,
25
+ ) -> None:
26
+ self._root = Path(root).resolve()
27
+ self._root.mkdir(parents=True, exist_ok=True)
28
+ self._objects_dir = self._root / "objects"
29
+ self._objects_dir.mkdir(parents=True, exist_ok=True)
30
+ self._server = ThreadingHTTPServer((host, port), self._build_handler())
31
+ self._thread: threading.Thread | None = None
32
+
33
+ @property
34
+ def host(self) -> str:
35
+ return str(self._server.server_address[0])
36
+
37
+ @property
38
+ def port(self) -> int:
39
+ return int(self._server.server_address[1])
40
+
41
+ @property
42
+ def server_address(self) -> str:
43
+ return f"{self.host}:{self.port}"
44
+
45
+ @property
46
+ def base_url(self) -> str:
47
+ return f"http://{self.server_address}{STORAGE_API_PREFIX}"
48
+
49
+ @property
50
+ def root(self) -> Path:
51
+ return self._root
52
+
53
+ def start(self) -> None:
54
+ if self._thread is not None:
55
+ return
56
+ self._thread = threading.Thread(
57
+ target=self._server.serve_forever,
58
+ name="pycodex-storage-server",
59
+ daemon=True,
60
+ )
61
+ self._thread.start()
62
+
63
+ def stop(self) -> None:
64
+ self._server.shutdown()
65
+ self._server.server_close()
66
+ if self._thread is not None:
67
+ self._thread.join(timeout=5.0)
68
+ self._thread = None
69
+
70
+ def _build_handler(self):
71
+ server = self
72
+
73
+ class Handler(BaseHTTPRequestHandler):
74
+ def do_GET(self) -> None: # noqa: N802
75
+ path = urlparse(self.path).path
76
+ if path == HEALTHCHECK_PATH:
77
+ self._send_json(200, {"ok": True})
78
+ return
79
+ if not path.startswith(f"{STORAGE_API_PREFIX}/call/"):
80
+ self._send_json(404, {"error": "not found"})
81
+ return
82
+ call_id = unquote(path[len(f"{STORAGE_API_PREFIX}/call/") :]).strip()
83
+ if not call_id:
84
+ self._send_json(400, {"error": "missing call_id"})
85
+ return
86
+ object_path = server._object_path(call_id)
87
+ if not object_path.is_file():
88
+ self._send_json(404, {"error": "not found"})
89
+ return
90
+ payload = object_path.read_bytes()
91
+ print(
92
+ "[server] call: "
93
+ f"client={self.client_address[0]} call_id={call_id} path={object_path}",
94
+ flush=True,
95
+ )
96
+ self.send_response(200)
97
+ self.send_header("Content-Type", "application/octet-stream")
98
+ self.send_header("Content-Length", str(len(payload)))
99
+ self.send_header("X-Pycodex-Sha256", hashlib.sha256(payload).hexdigest())
100
+ self.send_header("X-Pycodex-Call-Id", call_id)
101
+ self.end_headers()
102
+ self.wfile.write(payload)
103
+
104
+ def do_POST(self) -> None: # noqa: N802
105
+ path = urlparse(self.path).path
106
+ if path != f"{STORAGE_API_PREFIX}/put":
107
+ self._send_json(404, {"error": "not found"})
108
+ return
109
+ content_length = int(self.headers.get("Content-Length", "0") or "0")
110
+ if content_length <= 0:
111
+ self._send_json(400, {"error": "empty body"})
112
+ return
113
+ payload = self.rfile.read(content_length)
114
+ sha256 = hashlib.sha256(payload).hexdigest()
115
+ expected_sha256 = self.headers.get("X-Pycodex-Sha256", "").strip().lower()
116
+ if expected_sha256 and expected_sha256 != sha256:
117
+ self._send_json(400, {"error": "checksum mismatch"})
118
+ return
119
+ call_id = _call_id_from_payload(payload)
120
+ object_path = server._object_path(call_id)
121
+ if not object_path.is_file():
122
+ object_path.write_bytes(payload)
123
+ status = "stored"
124
+ else:
125
+ status = "reused"
126
+ print(
127
+ "[server] put: "
128
+ f"client={self.client_address[0]} "
129
+ f"call_id={call_id} status={status} path={object_path}",
130
+ flush=True,
131
+ )
132
+ host_header = self.headers.get("Host", server.server_address).strip() or server.server_address
133
+ self._send_json(
134
+ 200,
135
+ {
136
+ "call_id": call_id,
137
+ "call": f"{call_id}@{host_header}",
138
+ },
139
+ )
140
+
141
+ def log_message(self, _format: str, *_args) -> None:
142
+ return
143
+
144
+ def _send_json(self, status: int, payload: dict[str, object]) -> None:
145
+ body = json.dumps(payload).encode("utf-8")
146
+ self.send_response(status)
147
+ self.send_header("Content-Type", "application/json")
148
+ self.send_header("Content-Length", str(len(body)))
149
+ self.end_headers()
150
+ self.wfile.write(body)
151
+
152
+ return Handler
153
+
154
+ def _object_path(self, call_id: str) -> Path:
155
+ return self._objects_dir / f"{call_id}.bin"
156
+
157
+
158
+ def build_parser() -> argparse.ArgumentParser:
159
+ parser = argparse.ArgumentParser(
160
+ prog="python -m pycodex.portable_server",
161
+ description="Run a pycodex remote storage service for --put/--call testing.",
162
+ )
163
+ parser.add_argument(
164
+ "--root",
165
+ default=str(Path(".tmp") / "pycodex_storage"),
166
+ help="Directory used to store uploaded encrypted bundles.",
167
+ )
168
+ parser.add_argument(
169
+ "--host",
170
+ default=DEFAULT_STORAGE_SERVER.split(":", 1)[0],
171
+ help="Host interface to bind.",
172
+ )
173
+ parser.add_argument(
174
+ "--port",
175
+ type=int,
176
+ default=int(DEFAULT_STORAGE_SERVER.split(":", 1)[1]),
177
+ help="Port to bind.",
178
+ )
179
+ return parser
180
+
181
+
182
+ def main(argv: list[str] | None = None) -> int:
183
+ parser = build_parser()
184
+ args = parser.parse_args(argv)
185
+ server = CodexStorageServer(args.root, host=args.host, port=args.port)
186
+ server.start()
187
+ print(f"storage server listening on {server.base_url}", flush=True)
188
+ print(f"storage root: {server.root}", flush=True)
189
+ print(f"put current home: pycodex --put @{server.server_address}", flush=True)
190
+ print(
191
+ f"put custom home: pycodex --put /data/.codex/@{server.server_address}",
192
+ flush=True,
193
+ )
194
+ try:
195
+ if server._thread is not None:
196
+ server._thread.join()
197
+ except KeyboardInterrupt:
198
+ return 130
199
+ finally:
200
+ server.stop()
201
+ return 0
202
+
203
+
204
+ if __name__ == "__main__":
205
+ raise SystemExit(main())
pycodex/runtime.py CHANGED
@@ -3,12 +3,15 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  from collections import deque
5
5
  from dataclasses import dataclass
6
- from typing import Literal
6
+ from typing import TYPE_CHECKING, Literal
7
7
 
8
8
  from .agent import AgentLoop, EventHandler, NOOP_EVENT_HANDLER, TurnInterrupted
9
9
  from .protocol import AgentEvent, Operation, ShutdownOp, Submission, TurnResult, UserTurnOp
10
10
  from .utils import uuid7_string
11
11
 
12
+ if TYPE_CHECKING:
13
+ from .runtime_services import RuntimeEnvironment
14
+
12
15
 
13
16
  @dataclass(slots=True)
14
17
  class _QueuedSubmission:
@@ -20,8 +23,9 @@ class _QueuedSubmission:
20
23
  class AgentRuntime:
21
24
  """Thin outer queue that mirrors the Rust `submission_loop` shape."""
22
25
 
23
- def __init__(self, agent_loop: AgentLoop) -> None:
26
+ def __init__(self, agent_loop: AgentLoop, runtime_environment: RuntimeEnvironment | None = None) -> None:
24
27
  self._agent_loop = agent_loop
28
+ self.runtime_environment = runtime_environment
25
29
  self._enqueue_queue: deque[_QueuedSubmission] = deque()
26
30
  self._steer_queue: deque[_QueuedSubmission] = deque()
27
31
  self._queue_lock = asyncio.Lock()
@@ -346,6 +346,9 @@ class SubAgentManager:
346
346
  managed.state = "completed"
347
347
  finally:
348
348
  managed.pending_submission_ids.discard(submission_id)
349
+ if managed.pending_submission_ids and managed.error_message is None:
350
+ managed.completed_message = None
351
+ managed.state = "running"
349
352
  async with self._condition:
350
353
  self._condition.notify_all()
351
354
 
@@ -397,11 +400,12 @@ class RuntimeEnvironment:
397
400
  self.request_user_input_manager = RequestUserInputManager()
398
401
  self.request_permissions_manager = RequestPermissionsManager()
399
402
 
400
- def configure_runtime_builder(self, builder: RuntimeBuilder | None) -> None:
401
- self.subagent_manager.set_runtime_builder(builder)
402
403
 
404
+ def create_runtime_environment() -> RuntimeEnvironment:
405
+ return RuntimeEnvironment()
403
406
 
404
- _RUNTIME_ENV = RuntimeEnvironment()
407
+
408
+ _RUNTIME_ENV = create_runtime_environment()
405
409
 
406
410
 
407
411
  def get_runtime_environment() -> RuntimeEnvironment:
@@ -17,7 +17,7 @@ from ..protocol import JSONValue
17
17
  from .base_tool import BaseTool, ToolContext
18
18
  from .code_mode_manager import CodeModeManager
19
19
 
20
- EXEC_FREEFORM_GRAMMAR = """start: pragma_source | plain_source
20
+ EXEC_FREEFORM_GRAMMAR = r"""start: pragma_source | plain_source
21
21
  pragma_source: PRAGMA_LINE NEWLINE SOURCE
22
22
  plain_source: SOURCE
23
23
 
@@ -184,6 +184,9 @@ class _HeadTailBuffer:
184
184
  self.tail.clear()
185
185
  return combined
186
186
 
187
+ def has_data(self) -> bool:
188
+ return bool(self.head or self.tail)
189
+
187
190
 
188
191
  @dataclass(slots=True)
189
192
  class UnifiedExecSession:
@@ -194,6 +197,7 @@ class UnifiedExecSession:
194
197
  tty: bool
195
198
  unread_output: _HeadTailBuffer = field(default_factory=_HeadTailBuffer)
196
199
  reader_task: asyncio.Task | None = None
200
+ output_event: asyncio.Event = field(default_factory=asyncio.Event)
197
201
 
198
202
 
199
203
  class UnifiedExecManager:
@@ -294,11 +298,22 @@ class UnifiedExecManager:
294
298
  if session is None:
295
299
  return f"Error: session_id {session_id} is not running."
296
300
 
297
- start_wait = asyncio.get_running_loop().time()
301
+ loop = asyncio.get_running_loop()
302
+ start_wait = loop.time()
298
303
  try:
299
304
  await asyncio.wait_for(session.process.wait(), timeout=yield_time_ms / 1000.0)
300
305
  except asyncio.TimeoutError:
301
- pass
306
+ remaining_seconds = (yield_time_ms / 1000.0) - (loop.time() - start_wait)
307
+ if (
308
+ session.process.returncode is None
309
+ and not session.unread_output.has_data()
310
+ and remaining_seconds > 0
311
+ ):
312
+ session.output_event.clear()
313
+ try:
314
+ await asyncio.wait_for(session.output_event.wait(), timeout=remaining_seconds)
315
+ except asyncio.TimeoutError:
316
+ pass
302
317
 
303
318
  if session.reader_task is not None and session.process.returncode is not None:
304
319
  await session.reader_task
@@ -345,6 +360,8 @@ class UnifiedExecManager:
345
360
  if not chunk:
346
361
  break
347
362
  session.unread_output.push_chunk(chunk)
363
+ session.output_event.set()
364
+ session.output_event.set()
348
365
 
349
366
  def _resolve_workdir(self, workdir: str | None) -> Path:
350
367
  if not workdir:
pycodex/utils/get_env.py CHANGED
@@ -83,10 +83,15 @@ def get_package_version() -> str:
83
83
  detected = _detect_upstream_codex_version()
84
84
  if detected is not None:
85
85
  return detected
86
- try:
87
- return importlib.metadata.version("pycodex")
88
- except importlib.metadata.PackageNotFoundError:
89
- return "0.1.0"
86
+ for distribution_name in ("python-codex", "pycodex"):
87
+ try:
88
+ return importlib.metadata.version(distribution_name)
89
+ except importlib.metadata.PackageNotFoundError:
90
+ continue
91
+ local_version = _read_local_package_version()
92
+ if local_version is not None:
93
+ return local_version
94
+ return "0.1.0"
90
95
 
91
96
 
92
97
  def get_os_info() -> tuple[str, str]:
@@ -178,6 +183,20 @@ def _normalize_os_version(version: str) -> str:
178
183
  return version
179
184
 
180
185
 
186
+ def _read_local_package_version() -> str | None:
187
+ pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
188
+ if not pyproject_path.is_file():
189
+ return None
190
+ match = re.search(
191
+ r'^\s*version\s*=\s*"([^"]+)"\s*$',
192
+ pyproject_path.read_text(encoding="utf-8"),
193
+ flags=re.MULTILINE,
194
+ )
195
+ if match is None:
196
+ return None
197
+ return match.group(1).strip() or None
198
+
199
+
181
200
  def _tmux_display_message(fmt: str) -> str | None:
182
201
  try:
183
202
  output = subprocess.run(