chatlas 0.2.0__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.

Potentially problematic release.


This version of chatlas might be problematic. Click here for more details.

chatlas/_content.py ADDED
@@ -0,0 +1,242 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any, Literal, Optional
6
+
7
+ ImageContentTypes = Literal[
8
+ "image/png",
9
+ "image/jpeg",
10
+ "image/webp",
11
+ "image/gif",
12
+ ]
13
+ """
14
+ Allowable content types for images.
15
+ """
16
+
17
+
18
+ class Content:
19
+ """
20
+ Base class for all content types that can be appear in a [](`~chatlas.Turn`)
21
+ """
22
+
23
+ def __str__(self):
24
+ raise NotImplementedError
25
+
26
+ def _repr_markdown_(self):
27
+ raise NotImplementedError
28
+
29
+ def __repr__(self, indent: int = 0):
30
+ raise NotImplementedError
31
+
32
+
33
+ @dataclass
34
+ class ContentText(Content):
35
+ """
36
+ Text content for a [](`~chatlas.Turn`)
37
+ """
38
+
39
+ text: str
40
+
41
+ def __str__(self):
42
+ return self.text
43
+
44
+ def _repr_markdown_(self):
45
+ return self.text
46
+
47
+ def __repr__(self, indent: int = 0):
48
+ text = self.text[:50] + "..." if len(self.text) > 50 else self.text
49
+ return " " * indent + f"<ContentText text='{text}'>"
50
+
51
+
52
+ class ContentImage(Content):
53
+ """
54
+ Base class for image content.
55
+
56
+ This class is not meant to be used directly. Instead, use
57
+ [](`~chatlas.content_image_url`), [](`~chatlas.content_image_file`), or
58
+ [](`~chatlas.content_image_plot`).
59
+ """
60
+
61
+ pass
62
+
63
+
64
+ @dataclass
65
+ class ContentImageRemote(ContentImage):
66
+ """
67
+ Image content from a URL.
68
+
69
+ This is the return type for [](`~chatlas.content_image_url`).
70
+ It's not meant to be used directly.
71
+
72
+ Parameters
73
+ ----------
74
+ url
75
+ The URL of the image.
76
+ detail
77
+ A detail setting for the image. Can be `"auto"`, `"low"`, or `"high"`.
78
+ """
79
+
80
+ url: str
81
+ detail: Literal["auto", "low", "high"] = "auto"
82
+
83
+ def __str__(self):
84
+ return f"![]({self.url})"
85
+
86
+ def _repr_markdown_(self):
87
+ return self.__str__()
88
+
89
+ def __repr__(self, indent: int = 0):
90
+ return (
91
+ " " * indent
92
+ + f"<ContentImageRemote url='{self.url}' detail='{self.detail}'>"
93
+ )
94
+
95
+
96
+ @dataclass
97
+ class ContentImageInline(ContentImage):
98
+ """
99
+ Inline image content.
100
+
101
+ This is the return type for [](`~chatlas.content_image_file`) and
102
+ [](`~chatlas.content_image_plot`).
103
+ It's not meant to be used directly.
104
+
105
+ Parameters
106
+ ----------
107
+ content_type
108
+ The content type of the image.
109
+ data
110
+ The base64-encoded image data.
111
+ """
112
+
113
+ content_type: ImageContentTypes
114
+ data: Optional[str] = None
115
+
116
+ def __str__(self):
117
+ return f"![](data:{self.content_type};base64,{self.data})"
118
+
119
+ def _repr_markdown_(self):
120
+ return self.__str__()
121
+
122
+ def __repr__(self, indent: int = 0):
123
+ n_bytes = len(self.data) if self.data else 0
124
+ return (
125
+ " " * indent
126
+ + f"<ContentImageInline content_type='{self.content_type}' size={n_bytes}>"
127
+ )
128
+
129
+
130
+ @dataclass
131
+ class ContentToolRequest(Content):
132
+ """
133
+ A request to call a tool/function
134
+
135
+ This content type isn't meant to be used directly. Instead, it's
136
+ automatically generated by [](`~chatlas.Chat`) when a tool/function is
137
+ requested by the model assistant.
138
+
139
+ Parameters
140
+ ----------
141
+ id
142
+ A unique identifier for this request.
143
+ name
144
+ The name of the tool/function to call.
145
+ arguments
146
+ The arguments to pass to the tool/function.
147
+ """
148
+
149
+ id: str
150
+ name: str
151
+ arguments: object
152
+
153
+ def __str__(self):
154
+ args_str = self._arguments_str()
155
+ func_call = f"{self.name}({args_str})"
156
+ comment = f"# tool request ({self.id})"
157
+ return f"\n```python\n{comment}\n{func_call}\n```\n"
158
+
159
+ def _repr_markdown_(self):
160
+ return self.__str__()
161
+
162
+ def __repr__(self, indent: int = 0):
163
+ args_str = self._arguments_str()
164
+ return (
165
+ " " * indent
166
+ + f"<ContentToolRequest name='{self.name}' arguments='{args_str}' id='{self.id}'>"
167
+ )
168
+
169
+ def _arguments_str(self) -> str:
170
+ if isinstance(self.arguments, dict):
171
+ return ", ".join(f"{k}={v}" for k, v in self.arguments.items())
172
+ return str(self.arguments)
173
+
174
+
175
+ @dataclass
176
+ class ContentToolResult(Content):
177
+ """
178
+ The result of calling a tool/function
179
+
180
+ This content type isn't meant to be used directly. Instead, it's
181
+ automatically generated by [](`~chatlas.Chat`) when a tool/function is
182
+ called (in response to a [](`~chatlas.ContentToolRequest`)).
183
+
184
+ Parameters
185
+ ----------
186
+ id
187
+ The unique identifier of the tool request.
188
+ value
189
+ The value returned by the tool/function.
190
+ error
191
+ An error message if the tool/function call failed.
192
+ """
193
+
194
+ id: str
195
+ value: Any = None
196
+ error: Optional[str] = None
197
+
198
+ def __str__(self):
199
+ comment = f"# tool result ({self.id})"
200
+ val = self.get_final_value()
201
+ return f"""\n```python\n{comment}\n"{val}"\n```\n"""
202
+
203
+ def _repr_markdown_(self):
204
+ return self.__str__()
205
+
206
+ def __repr__(self, indent: int = 0):
207
+ res = " " * indent
208
+ res += f"<ContentToolResult value='{self.value}' id='{self.id}'"
209
+ if self.error:
210
+ res += f" error='{self.error}'"
211
+ return res + ">"
212
+
213
+ def get_final_value(self) -> str:
214
+ if self.error:
215
+ return f"Tool calling failed with error: '{self.error}'"
216
+ return str(self.value)
217
+
218
+
219
+ @dataclass
220
+ class ContentJson(Content):
221
+ """
222
+ JSON content
223
+
224
+ This content type primarily exists to signal structured data extraction
225
+ (i.e., data extracted via [](`~chatlas.Chat`)'s `.extract_data()` method)
226
+
227
+ Parameters
228
+ ----------
229
+ value
230
+ The JSON data extracted
231
+ """
232
+
233
+ value: dict[str, Any]
234
+
235
+ def __str__(self):
236
+ return json.dumps(self.value, indent=2)
237
+
238
+ def _repr_markdown_(self):
239
+ return f"""\n```json\n{self.__str__()}\n```\n"""
240
+
241
+ def __repr__(self, indent: int = 0):
242
+ return " " * indent + f"<ContentJson value={self.value}>"
@@ -0,0 +1,272 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import io
5
+ import os
6
+ import re
7
+ import warnings
8
+ from typing import Literal, Union, cast
9
+
10
+ from ._content import ContentImageInline, ContentImageRemote, ImageContentTypes
11
+ from ._utils import MISSING, MISSING_TYPE
12
+
13
+ __all__ = (
14
+ "content_image_url",
15
+ "content_image_file",
16
+ "content_image_plot",
17
+ )
18
+
19
+
20
+ def content_image_url(
21
+ url: str, detail: Literal["auto", "low", "high"] = "auto"
22
+ ) -> Union[ContentImageInline, ContentImageRemote]:
23
+ """
24
+ Encode image content from a URL for chat input.
25
+
26
+ This function is used to prepare image URLs for input to the chatbot. It can
27
+ handle both regular URLs and data URLs.
28
+
29
+ Parameters
30
+ ----------
31
+ url
32
+ The URL of the image to include in the chat input. Can be a data: URL or a
33
+ regular URL.
34
+ detail
35
+ The detail setting for this image. Can be `"auto"`, `"low"`, or `"high"`.
36
+
37
+ Returns
38
+ -------
39
+ [](`~chatlas.types.Content`)
40
+ Content suitable for a [](`~chatlas.Turn`) object.
41
+
42
+ Examples
43
+ --------
44
+ ```python
45
+ from chatlas import ChatOpenAI, content_image_url
46
+
47
+ chat = ChatOpenAI()
48
+ chat.chat(
49
+ "What do you see in this image?",
50
+ content_image_url("https://www.python.org/static/img/python-logo.png"),
51
+ )
52
+ ```
53
+
54
+ Raises
55
+ ------
56
+ ValueError
57
+ If the URL is not valid or the detail setting is invalid.
58
+ """
59
+ if detail not in ["auto", "low", "high"]:
60
+ raise ValueError("detail must be 'auto', 'low', or 'high'")
61
+
62
+ if url.startswith("data:"):
63
+ parts = url[5:].split(";", 1)
64
+ if len(parts) != 2 or not parts[1].startswith("base64,"):
65
+ raise ValueError("url is not a valid data URL.")
66
+ content_type = parts[0]
67
+ base64_data = parts[1][7:]
68
+ if content_type not in ["image/png", "image/jpeg", "image/webp", "image/gif"]:
69
+ raise ValueError(f"Unsupported image content type: {content_type}")
70
+ content_type = cast(ImageContentTypes, content_type)
71
+ return ContentImageInline(content_type, base64_data)
72
+ else:
73
+ return ContentImageRemote(url=url, detail=detail)
74
+
75
+
76
+ def content_image_file(
77
+ path: str,
78
+ content_type: Literal["auto", ImageContentTypes] = "auto",
79
+ resize: Union[Literal["low", "high", "none"], str, MISSING_TYPE] = MISSING,
80
+ ) -> ContentImageInline:
81
+ """
82
+ Encode image content from a file for chat input.
83
+
84
+ This function is used to prepare image files for input to the chatbot. It
85
+ can handle various image formats and provides options for resizing.
86
+
87
+ Parameters
88
+ ----------
89
+ path
90
+ The path to the image file to include in the chat input.
91
+ content_type
92
+ The content type of the image (e.g., `"image/png"`). If `"auto"`, the content
93
+ type is inferred from the file extension.
94
+ resize
95
+ Resizing option for the image. Can be:
96
+ - `"low"`: Resize to fit within 512x512
97
+ - `"high"`: Resize to fit within 2000x768 or 768x2000
98
+ - `"none"`: No resizing
99
+ - Custom string (e.g., `"200x200"`, `"300x200>!"`, etc.)
100
+
101
+ Returns
102
+ -------
103
+ [](`~chatlas.types.Content`)
104
+ Content suitable for a [](`~chatlas.Turn`) object.
105
+
106
+ Examples
107
+ --------
108
+ ```python
109
+ from chatlas import ChatOpenAI, content_image_file
110
+
111
+ chat = ChatOpenAI()
112
+ chat.chat(
113
+ "What do you see in this image?",
114
+ content_image_file("path/to/image.png"),
115
+ )
116
+ ```
117
+
118
+ Raises
119
+ ------
120
+ FileNotFoundError
121
+ If the specified file does not exist.
122
+ ValueError
123
+ If the file extension is unsupported or the resize option is invalid.
124
+ """
125
+
126
+ if not os.path.isfile(path):
127
+ raise FileNotFoundError(f"{path} must be an existing file.")
128
+
129
+ if content_type == "auto":
130
+ ext = os.path.splitext(path)[1].lower()
131
+ if ext == ".png":
132
+ content_type = "image/png"
133
+ elif ext in [".jpeg", ".jpg"]:
134
+ content_type = "image/jpeg"
135
+ elif ext == ".webp":
136
+ content_type = "image/webp"
137
+ elif ext == ".gif":
138
+ content_type = "image/gif"
139
+ else:
140
+ raise ValueError(f"Unsupported image file extension: {ext}")
141
+
142
+ if resize == "none":
143
+ with open(path, "rb") as image_file:
144
+ base64_data = base64.b64encode(image_file.read()).decode("utf-8")
145
+ else:
146
+ try:
147
+ from PIL import Image
148
+ except ImportError:
149
+ raise ImportError(
150
+ "Image resizing requires the `Pillow` package. "
151
+ "Install it with `pip install Pillow`."
152
+ )
153
+
154
+ img = Image.open(path)
155
+
156
+ if isinstance(resize, MISSING_TYPE):
157
+ warnings.warn(
158
+ "The `resize` parameter is missing. Defaulting to `resize='low'`. "
159
+ "As a result, the image has likely lost quality before the model received it. "
160
+ "Set `resize='low'` to suppress this warning, `resize='high'` for higher quality, or "
161
+ "`resize='none'` to disable resizing.",
162
+ category=MissingResizeWarning,
163
+ stacklevel=2,
164
+ )
165
+ resize = "low"
166
+
167
+ if resize == "low":
168
+ img.thumbnail((512, 512))
169
+ elif resize == "high":
170
+ if img.width > img.height:
171
+ img.thumbnail((2000, 768))
172
+ else:
173
+ img.thumbnail((768, 2000))
174
+ else:
175
+ match = re.match(r"(\d+)x(\d+)(>?)(!?)", resize)
176
+ if match:
177
+ width, height = map(int, match.group(1, 2))
178
+ only_shrink = ">" in match.group(3)
179
+ ignore_aspect = "!" in match.group(4)
180
+
181
+ if only_shrink and (img.width <= width and img.height <= height):
182
+ pass # No resize needed
183
+ elif ignore_aspect:
184
+ img = img.resize((width, height))
185
+ else:
186
+ img.thumbnail((width, height))
187
+ else:
188
+ raise ValueError(f"Invalid resize value: {resize}")
189
+
190
+ buffer = io.BytesIO()
191
+ img.save(buffer, format=img.format)
192
+ base64_data = base64.b64encode(buffer.getvalue()).decode("utf-8")
193
+
194
+ return ContentImageInline(content_type, base64_data)
195
+
196
+
197
+ def content_image_plot(
198
+ width: int = 768, height: int = 768, dpi: int = 72
199
+ ) -> ContentImageInline:
200
+ """
201
+ Encode the current matplotlib plot as an image for chat input.
202
+
203
+ This function captures the current matplotlib plot, resizes it to the specified
204
+ dimensions, and prepares it for chat input.
205
+
206
+ Parameters
207
+ ----------
208
+ width
209
+ The desired width of the output image in pixels.
210
+ height
211
+ The desired height of the output image in pixels.
212
+ dpi
213
+ The DPI (dots per inch) of the output image.
214
+
215
+ Returns
216
+ -------
217
+ [](`~chatlas.types.Content`)
218
+ Content suitable for a [](`~chatlas.Turn`) object.
219
+
220
+ Raises
221
+ ------
222
+ ValueError
223
+ If width or height is not a positive integer.
224
+
225
+ Examples
226
+ --------
227
+
228
+ ```python
229
+ from chatlas import ChatOpenAI, content_image_plot
230
+ import matplotlib.pyplot as plt
231
+
232
+ plt.scatter(faithful["eruptions"], faithful["waiting"])
233
+ chat = ChatOpenAI()
234
+ chat.chat(
235
+ "Describe this plot in one paragraph, as suitable for inclusion in "
236
+ "alt-text. You should briefly describe the plot type, the axes, and "
237
+ "2-5 major visual patterns.",
238
+ content_image_plot(),
239
+ )
240
+ ```
241
+ """
242
+
243
+ try:
244
+ import matplotlib.pyplot as plt
245
+ except ImportError:
246
+ raise ImportError(
247
+ "`content_image_plot()` requires the `matplotlib` package. "
248
+ "Install it with `pip install matplotlib`."
249
+ )
250
+
251
+ if not plt.get_fignums():
252
+ raise RuntimeError(
253
+ "No matplotlib figure to save. Please create one before calling `content_image_plot()`."
254
+ )
255
+
256
+ fig = plt.gcf()
257
+ size = fig.get_size_inches()
258
+
259
+ try:
260
+ fig.set_size_inches(width / dpi, height / dpi)
261
+
262
+ buf = io.BytesIO()
263
+ fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight")
264
+ buf.seek(0)
265
+ base64_data = base64.b64encode(buf.getvalue()).decode("utf-8")
266
+ return ContentImageInline("image/png", base64_data)
267
+ finally:
268
+ fig.set_size_inches(*size)
269
+
270
+
271
+ class MissingResizeWarning(RuntimeWarning):
272
+ pass
chatlas/_display.py ADDED
@@ -0,0 +1,139 @@
1
+ import logging
2
+ from abc import ABC, abstractmethod
3
+ from typing import Any
4
+ from uuid import uuid4
5
+
6
+ from rich.live import Live
7
+ from rich.logging import RichHandler
8
+
9
+ from ._logging import logger
10
+ from ._typing_extensions import TypedDict
11
+
12
+
13
+ class MarkdownDisplay(ABC):
14
+ @abstractmethod
15
+ def update(self, content: str):
16
+ pass
17
+
18
+ @abstractmethod
19
+ def __enter__(self) -> "MarkdownDisplay":
20
+ pass
21
+
22
+ @abstractmethod
23
+ def __exit__(self, exc_type, exc_value, traceback):
24
+ pass
25
+
26
+
27
+ class MockMarkdownDisplay(MarkdownDisplay):
28
+ def update(self, content: str):
29
+ pass
30
+
31
+ def __enter__(self):
32
+ return self
33
+
34
+ def __exit__(self, exc_type, exc_value, traceback):
35
+ pass
36
+
37
+
38
+ class LiveMarkdownDisplay(MarkdownDisplay):
39
+ """
40
+ Stream chunks of markdown into a rich-based live updating console.
41
+ """
42
+
43
+ def __init__(self, echo_options: "EchoOptions"):
44
+ from rich.console import Console
45
+
46
+ self.content: str = ""
47
+ self.live = Live(
48
+ auto_refresh=False,
49
+ vertical_overflow="visible",
50
+ console=Console(
51
+ **echo_options["rich_console"],
52
+ ),
53
+ )
54
+ self._markdown_options = echo_options["rich_markdown"]
55
+
56
+ def update(self, content: str):
57
+ from rich.markdown import Markdown
58
+
59
+ self.content += content
60
+ self.live.update(
61
+ Markdown(
62
+ self.content,
63
+ **self._markdown_options,
64
+ ),
65
+ refresh=True,
66
+ )
67
+
68
+ def __enter__(self):
69
+ self.live.__enter__()
70
+ # Live() isn't smart enough to know to automatically display logs when
71
+ # when they get handled while it Live() is active.
72
+ # However, if the logging handler is a RichHandler, it can be told
73
+ # about the live console so it can add logs to the top of the Live console.
74
+ handlers = [*logging.getLogger().handlers, *logger.handlers]
75
+ for h in handlers:
76
+ if isinstance(h, RichHandler):
77
+ h.console = self.live.console
78
+
79
+ return self
80
+
81
+ def __exit__(self, exc_type, exc_value, traceback):
82
+ self.content = ""
83
+ return self.live.__exit__(exc_type, exc_value, traceback)
84
+
85
+
86
+ class IPyMarkdownDisplay(MarkdownDisplay):
87
+ """
88
+ Stream chunks of markdown into an IPython notebook.
89
+ """
90
+
91
+ def __init__(self, echo_options: "EchoOptions"):
92
+ self.content: str = ""
93
+ self._css_styles = echo_options["css_styles"]
94
+
95
+ def update(self, content: str):
96
+ from IPython.display import Markdown, update_display
97
+
98
+ self.content += content
99
+ update_display(
100
+ Markdown(self.content),
101
+ display_id=self._ipy_display_id,
102
+ )
103
+
104
+ def _init_display(self) -> str:
105
+ try:
106
+ from IPython.display import HTML, Markdown, display
107
+ except ImportError:
108
+ raise ImportError(
109
+ "The IPython package is required for displaying content in a Jupyter notebook. "
110
+ "Install it with `pip install ipython`."
111
+ )
112
+
113
+ if self._css_styles:
114
+ id_ = uuid4().hex
115
+ css = "".join(f"{k}: {v}; " for k, v in self._css_styles.items())
116
+ display(HTML(f"<style>#{id_} + .chatlas-markdown {{ {css} }}</style>"))
117
+ display(HTML(f"<div id='{id_}' class='chatlas-markdown'>"))
118
+ else:
119
+ # Unfortunately, there doesn't seem to be a proper way to wrap
120
+ # Markdown() in a div?
121
+ display(HTML("<div class='chatlas-markdown'>"))
122
+
123
+ handle = display(Markdown(""), display_id=True)
124
+ if handle is None:
125
+ raise ValueError("Failed to create display handle")
126
+ return handle.display_id
127
+
128
+ def __enter__(self):
129
+ self._ipy_display_id = self._init_display()
130
+ return self
131
+
132
+ def __exit__(self, exc_type, exc_value, traceback):
133
+ self._ipy_display_id = None
134
+
135
+
136
+ class EchoOptions(TypedDict):
137
+ rich_markdown: dict[str, Any]
138
+ rich_console: dict[str, Any]
139
+ css_styles: dict[str, str]