codebind 0.1.5__tar.gz → 0.3.0__tar.gz

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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebind
3
- Version: 0.1.5
4
- Summary: A minimal model loop over a persistent IPython session.
3
+ Version: 0.3.0
4
+ Summary: A frontend-neutral model loop for persistent IPython sessions.
5
5
  Keywords: ai,ipython,llm,repl,agents
6
6
  Author: Giovanni Gravili
7
7
  Author-email: Giovanni Gravili <ghovax@users.noreply.github.com>
@@ -17,7 +17,6 @@ Classifier: Typing :: Typed
17
17
  Requires-Dist: ipython>=9.0
18
18
  Requires-Dist: langchain-core>=1.0
19
19
  Requires-Dist: models-provider>=0.1.0
20
- Requires-Dist: rich>=14.0
21
20
  Requires-Python: >=3.13
22
21
  Project-URL: Repository, https://github.com/ghovax/codebind
23
22
  Project-URL: Issues, https://github.com/ghovax/codebind/issues
@@ -25,7 +24,7 @@ Description-Content-Type: text/markdown
25
24
 
26
25
  # Codebind
27
26
 
28
- Codebind runs a model with one tool: an IPython cell executed in the session shared with the user. Conversation history belongs to a `Session`; the model is selected independently for every call.
27
+ Codebind is an IPython extension that runs a model with one tool: an IPython cell executed in the active session. IPython owns execution, namespace, history, magics, tracebacks, and rich display; Codebind owns only conversation and model orchestration.
29
28
 
30
29
  ## Installation
31
30
 
@@ -41,18 +40,18 @@ Or install it with any Python package installer:
41
40
  pip install codebind
42
41
  ```
43
42
 
44
- After installation, start the preconfigured shell from any directory:
43
+ After installation, start ordinary IPython with the Codebind extension from any directory:
45
44
 
46
45
  ```console
47
46
  codebind
48
47
  ```
49
48
 
50
- It opens IPython with `chat` and `Models` already available and renders a short Markdown usage guide in the terminal. It does not modify the user's global IPython profile.
49
+ It opens standard IPython with `chat` and `Models` in the user namespace. All normal IPython command-line options remain available.
51
50
 
52
51
  ```python
53
52
  models = Models({"openai": "OPENAI_API_KEY"})
54
53
 
