meshapi-code 0.3.4__py3-none-any.whl → 0.4.1__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.
meshapi/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.3.4"
1
+ __version__ = "0.4.1"
meshapi/attachments.py ADDED
@@ -0,0 +1,150 @@
1
+ """Image attachment loading for multimodal chat.
2
+
3
+ Builds OpenAI-compatible content parts (`{type: image_url, image_url: {url,
4
+ detail}}`) from local file paths or HTTP(S) URLs. We always base64-encode
5
+ into a `data:image/...;base64,...` URL — the Mesh API docs warn that some
6
+ providers refuse public URLs and require base64, so always-base64 is the
7
+ maximally-compatible default.
8
+
9
+ Also provides a conservative auto-detector for the main loop: scan a user
10
+ prompt for tokens that unambiguously look like image paths or URLs (start
11
+ with `/`, `~`, `./`, `../`, or `http(s)://`, end in a known image extension),
12
+ so a dragged-in file path can be attached without an explicit slash command.
13
+ """
14
+ import base64
15
+ import mimetypes
16
+ from pathlib import Path
17
+ from urllib.parse import urlparse
18
+
19
+ import httpx
20
+
21
+ # Size guardrails. We don't refuse — vision tokens are the user's call — but we
22
+ # do report sizes back so the user sees the cost.
23
+ HARD_LIMIT_BYTES = 20 * 1024 * 1024 # 20 MB
24
+
25
+ IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".gif", ".webp")
26
+ IMAGE_MIMES = {"image/png", "image/jpeg", "image/gif", "image/webp"}
27
+
28
+
29
+ class AttachmentError(Exception):
30
+ """Raised for any user-facing failure to attach an image."""
31
+
32
+
33
+ def load_image(source: str, detail: str = "auto") -> tuple[dict, dict]:
34
+ """Load an image from a local path or URL into a content part.
35
+
36
+ Returns (content_part, info):
37
+ - content_part is the dict to splice into the message's `content` array:
38
+ `{"type":"image_url","image_url":{"url":"data:...","detail":...}}`
39
+ - info has display metadata: `{"name", "size_bytes", "mime"}`
40
+
41
+ Raises AttachmentError on any failure (not found, too big, wrong MIME,
42
+ network error). Caller is expected to surface the message to the user.
43
+ """
44
+ if _looks_like_url(source):
45
+ data, mime, name = _fetch_url(source)
46
+ else:
47
+ data, mime, name = _read_local(source)
48
+
49
+ if len(data) > HARD_LIMIT_BYTES:
50
+ raise AttachmentError(
51
+ f"image too large: {len(data) // (1024*1024)} MB "
52
+ f"(limit {HARD_LIMIT_BYTES // (1024*1024)} MB)"
53
+ )
54
+
55
+ b64 = base64.b64encode(data).decode("ascii")
56
+ data_url = f"data:{mime};base64,{b64}"
57
+ return (
58
+ {"type": "image_url", "image_url": {"url": data_url, "detail": detail}},
59
+ {"name": name, "size_bytes": len(data), "mime": mime},
60
+ )
61
+
62
+
63
+ def find_image_tokens(text: str) -> list[str]:
64
+ """Return tokens in `text` that look like image paths or URLs.
65
+
66
+ Conservative on purpose — only matches:
67
+ - http(s) URLs ending in a known image extension
68
+ - Local paths starting with `/`, `~/`, `./`, or `../` (and ending in an
69
+ image extension, AND pointing at an existing file)
70
+
71
+ Bare filenames (`foo.png`) are NOT matched: too ambiguous with filenames
72
+ mentioned in conversation. Tokens wrapped in backticks or quotes are
73
+ skipped (user's escape hatch).
74
+
75
+ Trailing sentence punctuation (`.,;:!?)`) is trimmed before matching.
76
+ """
77
+ matches: list[str] = []
78
+ for raw in text.split():
79
+ if not raw or raw[0] in "`\"'":
80
+ continue
81
+ token = raw
82
+ while token and token[-1] in ".,;:!?)":
83
+ token = token[:-1]
84
+ if not token:
85
+ continue
86
+ low = token.lower()
87
+ if low.startswith(("http://", "https://")):
88
+ path_part = token.split("?", 1)[0]
89
+ if path_part.lower().endswith(IMAGE_EXTS):
90
+ matches.append(token)
91
+ continue
92
+ if token.startswith(("/", "~/", "./", "../")):
93
+ if low.endswith(IMAGE_EXTS):
94
+ try:
95
+ p = Path(token).expanduser()
96
+ if p.is_file():
97
+ matches.append(token)
98
+ except OSError:
99
+ pass
100
+ return matches
101
+
102
+
103
+ def _looks_like_url(s: str) -> bool:
104
+ try:
105
+ u = urlparse(s)
106
+ except (ValueError, AttributeError):
107
+ return False
108
+ return u.scheme in ("http", "https") and bool(u.netloc)
109
+
110
+
111
+ def _fetch_url(url: str) -> tuple[bytes, str, str]:
112
+ try:
113
+ with httpx.Client(timeout=30, follow_redirects=True) as client:
114
+ r = client.get(url)
115
+ r.raise_for_status()
116
+ data = r.content
117
+ mime = r.headers.get("content-type", "").split(";")[0].strip()
118
+ except httpx.HTTPError as e:
119
+ raise AttachmentError(f"couldn't fetch {url}: {e}") from None
120
+
121
+ if not mime:
122
+ guessed, _ = mimetypes.guess_type(url)
123
+ mime = guessed or ""
124
+ if mime not in IMAGE_MIMES:
125
+ raise AttachmentError(
126
+ f"URL doesn't look like an image (mime: {mime or 'unknown'})"
127
+ )
128
+ name = Path(urlparse(url).path).name or "image"
129
+ return data, mime, name
130
+
131
+
132
+ def _read_local(path_str: str) -> tuple[bytes, str, str]:
133
+ path = Path(path_str).expanduser()
134
+ if not path.exists():
135
+ raise AttachmentError(f"file not found: {path}")
136
+ if not path.is_file():
137
+ raise AttachmentError(f"not a regular file: {path}")
138
+ mime, _ = mimetypes.guess_type(str(path))
139
+ if not mime:
140
+ raise AttachmentError(f"couldn't determine MIME type for {path.name}")
141
+ if mime not in IMAGE_MIMES:
142
+ raise AttachmentError(
143
+ f"{path.name} isn't an image (mime: {mime}). "
144
+ "For text documents, use /file."
145
+ )
146
+ try:
147
+ data = path.read_bytes()
148
+ except OSError as e:
149
+ raise AttachmentError(f"can't read {path}: {e}") from None
150
+ return data, mime, path.name