python-codex 0.2.2__py3-none-any.whl → 0.2.4__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,561 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import tempfile
5
+ import typing
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from uuid import uuid4
9
+
10
+ from pycodex.utils import uuid7_string
11
+
12
+
13
+ SessionFactory = typing.Callable[[], object]
14
+
15
+
16
+ def default_board_path() -> Path:
17
+ return Path(tempfile.gettempdir()) / "pcws-{0}.html".format(uuid4().hex[:8])
18
+
19
+
20
+ class WorkspaceStateStore:
21
+ def __init__(self, board_path: "typing.Union[Path, None]") -> None:
22
+ self.path = (
23
+ None if board_path is None else board_path.with_suffix(".pycodex-ws.json")
24
+ )
25
+
26
+ def load_tabs(self) -> "typing.List[typing.Dict[str, str]]":
27
+ if self.path is None or not self.path.is_file():
28
+ return []
29
+
30
+ try:
31
+ payload = json.loads(
32
+ self.path.read_text(encoding="utf-8", errors="replace") or "{}"
33
+ )
34
+ except (OSError, ValueError):
35
+ return []
36
+
37
+ tabs = payload.get("tabs") if isinstance(payload, dict) else None
38
+ if not isinstance(tabs, list):
39
+ return []
40
+
41
+ result = []
42
+ for tab in tabs:
43
+ if not isinstance(tab, dict):
44
+ continue
45
+ title = str(tab.get("title") or "").strip()
46
+ rollout_path = str(tab.get("rollout_path") or "").strip()
47
+ if title or rollout_path:
48
+ result.append({"title": title, "rollout_path": rollout_path})
49
+ return result
50
+
51
+ def save_tabs(self, tabs: "typing.Iterable[typing.Dict[str, str]]") -> None:
52
+ if self.path is None:
53
+ return
54
+
55
+ state_tabs = [
56
+ {
57
+ "title": str(tab.get("title") or ""),
58
+ "rollout_path": str(tab.get("rollout_path") or ""),
59
+ }
60
+ for tab in tabs
61
+ ]
62
+ payload = json.dumps(
63
+ {"version": 1, "tabs": state_tabs},
64
+ ensure_ascii=False,
65
+ indent=2,
66
+ ) + "\n"
67
+ self.path.parent.mkdir(parents=True, exist_ok=True)
68
+ self.path.write_text(payload, encoding="utf-8")
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class WorkspaceDefinition:
73
+ workspace_id: 'str'
74
+ board_path: 'typing.Union[Path, None]'
75
+ work_dir: 'Path'
76
+
77
+
78
+ def load_workspace_definitions(
79
+ config_path: 'typing.Union[str, Path]',
80
+ ) -> 'typing.List[WorkspaceDefinition]':
81
+ path = Path(config_path).expanduser().resolve()
82
+ if not path.exists():
83
+ return []
84
+ try:
85
+ payload = json.loads(path.read_text(encoding="utf-8", errors="replace"))
86
+ except OSError as exc:
87
+ raise ValueError("unable to read workspace config: {0}".format(exc)) from exc
88
+ except ValueError as exc:
89
+ raise ValueError("workspace config must be valid JSON") from exc
90
+
91
+ if isinstance(payload, dict):
92
+ workspaces = payload.get("workspaces")
93
+ else:
94
+ workspaces = payload
95
+ if not isinstance(workspaces, list):
96
+ raise ValueError("workspace config must contain a workspace list")
97
+
98
+ result = []
99
+ seen_ids = set()
100
+ seen_boards = set()
101
+ for index, item in enumerate(workspaces, start=1):
102
+ if not isinstance(item, dict):
103
+ raise ValueError("workspace entry {0} must be an object".format(index))
104
+ workspace_id = normalize_workspace_id(str(item.get("id") or "").strip())
105
+ if not workspace_id:
106
+ workspace_id = _next_workspace_id(seen_ids)
107
+ if workspace_id in seen_ids:
108
+ raise ValueError("duplicate workspace name: {0}".format(workspace_id))
109
+ seen_ids.add(workspace_id)
110
+
111
+ work_dir_value = item.get("work_dir")
112
+ if work_dir_value is None:
113
+ work_dir_value = item.get("cwd")
114
+ if not str(work_dir_value or "").strip():
115
+ raise ValueError("workspace `{0}` is missing `work_dir`".format(workspace_id))
116
+ work_dir = _resolve_workspace_path(str(work_dir_value), path.parent)
117
+ if not work_dir.is_dir():
118
+ raise ValueError(
119
+ "workspace `{0}` work_dir does not exist: {1}".format(
120
+ workspace_id,
121
+ work_dir,
122
+ )
123
+ )
124
+
125
+ board_value = item.get("board")
126
+ if not str(board_value or "").strip():
127
+ raise ValueError("workspace `{0}` is missing `board`".format(workspace_id))
128
+ board_path = _resolve_workspace_path(str(board_value), path.parent)
129
+ if board_path in seen_boards:
130
+ raise ValueError("duplicate workspace board: {0}".format(board_path))
131
+ seen_boards.add(board_path)
132
+ if not board_path.parent.is_dir():
133
+ raise ValueError(
134
+ "workspace `{0}` board parent directory does not exist: {1}".format(
135
+ workspace_id,
136
+ board_path.parent,
137
+ )
138
+ )
139
+
140
+ result.append(
141
+ WorkspaceDefinition(
142
+ workspace_id=workspace_id,
143
+ board_path=board_path,
144
+ work_dir=work_dir,
145
+ )
146
+ )
147
+ return result
148
+
149
+
150
+ def save_workspace_definitions(
151
+ config_path: 'typing.Union[str, Path]',
152
+ definitions: 'typing.Iterable[WorkspaceDefinition]',
153
+ ) -> None:
154
+ path = Path(config_path).expanduser().resolve()
155
+ base_dir = path.parent
156
+ payload = {
157
+ "workspaces": [
158
+ _workspace_definition_to_json(definition, base_dir)
159
+ for definition in definitions
160
+ ]
161
+ }
162
+ path.parent.mkdir(parents=True, exist_ok=True)
163
+ path.write_text(
164
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
165
+ encoding="utf-8",
166
+ )
167
+
168
+
169
+ def _workspace_definition_to_json(
170
+ definition: 'WorkspaceDefinition',
171
+ base_dir: 'Path',
172
+ ) -> 'typing.Dict[str, str]':
173
+ result = {
174
+ "id": definition.workspace_id,
175
+ "work_dir": _format_path_for_workspace_config(definition.work_dir, base_dir),
176
+ }
177
+ if definition.board_path is not None:
178
+ result["board"] = _format_path_for_workspace_config(
179
+ definition.board_path,
180
+ base_dir,
181
+ )
182
+ return result
183
+
184
+
185
+ def _format_path_for_workspace_config(path: 'Path', base_dir: 'Path') -> 'str':
186
+ resolved = path.resolve()
187
+ try:
188
+ relative = os.path.relpath(str(resolved), str(base_dir.resolve()))
189
+ except ValueError:
190
+ return str(resolved)
191
+ if relative == ".":
192
+ return "."
193
+ if relative.startswith("..") or os.path.isabs(relative):
194
+ return str(resolved)
195
+ return relative
196
+
197
+
198
+ def _resolve_workspace_path(value: str, base_dir: 'Path') -> 'Path':
199
+ path = Path(str(value)).expanduser()
200
+ if not path.is_absolute():
201
+ path = base_dir / path
202
+ return path.resolve()
203
+
204
+
205
+ def _next_workspace_id(existing: 'typing.Container[str]') -> str:
206
+ index = 1
207
+ while True:
208
+ candidate = "workspace-{0}".format(index)
209
+ if candidate not in existing:
210
+ return candidate
211
+ index += 1
212
+
213
+
214
+ def normalize_workspace_id(workspace_id: str) -> str:
215
+ text = str(workspace_id or "").strip()
216
+ if not text:
217
+ return ""
218
+ allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-")
219
+ if any(char not in allowed for char in text):
220
+ raise ValueError(
221
+ "workspace id may only contain letters, numbers, underscore, and dash: {0}".format(
222
+ text
223
+ )
224
+ )
225
+ return text
226
+
227
+
228
+ class WorkspaceSessionManager:
229
+ def __init__(
230
+ self,
231
+ session_factory: "SessionFactory",
232
+ board_path: "typing.Union[Path, None]" = None,
233
+ persist_callback: "typing.Union[typing.Callable[[], None], None]" = None,
234
+ ) -> None:
235
+ self._session_factory = session_factory
236
+ self._sessions: "typing.Dict[str, object]" = {}
237
+ self._session_order: "typing.List[str]" = []
238
+ self._state_watchers: "typing.Dict[str, asyncio.Task]" = {}
239
+ self._persisted_titles: "typing.Dict[str, str]" = {}
240
+ self._lock = asyncio.Lock()
241
+ self._state_store = WorkspaceStateStore(board_path)
242
+ self._persist_callback = persist_callback
243
+
244
+ def set_persist_callback(
245
+ self,
246
+ callback: "typing.Union[typing.Callable[[], None], None]",
247
+ ) -> None:
248
+ self._persist_callback = callback
249
+
250
+ async def start(self) -> None:
251
+ state_tabs = self._state_store.load_tabs()
252
+ if not state_tabs:
253
+ await self.create_session()
254
+ return
255
+ for tab in state_tabs:
256
+ await self.create_session(
257
+ title=str(tab.get("title") or ""),
258
+ rollout_path=str(tab.get("rollout_path") or ""),
259
+ )
260
+
261
+ async def close(self) -> None:
262
+ sessions = list(self._sessions.values())
263
+ watchers = list(self._state_watchers.values())
264
+ self._sessions.clear()
265
+ self._session_order = []
266
+ self._state_watchers.clear()
267
+ self._persisted_titles.clear()
268
+ for watcher in watchers:
269
+ watcher.cancel()
270
+ if watchers:
271
+ await asyncio.gather(*watchers, return_exceptions=True)
272
+ for session in sessions:
273
+ await session.close()
274
+
275
+ async def create_session(
276
+ self,
277
+ title: str = "",
278
+ rollout_path: str = "",
279
+ ) -> str:
280
+ async with self._lock:
281
+ session_id = uuid7_string()
282
+ session = self._session_factory()
283
+ await session.start()
284
+
285
+ if rollout_path:
286
+ await session.restore_from_rollout(rollout_path, title=title)
287
+
288
+ self._sessions[session_id] = session
289
+ self._session_order.append(session_id)
290
+ self._persisted_titles[session_id] = str(
291
+ session_summary(session).get("title") or ""
292
+ )
293
+ self._state_watchers[session_id] = asyncio.create_task(
294
+ self._watch_session_title(session_id, session)
295
+ )
296
+ return session_id
297
+
298
+ async def close_session(self, session_id: str) -> None:
299
+ async with self._lock:
300
+ if len(self._session_order) <= 1:
301
+ raise ValueError("cannot close the last session")
302
+ session = self._sessions.pop(session_id, None)
303
+ if session is None:
304
+ raise KeyError(session_id)
305
+ watcher = self._state_watchers.pop(session_id, None)
306
+ self._persisted_titles.pop(session_id, None)
307
+ self._session_order = [
308
+ item for item in self._session_order if item != session_id
309
+ ]
310
+ if watcher is not None:
311
+ watcher.cancel()
312
+ await asyncio.gather(watcher, return_exceptions=True)
313
+ await session.close()
314
+ self.persist_workspace_state()
315
+
316
+ async def _watch_session_title(self, session_id: str, session) -> None:
317
+ subscriber = session.subscribe()
318
+ try:
319
+ while True:
320
+ event = await subscriber.get()
321
+ if event is None:
322
+ return
323
+ if not isinstance(event, dict) or event.get("type") != "title_changed":
324
+ continue
325
+ title = str(event.get("title") or "")
326
+ if title == self._persisted_titles.get(session_id, ""):
327
+ continue
328
+ self._persisted_titles[session_id] = title
329
+ self.persist_workspace_state()
330
+ finally:
331
+ session.unsubscribe(subscriber)
332
+
333
+ def persist_workspace_state(self) -> None:
334
+ tabs = []
335
+ for session_id in self._session_order:
336
+ session = self._sessions.get(session_id)
337
+ if session is None:
338
+ continue
339
+ summary = session_summary(session)
340
+ title = str(summary.get("title") or "").strip()
341
+ rollout_path = str(session.rollout_path() or "")
342
+ if not title and not rollout_path:
343
+ continue
344
+ tabs.append({"title": title, "rollout_path": rollout_path})
345
+ self._state_store.save_tabs(tabs)
346
+ if self._persist_callback is not None:
347
+ self._persist_callback()
348
+
349
+ def get(self, session_id: "typing.Union[str, None]" = None) -> object:
350
+ resolved_id = self.resolve_session_id(session_id)
351
+ try:
352
+ return self._sessions[resolved_id]
353
+ except KeyError:
354
+ raise KeyError(resolved_id)
355
+
356
+ def resolve_session_id(self, session_id: "typing.Union[str, None]" = None) -> str:
357
+ if session_id:
358
+ return str(session_id)
359
+ if not self._session_order:
360
+ raise KeyError("no sessions")
361
+ return self._session_order[0]
362
+
363
+ def list_sessions(self) -> "typing.List[typing.Dict[str, object]]":
364
+ result = []
365
+ for session_id in self._session_order:
366
+ session = self._sessions[session_id]
367
+ summary = session_summary(session)
368
+ result.append(
369
+ {
370
+ "id": session_id,
371
+ "title": summary.get("title") or "pycodex",
372
+ "running": bool(summary.get("running")),
373
+ "spinner": summary.get("spinner") or "",
374
+ "turn_count": summary.get("turn_count") or 0,
375
+ "last_assistant": summary.get("last_assistant") or "",
376
+ "context_remaining_percent": summary.get(
377
+ "context_remaining_percent"
378
+ ),
379
+ }
380
+ )
381
+ return result
382
+
383
+
384
+ @dataclass(frozen=True)
385
+ class WorkspaceEntry:
386
+ definition: 'WorkspaceDefinition'
387
+ manager: 'WorkspaceSessionManager'
388
+
389
+ def to_dict(self) -> 'typing.Dict[str, object]':
390
+ return {
391
+ "id": self.definition.workspace_id,
392
+ "board_path": (
393
+ "" if self.definition.board_path is None
394
+ else str(self.definition.board_path)
395
+ ),
396
+ "work_dir": str(self.definition.work_dir),
397
+ "sessions": self.manager.list_sessions(),
398
+ }
399
+
400
+
401
+ WorkspaceEntryFactory = typing.Callable[
402
+ [WorkspaceDefinition, "typing.Union[typing.Callable[[], None], None]"],
403
+ WorkspaceEntry,
404
+ ]
405
+
406
+
407
+ class WorkspaceRegistry:
408
+ def __init__(
409
+ self,
410
+ entries: 'typing.Iterable[WorkspaceEntry]',
411
+ config_path: 'typing.Union[str, Path, None]' = None,
412
+ entry_factory: "typing.Union[WorkspaceEntryFactory, None]" = None,
413
+ ) -> None:
414
+ self._entries: 'typing.Dict[str, WorkspaceEntry]' = {}
415
+ self._name_to_key: 'typing.Dict[str, str]' = {}
416
+ self._order: 'typing.List[str]' = []
417
+ self._started = False
418
+ self._config_path = (
419
+ None if config_path is None else Path(config_path).expanduser().resolve()
420
+ )
421
+ self._entry_factory = entry_factory
422
+ for entry in entries:
423
+ self._register(entry)
424
+ if not self._entries and self._entry_factory is None:
425
+ raise ValueError("at least one workspace is required")
426
+
427
+ def _register(self, entry: 'WorkspaceEntry') -> None:
428
+ workspace_id = entry.definition.workspace_id
429
+ key = self._key_for_definition(entry.definition)
430
+ if key in self._entries:
431
+ raise ValueError(
432
+ "duplicate workspace board: {0}".format(entry.definition.board_path)
433
+ )
434
+ if workspace_id in self._name_to_key:
435
+ raise ValueError("duplicate workspace name: {0}".format(workspace_id))
436
+ self._entries[key] = entry
437
+ self._name_to_key[workspace_id] = key
438
+ self._order.append(key)
439
+ entry.manager.set_persist_callback(self.persist_definitions)
440
+
441
+ def _key_for_definition(self, definition: 'WorkspaceDefinition') -> str:
442
+ if definition.board_path is None:
443
+ raise ValueError("workspace `{0}` is missing board".format(definition.workspace_id))
444
+ return str(definition.board_path.resolve())
445
+
446
+ async def start(self) -> None:
447
+ self._started = True
448
+ for key in self._order:
449
+ await self._entries[key].manager.start()
450
+
451
+ async def close(self) -> None:
452
+ for key in reversed(self._order):
453
+ await self._entries[key].manager.close()
454
+ self._started = False
455
+
456
+ def get(self, workspace_id: str) -> 'WorkspaceEntry':
457
+ workspace_id = normalize_workspace_id(workspace_id)
458
+ if not workspace_id:
459
+ raise KeyError(workspace_id)
460
+ try:
461
+ return self._entries[self._name_to_key[workspace_id]]
462
+ except KeyError:
463
+ raise KeyError(workspace_id)
464
+
465
+ async def add_workspace(
466
+ self,
467
+ name: str,
468
+ work_dir: str = "./",
469
+ board: "typing.Union[str, None]" = None,
470
+ ) -> 'WorkspaceEntry':
471
+ if self._entry_factory is None:
472
+ raise ValueError("workspace creation is unavailable")
473
+
474
+ base_dir = self._config_path.parent if self._config_path is not None else Path.cwd()
475
+ resolved_work_dir = _resolve_workspace_path(str(work_dir or "./"), base_dir)
476
+ resolved_work_dir.mkdir(parents=True, exist_ok=True)
477
+
478
+ board_path = (
479
+ _resolve_workspace_path(str(board), base_dir)
480
+ if str(board or "").strip()
481
+ else default_board_path()
482
+ )
483
+ board_path.parent.mkdir(parents=True, exist_ok=True)
484
+ board_key = str(board_path.resolve())
485
+ if board_key in self._entries:
486
+ raise ValueError("workspace board already exists: {0}".format(board_path))
487
+
488
+ workspace_id = normalize_workspace_id(name)
489
+ if not workspace_id:
490
+ workspace_id = _next_workspace_id(self._name_to_key)
491
+ if workspace_id in self._name_to_key:
492
+ raise ValueError("workspace name already exists: {0}".format(workspace_id))
493
+
494
+ definition = WorkspaceDefinition(
495
+ workspace_id=workspace_id,
496
+ board_path=board_path,
497
+ work_dir=resolved_work_dir,
498
+ )
499
+ entry = self._entry_factory(definition, self.persist_definitions)
500
+ self._register(entry)
501
+ if self._started:
502
+ await entry.manager.start()
503
+ self.persist_definitions()
504
+ return entry
505
+
506
+ async def delete_workspace(self, name: str) -> 'WorkspaceDefinition':
507
+ workspace_id = normalize_workspace_id(name)
508
+ if not workspace_id:
509
+ raise KeyError(workspace_id)
510
+ try:
511
+ key = self._name_to_key.pop(workspace_id)
512
+ entry = self._entries.pop(key)
513
+ except KeyError:
514
+ raise KeyError(workspace_id)
515
+ self._order = [item for item in self._order if item != key]
516
+ await entry.manager.close()
517
+ self.persist_definitions()
518
+ return entry.definition
519
+
520
+ def persist_definitions(self) -> None:
521
+ if self._config_path is None:
522
+ return
523
+ save_workspace_definitions(
524
+ self._config_path,
525
+ [self._entries[key].definition for key in self._order],
526
+ )
527
+
528
+ def list_workspaces(self) -> 'typing.List[typing.Dict[str, object]]':
529
+ return [
530
+ self._entries[key].to_dict()
531
+ for key in self._order
532
+ ]
533
+
534
+
535
+ def session_snapshot(session) -> "typing.Dict[str, object]":
536
+ return typing.cast("typing.Dict[str, object]", session.snapshot())
537
+
538
+
539
+ def session_summary(session) -> "typing.Dict[str, object]":
540
+ summary = getattr(session, "summary", None)
541
+ if callable(summary):
542
+ return typing.cast("typing.Dict[str, object]", summary())
543
+ snapshot = session_snapshot(session)
544
+ return {
545
+ "title": snapshot.get("title") or "",
546
+ "running": bool(snapshot.get("running")),
547
+ "spinner": snapshot.get("spinner") or "",
548
+ "turn_count": len(snapshot.get("turns") or []),
549
+ "last_assistant": _last_assistant_text(snapshot.get("turns") or []),
550
+ "context_remaining_percent": snapshot.get("context_remaining_percent"),
551
+ }
552
+
553
+
554
+ def _last_assistant_text(turns: "typing.Iterable[typing.Dict[str, object]]") -> str:
555
+ for turn in reversed(list(turns)):
556
+ if str(turn.get("kind") or "assistant") == "control":
557
+ continue
558
+ response = str(turn.get("response") or "").strip()
559
+ if response:
560
+ return response
561
+ return ""