chatlas 0.4.0__py3-none-any.whl → 0.6.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.

@@ -0,0 +1,94 @@
1
+ import os
2
+ import tempfile
3
+ from typing import Union
4
+
5
+ import requests
6
+
7
+ from ._content import ContentPDF
8
+
9
+ __all__ = (
10
+ "content_pdf_url",
11
+ "content_pdf_file",
12
+ )
13
+
14
+
15
+ def content_pdf_file(path: Union[str, os.PathLike]) -> ContentPDF:
16
+ """
17
+ Prepare a local PDF for input to a chat.
18
+
19
+ Not all providers support PDF input, so check the documentation for the
20
+ provider you are using.
21
+
22
+ Parameters
23
+ ----------
24
+ path
25
+ A path to a local PDF file.
26
+
27
+ Returns
28
+ -------
29
+ [](`~chatlas.types.Content`)
30
+ Content suitable for a [](`~chatlas.Turn`) object.
31
+ """
32
+
33
+ if not os.path.isfile(path):
34
+ raise FileNotFoundError(f"PDF file not found: {path}")
35
+
36
+ with open(path, "rb") as f:
37
+ data = f.read()
38
+
39
+ return ContentPDF(data=data)
40
+
41
+
42
+ def content_pdf_url(url: str) -> ContentPDF:
43
+ """
44
+ Use a remote PDF for input to a chat.
45
+
46
+ Not all providers support PDF input, so check the documentation for the
47
+ provider you are using.
48
+
49
+ Parameters
50
+ ----------
51
+ url
52
+ A URL to a remote PDF file.
53
+
54
+ Returns
55
+ -------
56
+ [](`~chatlas.types.Content`)
57
+ Content suitable for a [](`~chatlas.Turn`) object.
58
+ """
59
+
60
+ if url.startswith("data:"):
61
+ content_type, base64_data = parse_data_url(url)
62
+ if content_type != "application/pdf":
63
+ raise ValueError(f"Unsupported PDF content type: {content_type}")
64
+ return ContentPDF(data=base64_data.encode("utf-8"))
65
+ # TODO: need separate ContentPDFRemote type so we can use file upload
66
+ # apis where they exist. Might need some kind of mutable state so can
67
+ # record point to uploaded file.
68
+ data = download_pdf_bytes(url)
69
+ return ContentPDF(data=data)
70
+
71
+
72
+ def parse_data_url(url: str) -> tuple[str, str]:
73
+ parts = url[5:].split(";", 1)
74
+ if len(parts) != 2 or not parts[1].startswith("base64,"):
75
+ raise ValueError("url is not a valid data URL.")
76
+ return (parts[0], parts[1][7:])
77
+
78
+
79
+ def download_pdf_bytes(url):
80
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=True) as temp_file:
81
+ try:
82
+ response = requests.get(url, stream=True)
83
+ response.raise_for_status()
84
+
85
+ for chunk in response.iter_content(chunk_size=8192):
86
+ temp_file.write(chunk)
87
+
88
+ temp_file.flush()
89
+ temp_file.seek(0)
90
+
91
+ return temp_file.read()
92
+
93
+ except Exception as e:
94
+ raise e
chatlas/_display.py CHANGED
@@ -6,6 +6,7 @@ from uuid import uuid4
6
6
  from rich.live import Live
7
7
  from rich.logging import RichHandler
8
8
 
9
+ from ._live_render import LiveRender
9
10
  from ._logging import logger
10
11
  from ._typing_extensions import TypedDict
11
12
 
@@ -44,13 +45,22 @@ class LiveMarkdownDisplay(MarkdownDisplay):
44
45
  from rich.console import Console
45
46
 
46
47
  self.content: str = ""
