codebind 0.1.4__tar.gz → 0.2.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.4
4
- Summary: A minimal model loop over a persistent IPython session.
3
+ Version: 0.2.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,13 +40,13 @@ 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"})
@@ -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.aask("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:
@@ -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,13 +16,13 @@ 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"})
@@ -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.aask("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:
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "codebind"
3
- version = "0.1.4"
4
- description = "A minimal model loop over a persistent IPython session."
3
+ version = "0.2.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.4"
4
- description = "A minimal model loop over a persistent IPython session."
3
+ version = "0.2.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)
@@ -12,9 +12,8 @@ from langchain_core.language_models import BaseChatModel
12
12
  from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage
13
13
  from langchain_core.runnables import Runnable
14
14
 
15
+ from .display import display_assistant
15
16
  from .execution import ExecutionReport, IPythonExecutor
16
- from .prompts import render_cell
17
- from .rendering import TerminalRenderer
18
17
 
19
18
 
20
19
  IPYTHON_TOOL = {
@@ -73,7 +72,6 @@ class Session:
73
72
 
74
73
  self.shell = resolved_shell
75
74
  self.executor = IPythonExecutor(resolved_shell)
76
- self.renderer = TerminalRenderer()
77
75
  self.instructions = instructions.strip() if instructions else None
78
76
  self.messages: list[BaseMessage] = []
79
77
  self.last_response: AIMessage | None = None
@@ -106,7 +104,7 @@ class Session:
106
104
  if not response.tool_calls:
107
105
  answer = _message_text(response)
108
106
  if answer:
109
- self.renderer.assistant(answer)
107
+ display_assistant(answer)
110
108
  return
111
109
 
112
110
  for call in response.tool_calls:
@@ -119,6 +117,38 @@ class Session:
119
117
  )
120
118
  )
121
119
 
120
+ async def aask(self, prompt: str, model: BaseChatModel) -> None:
121
+ """Run one user turn asynchronously for notebook and event-loop frontends."""
122
+ text = prompt.strip()
123
+ if not text:
124
+ raise ValueError("prompt cannot be empty")
125
+
126
+ bound_model = self._bind(model)
127
+ self.messages.append(HumanMessage(text))
128
+
129
+ while True:
130
+ response = await bound_model.ainvoke(tuple(self.messages))
131
+ if not isinstance(response, AIMessage):
132
+ raise TypeError("model must return an AIMessage")
133
+ self.messages.append(response)
134
+ self.last_response = response
135
+
136
+ if not response.tool_calls:
137
+ answer = _message_text(response)
138
+ if answer:
139
+ display_assistant(answer)
140
+ return
141
+
142
+ for call in response.tool_calls:
143
+ report = await self._aexecute_call(call)
144
+ identifier = str(call.get("id") or f"ipython-{len(self.messages)}")
145
+ self.messages.append(
146
+ ToolMessage(
147
+ json.dumps(report.as_dict(), ensure_ascii=False),
148
+ tool_call_id=identifier,
149
+ )
150
+ )
151
+
122
152
  @staticmethod
123
153
  def _bind(model: BaseChatModel) -> Runnable[Any, BaseMessage]:
124
154
  try:
@@ -133,11 +163,20 @@ class Session:
133
163
  if not isinstance(arguments, Mapping) or not isinstance(arguments.get("cell"), str):
134
164
  return _tool_error("InvalidArguments", "ipython requires a string cell argument")
135
165
 
136
- cell = arguments["cell"]
137
- render_cell(self.shell, cell)
138
166
  try:
139
- report = self.executor.execute(cell)
167
+ report = self.executor.execute(arguments["cell"])
140
168
  except Exception as error: # The failure must be returned to the model, not end the session.
141
169
  report = _tool_error(type(error).__name__, str(error))
142
- self.renderer.tool_output(report, self.shell)
143
170
  return report
171
+
172
+ async def _aexecute_call(self, call: Mapping[str, Any]) -> ExecutionReport:
173
+ if call.get("name") != "ipython":
174
+ return _tool_error("UnknownTool", f"unknown tool: {call.get('name')!r}")
175
+ arguments = call.get("args")
176
+ if not isinstance(arguments, Mapping) or not isinstance(arguments.get("cell"), str):
177
+ return _tool_error("InvalidArguments", "ipython requires a string cell argument")
178
+
179
+ try:
180
+ return await self.executor.aexecute(arguments["cell"])
181
+ except Exception as error:
182
+ 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(text))
File without changes
File without changes