55
- chat.ask(
54
+ chat.send(
56
55
  "Inspect this project and tell me what to implement first.",
57
56
  models.chat("openai/gpt-5", reasoning_effort="medium"),
58
57
  )
@@ -60,6 +59,29 @@ chat.ask(
60
59
 
61
60
  Codebind does not load files or construct a project prompt automatically. The user states what should be loaded as context. Pass `instructions=` to `Session` only when an application needs its own system instructions.
62
61
 
62
+ ## Jupyter
63
+
64
+ Install Codebind in the environment used by a Jupyter kernel, then start the Jupyter frontend normally:
65
+
66
+ ```console
67
+ pip install codebind jupyterlab
68
+ jupyter lab
69
+ ```
70
+
71
+ Load Codebind in a notebook:
72
+
73
+ ```python
74
+ %load_ext codebind
75
+
76
+ models = Models({"openai": "OPENAI_API_KEY"})
77
+ model = models.chat("openai/gpt-5")
78
+ await chat.asend("Inspect the current notebook state.", model)
79
+ ```
80
+
81
+ Codebind publishes cells, assistant Markdown, stdout, tracebacks, and rich results through IPython's MIME display system. The active frontend decides how to render HTML, Markdown, images, SVG, audio, tables, and plain text.
82
+
83
+ Model-authored cells are recorded in native IPython history and displayed through the active frontend. A kernel cannot insert a genuine input cell into every possible frontend without a frontend-specific extension, so Codebind does not attempt to control notebook or editor UI.
84
+
63
85
  ## ChatGPT account login
64
86
 
65
87
  Models Provider can start its OpenAI browser sign-in flow directly from IPython:
@@ -72,7 +94,7 @@ authorization = await models.sign_in("openai")
72
94
  webbrowser.open(authorization.url)
73
95
  await authorization.complete()
74
96
 
75
- chat.ask(
97
+ chat.send(
76
98
  "Inspect this project.",
77
99
  models.chat("openai/gpt-5", authorization=authorization),
78
100
  )
@@ -1,6 +1,6 @@
1
1
  # Codebind
2
2
 
3
- Codebind runs a model with one tool: an IPython cell executed in the session shared with the user. Conversation history belongs to a `Session`; the model is selected independently for every call.
3
+ Codebind is an IPython extension that runs a model with one tool: an IPython cell executed in the active session. IPython owns execution, namespace, history, magics, tracebacks, and rich display; Codebind owns only conversation and model orchestration.
4
4
 
5
5
  ## Installation
6
6
 
@@ -16,18 +16,18 @@ Or install it with any Python package installer:
16
16
  pip install codebind
17
17
  ```
18
18
 
19
- After installation, start the preconfigured shell from any directory:
19
+ After installation, start ordinary IPython with the Codebind extension from any directory:
20
20
 
21
21
  ```console
22
22
  codebind
23
23
  ```
24
24
 
25
- It opens IPython with `chat` and `Models` already available and renders a short Markdown usage guide in the terminal. It does not modify the user's global IPython profile.
25
+ It opens standard IPython with `chat` and `Models` in the user namespace. All normal IPython command-line options remain available.
26
26
 
27
27
  ```python
28
28
  models = Models({"openai": "OPENAI_API_KEY"})
29
29
 
30
- chat.ask(
30
+ chat.send(
31
31
  "Inspect this project and tell me what to implement first.",
32
32
  models.chat("openai/gpt-5", reasoning_effort="medium"),
33
33
  )
@@ -35,6 +35,29 @@ chat.ask(
35
35
 
36
36
  Codebind does not load files or construct a project prompt automatically. The user states what should be loaded as context. Pass `instructions=` to `Session` only when an application needs its own system instructions.
37
37
 
38
+ ## Jupyter
39
+
40
+ Install Codebind in the environment used by a Jupyter kernel, then start the Jupyter frontend normally:
41
+
42
+ ```console
43
+ pip install codebind jupyterlab
44
+ jupyter lab
45
+ ```
46
+
47
+ Load Codebind in a notebook:
48
+
49
+ ```python
50
+ %load_ext codebind
51
+
52
+ models = Models({"openai": "OPENAI_API_KEY"})
53
+ model = models.chat("openai/gpt-5")
54
+ await chat.asend("Inspect the current notebook state.", model)
55
+ ```
56
+
57
+ Codebind publishes cells, assistant Markdown, stdout, tracebacks, and rich results through IPython's MIME display system. The active frontend decides how to render HTML, Markdown, images, SVG, audio, tables, and plain text.
58
+
59
+ Model-authored cells are recorded in native IPython history and displayed through the active frontend. A kernel cannot insert a genuine input cell into every possible frontend without a frontend-specific extension, so Codebind does not attempt to control notebook or editor UI.
60
+
38
61
  ## ChatGPT account login
39
62
 
40
63
  Models Provider can start its OpenAI browser sign-in flow directly from IPython:
@@ -47,7 +70,7 @@ authorization = await models.sign_in("openai")
47
70
  webbrowser.open(authorization.url)
48
71
  await authorization.complete()
49
72
 
50
- chat.ask(
73
+ chat.send(
51
74
  "Inspect this project.",
52
75
  models.chat("openai/gpt-5", authorization=authorization),
53
76
  )
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "codebind"
3
- version = "0.1.5"
4
- description = "A minimal model loop over a persistent IPython session."
3
+ version = "0.3.0"
4
+ description = "A frontend-neutral model loop for persistent IPython sessions."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
7
7
  license = "MIT"
@@ -26,7 +26,6 @@ dependencies = [
26
26
  "ipython>=9.0",
27
27
  "langchain-core>=1.0",
28
28
  "models-provider>=0.1.0",
29
- "rich>=14.0",
30
29
  ]
31
30
 
32
31
  [[project.authors]]
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "codebind"
3
- version = "0.1.5"
4
- description = "A minimal model loop over a persistent IPython session."
3
+ version = "0.3.0"
4
+ description = "A frontend-neutral model loop for persistent IPython sessions."
5
5
  readme = "README.md"
6
6
  authors = [
7
7
  { name = "Giovanni Gravili", email = "ghovax@users.noreply.github.com" }
@@ -23,7 +23,6 @@ dependencies = [
23
23
  "ipython>=9.0",
24
24
  "langchain-core>=1.0",
25
25
  "models-provider>=0.1.0",
26
- "rich>=14.0",
27
26
  ]
28
27
 
29
28
  [project.urls]
@@ -3,6 +3,7 @@
3
3
  from importlib.metadata import version
4
4
 
5
5
  from .execution import ExecutionReport, IPythonExecutor
6
+ from .extension import load_ipython_extension, unload_ipython_extension
6
7
  from .session import Session
7
8
 
8
9
 
@@ -13,4 +14,6 @@ __all__ = [
13
14
  "IPythonExecutor",
14
15
  "Session",
15
16
  "__version__",
17
+ "load_ipython_extension",
18
+ "unload_ipython_extension",
16
19
  ]
@@ -0,0 +1,17 @@
1
+ """Standard IPython launcher with the Codebind extension loaded."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from IPython import start_ipython
8
+
9
+ from . import __version__
10
+
11
+
12
+ def main() -> None:
13
+ """Start ordinary IPython with Codebind loaded as an extension."""
14
+ if sys.argv[1:] == ["--version"]:
15
+ print(f"codebind {__version__}")
16
+ return
17
+ start_ipython(argv=["--ext=codebind", *sys.argv[1:]])
@@ -0,0 +1,15 @@
1
+ """Frontend-neutral output through IPython's MIME display protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from IPython.display import Code, display
6
+
7
+
8
+ def display_cell(cell: str) -> None:
9
+ """Display model-authored cell source in the active frontend."""
10
+ display(Code(cell, language="python"))
11
+
12
+
13
+ def display_assistant(text: str) -> None:
14
+ """Publish assistant text as plain text and Markdown MIME representations."""
15
+ display({"text/plain": text, "text/markdown": text}, raw=True)
@@ -8,6 +8,9 @@ from typing import Any
8
8
  from IPython.core.interactiveshell import InteractiveShell
9
9
  from IPython.utils.capture import capture_output
10
10
 
11
+ from .display import display_cell
12
+
13
+
11
14
  @dataclass(frozen=True, slots=True)
12
15
  class ExecutionReport:
13
16
  """The model-facing result of one IPython cell."""
@@ -31,18 +34,37 @@ class IPythonExecutor:
31
34
  self.shell = shell
32
35
 
33
36
  def execute(self, cell: str) -> ExecutionReport:
34
- """Execute a cell, replay its visible output, and capture a structured result."""
37
+ """Execute a cell through IPython and replay its native rich output."""
35
38
  if not isinstance(cell, str) or not cell.strip():
36
39
  raise ValueError("cell must be a non-empty string")
37
40
 
38
- displayhook = self.shell.displayhook
39
- original_output_prompt = displayhook.write_output_prompt
40
- displayhook.write_output_prompt = lambda: None
41
- try:
42
- with capture_output() as captured:
43
- result = self.shell.run_cell(cell, store_history=True)
44
- finally:
45
- displayhook.write_output_prompt = original_output_prompt
41
+ display_cell(cell)
42
+ with capture_output() as captured:
43
+ result = self.shell.run_cell(cell, store_history=True)
44
+ captured.show()
45
+
46
+ return self._report(result, captured)
47
+
48
+ async def aexecute(self, cell: str) -> ExecutionReport:
49
+ """Execute an async-capable cell and replay its native rich output."""
50
+ if not isinstance(cell, str) or not cell.strip():
51
+ raise ValueError("cell must be a non-empty string")
52
+
53
+ display_cell(cell)
54
+ transformed = self.shell.transform_cell(cell)
55
+ with capture_output() as captured:
56
+ result = await self.shell.run_cell_async(
57
+ cell,
58
+ store_history=True,
59
+ transformed_cell=transformed,
60
+ )
61
+ captured.show()
62
+
63
+ return self._report(result, captured)
64
+
65
+ @staticmethod
66
+ def _report(result: Any, captured: Any) -> ExecutionReport:
67
+ """Build the model-facing text projection of an IPython execution."""
46
68
 
47
69
  displays: list[str] = []
48
70
  for output in captured.outputs:
@@ -0,0 +1,34 @@
1
+ """IPython extension entry points."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from IPython.core.interactiveshell import InteractiveShell
8
+ from models_provider import Models
9
+
10
+ from .session import Session
11
+
12
+
13
+ _NAMESPACE_ATTRIBUTE = "_codebind_extension_namespace"
14
+
15
+
16
+ def load_ipython_extension(ipython: InteractiveShell) -> None:
17
+ """Load Codebind into the active IPython user namespace."""
18
+ previous = getattr(ipython, _NAMESPACE_ATTRIBUTE, None)
19
+ if isinstance(previous, dict):
20
+ ipython.drop_by_id(previous)
21
+ namespace: dict[str, Any] = {
22
+ "chat": Session(shell=ipython),
23
+ "Models": Models,
24
+ }
25
+ ipython.push(namespace)
26
+ setattr(ipython, _NAMESPACE_ATTRIBUTE, namespace)
27
+
28
+
29
+ def unload_ipython_extension(ipython: InteractiveShell) -> None:
30
+ """Remove names added by Codebind without touching user replacements."""
31
+ namespace = getattr(ipython, _NAMESPACE_ATTRIBUTE, None)
32
+ if isinstance(namespace, dict):
33
+ ipython.drop_by_id(namespace)
34
+ delattr(ipython, _NAMESPACE_ATTRIBUTE)
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import json
6
7
  from collections.abc import Mapping, Sequence
7
8
  from typing import Any
@@ -12,9 +13,8 @@ from langchain_core.language_models import BaseChatModel
12
13
  from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage
13
14
  from langchain_core.runnables import Runnable
14
15
 
16
+ from .display import display_assistant
15
17
  from .execution import ExecutionReport, IPythonExecutor
16
- from .prompts import render_cell
17
- from .rendering import TerminalRenderer
18
18
 
19
19
 
20
20
  IPYTHON_TOOL = {
@@ -73,7 +73,6 @@ class Session:
73
73
 
74
74
  self.shell = resolved_shell
75
75
  self.executor = IPythonExecutor(resolved_shell)
76
- self.renderer = TerminalRenderer()
77
76
  self.instructions = instructions.strip() if instructions else None
78
77
  self.messages: list[BaseMessage] = []
79
78
  self.last_response: AIMessage | None = None
@@ -87,8 +86,15 @@ class Session:
87
86
  if self.instructions:
88
87
  self.messages.append(SystemMessage(self.instructions))
89
88
 
90
- def ask(self, prompt: str, model: BaseChatModel) -> None:
91
- """Run one user turn with the explicitly supplied model."""
89
+ def send(self, prompt: str, model: BaseChatModel) -> None:
90
+ """Send one user message synchronously."""
91
+ try:
92
+ asyncio.get_running_loop()
93
+ except RuntimeError:
94
+ pass
95
+ else:
96
+ raise RuntimeError("send() cannot run inside an active event loop; use await asend().")
97
+
92
98
  text = prompt.strip()
93
99
  if not text:
94
100
  raise ValueError("prompt cannot be empty")
@@ -106,7 +112,7 @@ class Session:
106
112
  if not response.tool_calls:
107
113
  answer = _message_text(response)
108
114
  if answer:
109
- self.renderer.assistant(answer)
115
+ display_assistant(answer)
110
116
  return
111
117
 
112
118
  for call in response.tool_calls:
@@ -119,6 +125,38 @@ class Session:
119
125
  )
120
126
  )
121
127
 
128
+ async def asend(self, prompt: str, model: BaseChatModel) -> None:
129
+ """Send one user message asynchronously."""
130
+ text = prompt.strip()
131
+ if not text:
132
+ raise ValueError("prompt cannot be empty")
133
+
134
+ bound_model = self._bind(model)
135
+ self.messages.append(HumanMessage(text))
136
+
137
+ while True:
138
+ response = await bound_model.ainvoke(tuple(self.messages))
139
+ if not isinstance(response, AIMessage):
140
+ raise TypeError("model must return an AIMessage")
141
+ self.messages.append(response)
142
+ self.last_response = response
143
+
144
+ if not response.tool_calls:
145
+ answer = _message_text(response)
146
+ if answer:
147
+ display_assistant(answer)
148
+ return
149
+
150
+ for call in response.tool_calls:
151
+ report = await self._aexecute_call(call)
152
+ identifier = str(call.get("id") or f"ipython-{len(self.messages)}")
153
+ self.messages.append(
154
+ ToolMessage(
155
+ json.dumps(report.as_dict(), ensure_ascii=False),
156
+ tool_call_id=identifier,
157
+ )
158
+ )
159
+
122
160
  @staticmethod
123
161
  def _bind(model: BaseChatModel) -> Runnable[Any, BaseMessage]:
124
162
  try:
@@ -133,11 +171,20 @@ class Session:
133
171
  if not isinstance(arguments, Mapping) or not isinstance(arguments.get("cell"), str):
134
172
  return _tool_error("InvalidArguments", "ipython requires a string cell argument")
135
173
 
136
- cell = arguments["cell"]
137
- render_cell(self.shell, cell)
138
174
  try:
139
- report = self.executor.execute(cell)
175
+ report = self.executor.execute(arguments["cell"])
140
176
  except Exception as error: # The failure must be returned to the model, not end the session.
141
177
  report = _tool_error(type(error).__name__, str(error))
142
- self.renderer.tool_output(report, self.shell)
143
178
  return report
179
+
180
+ async def _aexecute_call(self, call: Mapping[str, Any]) -> ExecutionReport:
181
+ if call.get("name") != "ipython":
182
+ return _tool_error("UnknownTool", f"unknown tool: {call.get('name')!r}")
183
+ arguments = call.get("args")
184
+ if not isinstance(arguments, Mapping) or not isinstance(arguments.get("cell"), str):
185
+ return _tool_error("InvalidArguments", "ipython requires a string cell argument")
186
+
187
+ try:
188
+ return await self.executor.aexecute(arguments["cell"])
189
+ except Exception as error:
190
+ return _tool_error(type(error).__name__, str(error))
@@ -1,45 +0,0 @@
1
- """Project-local IPython launcher with Codebind conveniences preloaded."""
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
- from typing import Any
7
-
8
- from IPython.core.interactiveshell import InteractiveShell
9
- from IPython.terminal.ipapp import TerminalIPythonApp
10
- from models_provider import Models
11
- from rich.console import Console
12
- from rich.markdown import Markdown
13
-
14
- from . import __version__
15
- from .session import Session
16
-
17
-
18
- BANNER = """\
19
- - `models = Models({...})`
20
- - `chat.ask("...", models.chat("provider/model"))`
21
- """
22
-
23
-
24
- def namespace(shell: InteractiveShell) -> dict[str, Any]:
25
- """Build the small namespace exposed by the Codebind shell."""
26
- return {
27
- "chat": Session(shell=shell),
28
- "Models": Models,
29
- }
30
-
31
-
32
- def main() -> None:
33
- """Start IPython in the current directory with Codebind preloaded."""
34
- parser = argparse.ArgumentParser(description="Start a model-enabled IPython session.")
35
- parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
36
- parser.parse_args()
37
-
38
- Console().print(Markdown(BANNER))
39
- application = TerminalIPythonApp.instance()
40
- application.display_banner = False
41
- application.initialize([])
42
- shell = application.shell
43
- shell.enable_tip = False
44
- shell.push(namespace(shell))
45
- application.start()
@@ -1,36 +0,0 @@
1
- """Native IPython prompt rendering for model-authored cells."""
2
-
3
- from __future__ import annotations
4
-
5
- import sys
6
-
7
- from IPython.terminal.ptutils import IPythonPTLexer
8
- from prompt_toolkit.document import Document
9
- from prompt_toolkit.formatted_text import FormattedText, PygmentsTokens
10
- from prompt_toolkit.shortcuts import print_formatted_text
11
-
12
-
13
- def render_cell(shell: object, cell: str) -> None:
14
- """Render a cell with IPython's lexer, prompt tokens, and terminal style."""
15
- lexer = IPythonPTLexer().lex_document(Document(cell))
16
- pt_app = getattr(shell, "pt_app", None)
17
- style = pt_app.app.style if pt_app is not None else None
18
- sys.stdout.write(getattr(shell, "separate_in", "\n"))
19
- prompts = shell.prompts
20
-
21
- for index, _line in enumerate(cell.split("\n")):
22
- prompt_tokens = (
23
- prompts.in_prompt_tokens()
24
- if index == 0
25
- else prompts.continuation_prompt_tokens(lineno=index - 1)
26
- )
27
- print_formatted_text(PygmentsTokens(prompt_tokens), style=style, end="")
28
- print_formatted_text(FormattedText(lexer(index)), style=style)
29
-
30
-
31
- def render_output_prompt(shell: object) -> None:
32
- """Render an output prompt with IPython's terminal style."""
33
- pt_app = getattr(shell, "pt_app", None)
34
- style = pt_app.app.style if pt_app is not None else None
35
- sys.stdout.write(getattr(shell, "separate_out", "") or "\n")
36
- print_formatted_text(PygmentsTokens(shell.prompts.out_prompt_tokens()), style=style, end="")
@@ -1,43 +0,0 @@
1
- """Terminal rendering for model responses and IPython tool output."""
2
-
3
- from __future__ import annotations
4
-
5
- from rich.console import Console
6
- from rich.markdown import Markdown
7
- from rich.text import Text
8
-
9
- from .execution import ExecutionReport
10
- from .prompts import render_output_prompt
11
-
12
-
13
- def _visible_output(report: ExecutionReport) -> str:
14
- parts = [part for part in (report.stdout, report.stderr) if part]
15
- if report.displays:
16
- parts.extend(report.displays)
17
- elif not parts and report.result is not None:
18
- parts.append(report.result)
19
- if not parts and report.error is not None:
20
- parts.append(f"{report.error['type']}: {report.error['message']}")
21
- return "\n".join(part.rstrip("\n") for part in parts if part).strip("\n")
22
-
23
-
24
- class TerminalRenderer:
25
- """Render tool output and Markdown answers to the active terminal."""
26
-
27
- def __init__(self) -> None:
28
- self.console = Console()
29
-
30
- def tool_output(self, report: ExecutionReport, shell: object) -> None:
31
- """Show the complete tool output."""
32
- visible = _visible_output(report)
33
- if not visible:
34
- return
35
- output = Text.from_ansi(visible)
36
- if report.ok:
37
- render_output_prompt(shell)
38
- self.console.print(output)
39
-
40
- def assistant(self, text: str) -> None:
41
- """Render the final model response as terminal Markdown."""
42
- self.console.print()
43
- self.console.print(Markdown(f"---\n\n{text}\n\n---"))
File without changes
File without changes