47
- self.live = Live(
48
+ live = Live(
48
49
  auto_refresh=False,
49
- vertical_overflow="visible",
50
50
  console=Console(
51
51
  **echo_options["rich_console"],
52
52
  ),
53
53
  )
54
+
55
+ # Monkeypatch LiveRender() with our own version that add "crop_above"
56
+ # https://github.com/Textualize/rich/blob/43d3b047/rich/live.py#L87-L89
57
+ live.vertical_overflow = "crop_above"
58
+ live._live_render = LiveRender( # pyright: ignore[reportAttributeAccessIssue]
59
+ live.get_renderable(), vertical_overflow="crop_above"
60
+ )
61
+
62
+ self.live = live
63
+
54
64
  self._markdown_options = echo_options["rich_markdown"]
55
65
 
56
66
  def update(self, content: str):
chatlas/_github.py CHANGED
@@ -43,7 +43,7 @@ def ChatGithub(
43
43
  ::: {.callout-note}
44
44
  ## Python requirements
45
45
 
46
- `ChatGithub` requires the `openai` package (e.g., `pip install openai`).
46
+ `ChatGithub` requires the `openai` package: `pip install "chatlas[github]"`.
47
47
  :::
48
48
 
49
49
 
chatlas/_google.py CHANGED
@@ -12,6 +12,7 @@ from ._content import (
12
12
  ContentImageInline,
13
13
  ContentImageRemote,
14
14
  ContentJson,
15
+ ContentPDF,
15
16
  ContentText,
16
17
  ContentToolRequest,
17
18
  ContentToolResult,
@@ -61,8 +62,7 @@ def ChatGoogle(
61
62
  ::: {.callout-note}
62
63
  ## Python requirements
63
64
 
64
- `ChatGoogle` requires the `google-genai` package
65
- (e.g., `pip install google-genai`).
65
+ `ChatGoogle` requires the `google-genai` package: `pip install "chatlas[google]"`.
66
66
  :::
67
67
 
68
68
  Examples
@@ -400,10 +400,19 @@ class GoogleProvider(
400
400
  return Part.from_text(text=content.text)
401
401
  elif isinstance(content, ContentJson):
402
402
  return Part.from_text(text="<structured data/>")
403
+ elif isinstance(content, ContentPDF):
404
+ from google.genai.types import Blob
405
+
406
+ return Part(
407
+ inline_data=Blob(
408
+ data=content.data,
409
+ mime_type="application/pdf",
410
+ )
411
+ )
403
412
  elif isinstance(content, ContentImageInline) and content.data:
404
413
  return Part.from_bytes(
405
414
  data=base64.b64decode(content.data),
406
- mime_type=content.content_type,
415
+ mime_type=content.image_content_type,
407
416
  )
408
417
  elif isinstance(content, ContentImageRemote):
409
418
  raise NotImplementedError(
@@ -413,7 +422,7 @@ class GoogleProvider(
413
422
  elif isinstance(content, ContentToolRequest):
414
423
  return Part(
415
424
  function_call=FunctionCall(
416
- id=content.id,
425
+ id=content.id if content.name != content.id else None,
417
426
  name=content.name,
418
427
  # Goes in a dict, so should come out as a dict
419
428
  args=cast(dict[str, Any], content.arguments),
@@ -428,7 +437,7 @@ class GoogleProvider(
428
437
  # TODO: seems function response parts might need role='tool'???
429
438
  # https://github.com/googleapis/python-genai/blame/c8cfef85c/README.md#L344
430
439
  function_response=FunctionResponse(
431
- id=content.id,
440
+ id=content.id if content.name != content.id else None,
432
441
  name=content.name,
433
442
  response=resp,
434
443
  )
@@ -461,9 +470,9 @@ class GoogleProvider(
461
470
  text = part.get("text")
462
471
  if text:
463
472
  if has_data_model:
464
- contents.append(ContentJson(json.loads(text)))
473
+ contents.append(ContentJson(value=json.loads(text)))
465
474
  else:
466
- contents.append(ContentText(text))
475
+ contents.append(ContentText(text=text))
467
476
  function_call = part.get("function_call")
468
477
  if function_call:
469
478
  # Seems name is required but id is optional?
@@ -530,8 +539,7 @@ def ChatVertex(
530
539
  ::: {.callout-note}
531
540
  ## Python requirements
532
541
 
533
- `ChatGoogle` requires the `google-genai` package
534
- (e.g., `pip install google-genai`).
542
+ `ChatGoogle` requires the `google-genai` package: `pip install "chatlas[vertex]"`.
535
543
  :::
536
544
 
537
545
  ::: {.callout-note}
chatlas/_groq.py CHANGED
@@ -41,7 +41,7 @@ def ChatGroq(
41
41
  ::: {.callout-note}
42
42
  ## Python requirements
43
43
 
44
- `ChatGroq` requires the `openai` package (e.g., `pip install openai`).
44
+ `ChatGroq` requires the `openai` package: `pip install "chatlas[groq]"`.
45
45
  :::
46
46
 
47
47
  Examples
@@ -0,0 +1,116 @@
1
+ # A 'patched' version of LiveRender that adds the 'crop_above' vertical overflow method.
2
+ # Derives from https://github.com/Textualize/rich/pull/3637
3
+ import sys
4
+ from typing import Optional, Tuple
5
+
6
+ if sys.version_info >= (3, 8):
7
+ from typing import Literal
8
+ else:
9
+ from typing_extensions import Literal # pragma: no cover
10
+
11
+ from rich._loop import loop_last
12
+ from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
13
+ from rich.control import Control
14
+ from rich.segment import ControlType, Segment
15
+ from rich.style import StyleType
16
+ from rich.text import Text
17
+
18
+ VerticalOverflowMethod = Literal["crop", "crop_above", "ellipsis", "visible"]
19
+
20
+
21
+ class LiveRender:
22
+ """Creates a renderable that may be updated.
23
+
24
+ Args:
25
+ renderable (RenderableType): Any renderable object.
26
+ style (StyleType, optional): An optional style to apply to the renderable. Defaults to "".
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ renderable: RenderableType,
32
+ style: StyleType = "",
33
+ vertical_overflow: VerticalOverflowMethod = "ellipsis",
34
+ ) -> None:
35
+ self.renderable = renderable
36
+ self.style = style
37
+ self.vertical_overflow = vertical_overflow
38
+ self._shape: Optional[Tuple[int, int]] = None
39
+
40
+ def set_renderable(self, renderable: RenderableType) -> None:
41
+ """Set a new renderable.
42
+
43
+ Args:
44
+ renderable (RenderableType): Any renderable object, including str.
45
+ """
46
+ self.renderable = renderable
47
+
48
+ def position_cursor(self) -> Control:
49
+ """Get control codes to move cursor to beginning of live render.
50
+
51
+ Returns:
52
+ Control: A control instance that may be printed.
53
+ """
54
+ if self._shape is not None:
55
+ _, height = self._shape
56
+ return Control(
57
+ ControlType.CARRIAGE_RETURN,
58
+ (ControlType.ERASE_IN_LINE, 2),
59
+ *(
60
+ (
61
+ (ControlType.CURSOR_UP, 1),
62
+ (ControlType.ERASE_IN_LINE, 2),
63
+ )
64
+ * (height - 1)
65
+ ),
66
+ )
67
+ return Control()
68
+
69
+ def restore_cursor(self) -> Control:
70
+ """Get control codes to clear the render and restore the cursor to its previous position.
71
+
72
+ Returns:
73
+ Control: A Control instance that may be printed.
74
+ """
75
+ if self._shape is not None:
76
+ _, height = self._shape
77
+ return Control(
78
+ ControlType.CARRIAGE_RETURN,
79
+ *((ControlType.CURSOR_UP, 1), (ControlType.ERASE_IN_LINE, 2)) * height,
80
+ )
81
+ return Control()
82
+
83
+ def __rich_console__(
84
+ self, console: Console, options: ConsoleOptions
85
+ ) -> RenderResult:
86
+ renderable = self.renderable
87
+ style = console.get_style(self.style)
88
+ lines = console.render_lines(renderable, options, style=style, pad=False)
89
+ shape = Segment.get_shape(lines)
90
+
91
+ _, height = shape
92
+ if height > options.size.height:
93
+ if self.vertical_overflow == "crop":
94
+ lines = lines[: options.size.height]
95
+ shape = Segment.get_shape(lines)
96
+ elif self.vertical_overflow == "crop_above":
97
+ lines = lines[-(options.size.height) :]
98
+ shape = Segment.get_shape(lines)
99
+ elif self.vertical_overflow == "ellipsis":
100
+ lines = lines[: (options.size.height - 1)]
101
+ overflow_text = Text(
102
+ "...",
103
+ overflow="crop",
104
+ justify="center",
105
+ end="",
106
+ style="live.ellipsis",
107
+ )
108
+ lines.append(list(console.render(overflow_text)))
109
+ shape = Segment.get_shape(lines)
110
+ self._shape = shape
111
+
112
+ new_line = Segment.line()
113
+ for last, line in loop_last(lines):
114
+ yield from line
115
+ if not last:
116
+ yield new_line
chatlas/_ollama.py CHANGED
@@ -51,7 +51,7 @@ def ChatOllama(
51
51
  ::: {.callout-note}
52
52
  ## Python requirements
53
53
 
54
- `ChatOllama` requires the `openai` package (e.g., `pip install openai`).
54
+ `ChatOllama` requires the `openai` package: `pip install "chatlas[ollama]"`.
55
55
  :::
56
56
 
57
57
 
chatlas/_openai.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
3
4
  import json
4
5
  from typing import TYPE_CHECKING, Any, Literal, Optional, cast, overload
5
6
 
@@ -12,6 +13,7 @@ from ._content import (
12
13
  ContentImageInline,
13
14
  ContentImageRemote,
14
15
  ContentJson,
16
+ ContentPDF,
15
17
  ContentText,
16
18
  ContentToolRequest,
17
19
  ContentToolResult,
@@ -79,7 +81,7 @@ def ChatOpenAI(
79
81
  ::: {.callout-note}
80
82
  ## Python requirements
81
83
 
82
- `ChatOpenAI` requires the `openai` package (e.g., `pip install openai`).
84
+ `ChatOpenAI` requires the `openai` package: `pip install "chatlas[openai]"`.
83
85
  :::
84
86
 
85
87
  Examples
@@ -461,6 +463,19 @@ class OpenAIProvider(Provider[ChatCompletion, ChatCompletionChunk, ChatCompletio
461
463
  contents.append({"type": "text", "text": x.text})
462
464
  elif isinstance(x, ContentJson):
463
465
  contents.append({"type": "text", "text": ""})
466
+ elif isinstance(x, ContentPDF):
467
+ contents.append(
468
+ {
469
+ "type": "file",
470
+ "file": {
471
+ "filename": "",
472
+ "file_data": (
473
+ "data:application/pdf;base64,"
474
+ f"{base64.b64encode(x.data).decode('utf-8')}"
475
+ ),
476
+ },
477
+ }
478
+ )
464
479
  elif isinstance(x, ContentImageRemote):
465
480
  contents.append(
466
481
  {
@@ -476,7 +491,7 @@ class OpenAIProvider(Provider[ChatCompletion, ChatCompletionChunk, ChatCompletio
476
491
  {
477
492
  "type": "image_url",
478
493
  "image_url": {
479
- "url": f"data:{x.content_type};base64,{x.data}"
494
+ "url": f"data:{x.image_content_type};base64,{x.data}"
480
495
  },
481
496
  }
482
497
  )
@@ -514,9 +529,9 @@ class OpenAIProvider(Provider[ChatCompletion, ChatCompletionChunk, ChatCompletio
514
529
  if message.content is not None:
515
530
  if has_data_model:
516
531
  data = json.loads(message.content)
517
- contents = [ContentJson(data)]
532
+ contents = [ContentJson(value=data)]
518
533
  else:
519
- contents = [ContentText(message.content)]
534
+ contents = [ContentText(text=message.content)]
520
535
 
521
536
  tool_calls = message.tool_calls
522
537
 
@@ -540,7 +555,7 @@ class OpenAIProvider(Provider[ChatCompletion, ChatCompletionChunk, ChatCompletio
540
555
 
541
556
  contents.append(
542
557
  ContentToolRequest(
543
- call.id,
558
+ id=call.id,
544
559
  name=func.name,
545
560
  arguments=args,
546
561
  )
@@ -592,7 +607,8 @@ def ChatAzureOpenAI(
592
607
  ::: {.callout-note}
593
608
  ## Python requirements
594
609
 
595
- `ChatAzureOpenAI` requires the `openai` package (e.g., `pip install openai`).
610
+ `ChatAzureOpenAI` requires the `openai` package:
611
+ `pip install "chatlas[azure-openai]"`.
596
612
  :::
597
613
 
598
614
  Examples
chatlas/_perplexity.py CHANGED
@@ -43,7 +43,7 @@ def ChatPerplexity(
43
43
  ::: {.callout-note}
44
44
  ## Python requirements
45
45
 
46
- `ChatPerplexity` requires the `openai` package (e.g., `pip install openai`).
46
+ `ChatPerplexity` requires the `openai` package: `pip install "chatlas[perplexity]"`.
47
47
  :::
48
48
 
49
49