baltamatica-mcp 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.
@@ -0,0 +1,3 @@
1
+ """baltamatica-mcp: MCP server for Baltamatica scientific computing engine."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Entry point for running baltamatica-mcp as a module: python -m baltamatica_mcp"""
2
+
3
+ from baltamatica_mcp.server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,316 @@
1
+ """BEX backend client for the Baltamatica JSON-over-TCP bridge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import json
8
+ from dataclasses import replace
9
+ from typing import Any
10
+
11
+ from baltamatica_mcp.engine import (
12
+ Artifact,
13
+ EngineError,
14
+ EngineUnavailableError,
15
+ ExecutionResult,
16
+ VariableInfo,
17
+ VariableListResult,
18
+ )
19
+ from baltamatica_mcp.serializer import (
20
+ encode_for_set,
21
+ present_binary_value,
22
+ present_structured,
23
+ )
24
+
25
+ DEFAULT_BEX_HOST = "127.0.0.1"
26
+ DEFAULT_BEX_PORT = 31415
27
+ DEFAULT_ARTIFACT_TYPE = "application/octet-stream"
28
+ # Max bytes for a single response line (large binary get_variable payloads).
29
+ _STREAM_LIMIT = 64 * 1024 * 1024
30
+
31
+
32
+ class BexProtocolError(EngineError):
33
+ """Raised when the BEX bridge returns malformed protocol data."""
34
+
35
+
36
+ class _BexConnectionLost(EngineUnavailableError):
37
+ """Internal signal used to retry a dropped transport once."""
38
+
39
+
40
+ class BexEngine:
41
+ """Client for a Baltamatica BEX plugin speaking newline-delimited JSON."""
42
+
43
+ backend = "bex"
44
+
45
+ def __init__(
46
+ self,
47
+ host: str = DEFAULT_BEX_HOST,
48
+ port: int = DEFAULT_BEX_PORT,
49
+ timeout: float = 30.0,
50
+ reconnect: bool = True,
51
+ ) -> None:
52
+ if port <= 0 or port > 65535:
53
+ raise ValueError(f"Invalid BEX port: {port}")
54
+ if timeout <= 0:
55
+ raise ValueError(f"Invalid BEX timeout: {timeout}")
56
+
57
+ self.host = host
58
+ self.port = port
59
+ self.timeout = timeout
60
+ self.reconnect = reconnect
61
+ self._reader: asyncio.StreamReader | None = None
62
+ self._writer: asyncio.StreamWriter | None = None
63
+ self._request_counter = 0
64
+
65
+ async def execute_code(self, code: str) -> ExecutionResult:
66
+ response = await self._request("execute_code", {"code": code})
67
+ return _with_output_artifacts(_execution_result_from_response(response))
68
+
69
+ async def run_script(self, file_path: str) -> ExecutionResult:
70
+ response = await self._request("run_script", {"file_path": file_path})
71
+ return _with_output_artifacts(_execution_result_from_response(response))
72
+
73
+ async def clear_workspace(self) -> ExecutionResult:
74
+ response = await self._request("clear_workspace", {})
75
+ return _execution_result_from_response(response)
76
+
77
+ async def set_variable(self, name: str, data: Any, dtype: str | None = None) -> ExecutionResult:
78
+ resolved_dtype, dims, raw = encode_for_set(data, dtype)
79
+ response = await self._request(
80
+ "set_variable",
81
+ {
82
+ "name": name,
83
+ "dtype": resolved_dtype,
84
+ "dims": dims,
85
+ "data_b64": base64.b64encode(raw).decode("ascii"),
86
+ },
87
+ )
88
+ return _execution_result_from_response(response)
89
+
90
+ async def list_variables(self) -> VariableListResult:
91
+ response = await self._request("list_variables", {})
92
+ return VariableListResult(
93
+ success=bool(response.get("success", False)),
94
+ variables=_variables_from_response(response),
95
+ output=_string_field(response, "output"),
96
+ error=_error_from_response(response),
97
+ )
98
+
99
+ async def get_variable(self, name: str) -> ExecutionResult:
100
+ response = await self._request("get_variable", {"name": name})
101
+ result = _execution_result_from_response(response)
102
+ value = result.value
103
+ if isinstance(value, dict) and "data_b64" in value:
104
+ presented, artifacts = present_binary_value(value, name=name)
105
+ result = replace(
106
+ result,
107
+ value=presented,
108
+ artifacts=(result.artifacts or []) + artifacts,
109
+ )
110
+ elif isinstance(value, dict) and value.get("type") in {
111
+ "numeric_array",
112
+ "logical_array",
113
+ "struct",
114
+ "cell",
115
+ }:
116
+ result = replace(result, value=present_structured(value))
117
+ return result
118
+
119
+ async def close(self) -> None:
120
+ """Close the current TCP connection, if one is open."""
121
+
122
+ writer = self._writer
123
+ self._reader = None
124
+ self._writer = None
125
+ if writer is None:
126
+ return
127
+ writer.close()
128
+ await writer.wait_closed()
129
+
130
+ async def _request(self, method: str, params: dict[str, object]) -> dict[str, Any]:
131
+ payload = {
132
+ "id": self._next_request_id(),
133
+ "method": method,
134
+ "params": params,
135
+ }
136
+
137
+ try:
138
+ return await self._send_once(payload)
139
+ except _BexConnectionLost:
140
+ if not self.reconnect:
141
+ raise
142
+ return await self._send_once(payload)
143
+
144
+ async def _send_once(self, payload: dict[str, object]) -> dict[str, Any]:
145
+ reader, writer = await self._ensure_connection()
146
+ data = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + b"\n"
147
+
148
+ try:
149
+ writer.write(data)
150
+ await asyncio.wait_for(writer.drain(), timeout=self.timeout)
151
+ line = await asyncio.wait_for(reader.readline(), timeout=self.timeout)
152
+ except asyncio.TimeoutError as exc:
153
+ await self.close()
154
+ raise TimeoutError(
155
+ f"BEX request '{payload['method']}' timed out after {self.timeout:g} seconds."
156
+ ) from exc
157
+ except (ConnectionError, OSError) as exc:
158
+ await self.close()
159
+ raise _BexConnectionLost(f"BEX connection lost: {exc}") from exc
160
+
161
+ if not line:
162
+ await self.close()
163
+ raise _BexConnectionLost("BEX connection closed before a response was received.")
164
+
165
+ try:
166
+ response = json.loads(line.decode("utf-8"))
167
+ except json.JSONDecodeError as exc:
168
+ raise BexProtocolError(f"BEX response is not valid JSON: {exc}") from exc
169
+
170
+ if not isinstance(response, dict):
171
+ raise BexProtocolError("BEX response must be a JSON object.")
172
+ if response.get("id") != payload["id"]:
173
+ raise BexProtocolError(
174
+ f"BEX response id mismatch: expected {payload['id']!r}, "
175
+ f"got {response.get('id')!r}."
176
+ )
177
+ return response
178
+
179
+ async def _ensure_connection(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
180
+ if self._connection_is_usable():
181
+ return self._reader, self._writer # type: ignore[return-value]
182
+
183
+ await self.close()
184
+ try:
185
+ self._reader, self._writer = await asyncio.wait_for(
186
+ # A large stream limit so big get_variable responses (base64 of a
187
+ # large matrix) fit in a single readline() instead of raising
188
+ # LimitOverrunError at the 64 KB default.
189
+ asyncio.open_connection(self.host, self.port, limit=_STREAM_LIMIT),
190
+ timeout=self.timeout,
191
+ )
192
+ except asyncio.TimeoutError as exc:
193
+ raise EngineUnavailableError(
194
+ f"Timed out connecting to BEX bridge at {self.host}:{self.port}."
195
+ ) from exc
196
+ except OSError as exc:
197
+ raise EngineUnavailableError(
198
+ f"Failed to connect to BEX bridge at {self.host}:{self.port}: {exc}"
199
+ ) from exc
200
+ return self._reader, self._writer
201
+
202
+ def _connection_is_usable(self) -> bool:
203
+ return (
204
+ self._reader is not None
205
+ and self._writer is not None
206
+ and not self._writer.is_closing()
207
+ and not self._reader.at_eof()
208
+ )
209
+
210
+ def _next_request_id(self) -> str:
211
+ self._request_counter += 1
212
+ return str(self._request_counter)
213
+
214
+
215
+ def _with_output_artifacts(result: ExecutionResult) -> ExecutionResult:
216
+ """Merge BALTAMATICA_ARTIFACT markers found in captured output into artifacts.
217
+
218
+ Baltamatica cannot export figures to files, but data-export functions
219
+ (writematrix, writetable, save, ...) can, and a script/command can announce
220
+ the resulting file with a BALTAMATICA_ARTIFACT line. Since execute_code and
221
+ run_script now capture console output, those markers are reported here too.
222
+ """
223
+
224
+ from baltamatica_mcp.backend_cli import parse_artifacts
225
+
226
+ if not result.output:
227
+ return result
228
+ marker_artifacts = parse_artifacts(result.output)
229
+ if not marker_artifacts:
230
+ return result
231
+ return replace(result, artifacts=(result.artifacts or []) + marker_artifacts)
232
+
233
+
234
+ def _execution_result_from_response(response: dict[str, Any]) -> ExecutionResult:
235
+ return ExecutionResult(
236
+ success=bool(response.get("success", False)),
237
+ output=_string_field(response, "output"),
238
+ error=_error_from_response(response),
239
+ artifacts=_artifacts_from_response(response),
240
+ value=_value_from_response(response),
241
+ )
242
+
243
+
244
+ def _error_from_response(response: dict[str, Any]) -> str | None:
245
+ error = response.get("error")
246
+ if error is None:
247
+ return None if response.get("success", False) else "BEX request failed."
248
+ if isinstance(error, dict):
249
+ code = str(error.get("code") or "ERROR")
250
+ message = str(error.get("message") or "")
251
+ return f"{code}: {message}" if message else code
252
+ return str(error)
253
+
254
+
255
+ def _artifacts_from_response(response: dict[str, Any]) -> list[Artifact]:
256
+ raw_artifacts = response.get("artifacts") or []
257
+ if not isinstance(raw_artifacts, list):
258
+ return []
259
+
260
+ artifacts: list[Artifact] = []
261
+ for item in raw_artifacts:
262
+ if not isinstance(item, dict):
263
+ continue
264
+ path = str(item.get("path") or "")
265
+ if not path:
266
+ continue
267
+ artifacts.append(
268
+ Artifact(
269
+ path=path,
270
+ type=str(item.get("type") or DEFAULT_ARTIFACT_TYPE),
271
+ exists=bool(item.get("exists", False)),
272
+ size=_int_field(item.get("size")),
273
+ )
274
+ )
275
+ return artifacts
276
+
277
+
278
+ def _value_from_response(response: dict[str, Any]) -> dict[str, Any] | None:
279
+ value = response.get("value")
280
+ return value if isinstance(value, dict) else None
281
+
282
+
283
+ def _variables_from_response(response: dict[str, Any]) -> list[VariableInfo]:
284
+ raw_variables = response.get("variables") or []
285
+ if not isinstance(raw_variables, list):
286
+ return []
287
+
288
+ variables: list[VariableInfo] = []
289
+ for item in raw_variables:
290
+ if not isinstance(item, dict):
291
+ continue
292
+ name = str(item.get("name") or "")
293
+ if not name:
294
+ continue
295
+ variables.append(
296
+ VariableInfo(
297
+ name=name,
298
+ size=str(item.get("size") or ""),
299
+ bytes=_int_field(item.get("bytes")),
300
+ class_name=str(item.get("class_name") or ""),
301
+ attributes=str(item.get("attributes") or ""),
302
+ )
303
+ )
304
+ return variables
305
+
306
+
307
+ def _string_field(data: dict[str, Any], key: str) -> str:
308
+ value = data.get(key)
309
+ return "" if value is None else str(value)
310
+
311
+
312
+ def _int_field(value: object) -> int:
313
+ try:
314
+ return int(value) if value is not None else 0
315
+ except (TypeError, ValueError):
316
+ return 0
@@ -0,0 +1,323 @@
1
+ """CLI backend for running Baltamatica commands through baltamaticaC.sh."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import locale
7
+ import os
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ from collections.abc import Sequence
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from baltamatica_mcp.engine import (
17
+ Artifact,
18
+ ExecutionResult,
19
+ EngineUnavailableError,
20
+ VariableInfo,
21
+ VariableListResult,
22
+ )
23
+
24
+ DEFAULT_EXECUTABLE = "baltamaticaC.sh"
25
+ ENV_EXECUTABLE = "BALTAMATICA_CLI"
26
+ ARTIFACT_PREFIX = "BALTAMATICA_ARTIFACT="
27
+ VAR_NAME_PATTERN = re.compile(r"^[A-Za-z]\w*$")
28
+ ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
29
+ NOISE_OUTPUT_PATTERNS = (
30
+ "Failed to delete old log:",
31
+ )
32
+ ERROR_OUTPUT_PATTERNS = (
33
+ "错误使用函数",
34
+ "未定义的变量或函数",
35
+ "未定义的函数",
36
+ "是未定义的变量或函数",
37
+ "位于输入的第",
38
+ "位于文件",
39
+ )
40
+ WHOS_ROW_PATTERN = re.compile(
41
+ r"^\s*(?P<name>[A-Za-z]\w*)\s+"
42
+ r"(?P<size>\S+)\s+"
43
+ r"(?P<bytes>\d+)\s+"
44
+ r"(?P<class_name>\S+)"
45
+ r"(?:\s+(?P<attributes>.*?))?\s*$"
46
+ )
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class CliProcessResult:
51
+ """Raw process result from the Baltamatica CLI."""
52
+
53
+ returncode: int
54
+ stdout: str
55
+ stderr: str
56
+
57
+
58
+ class CliEngine:
59
+ """Execute Baltamatica code by spawning the command-line runtime."""
60
+
61
+ backend = "cli"
62
+
63
+ def __init__(
64
+ self,
65
+ executable: str | None = None,
66
+ timeout: float = 30.0,
67
+ state_file: str | Path | None = None,
68
+ ) -> None:
69
+ self._configured_executable = executable
70
+ self.timeout = timeout
71
+ self.state_file = Path(state_file) if state_file else _default_state_file()
72
+
73
+ async def execute_code(self, code: str) -> ExecutionResult:
74
+ return await self._execute_command(self._wrap_stateful_code(code))
75
+
76
+ async def run_script(self, file_path: str) -> ExecutionResult:
77
+ script_command = f"run('{_escape_baltamatica_string(Path(file_path).as_posix())}')"
78
+ return await self.execute_code(script_command)
79
+
80
+ async def clear_workspace(self) -> ExecutionResult:
81
+ if self.state_file.exists():
82
+ self.state_file.unlink()
83
+ return await self._execute_command("clear")
84
+
85
+ async def set_variable(self, name: str, data: object, dtype: str | None = None) -> ExecutionResult:
86
+ from baltamatica_mcp.serializer import to_baltamatica_literal
87
+
88
+ return await self.execute_code(f"{name} = {to_baltamatica_literal(data, dtype)};")
89
+
90
+ async def list_variables(self) -> VariableListResult:
91
+ result = await self._execute_command(self._wrap_readonly_code("whos"))
92
+ if not result.success:
93
+ return VariableListResult(
94
+ success=False,
95
+ variables=[],
96
+ output=result.output,
97
+ error=result.error,
98
+ )
99
+ return VariableListResult(
100
+ success=True,
101
+ variables=parse_whos_output(result.output),
102
+ output=result.output,
103
+ )
104
+
105
+ async def get_variable(self, name: str) -> ExecutionResult:
106
+ if not VAR_NAME_PATTERN.match(name):
107
+ raise ValueError(f"Invalid variable name: {name}")
108
+ return await self._execute_command(self._wrap_readonly_code(f"disp({name})"))
109
+
110
+ def _resolve_executable(self) -> str:
111
+ configured = self._configured_executable or os.environ.get(ENV_EXECUTABLE)
112
+ if configured:
113
+ path = Path(configured).expanduser()
114
+ if path.is_absolute() or len(path.parts) > 1:
115
+ if not path.exists():
116
+ raise EngineUnavailableError(
117
+ f"Baltamatica CLI executable does not exist: {path}"
118
+ )
119
+ if not path.is_file():
120
+ raise EngineUnavailableError(f"Baltamatica CLI path is not a file: {path}")
121
+ return str(path)
122
+ resolved = shutil.which(configured)
123
+ if resolved:
124
+ return resolved
125
+ raise EngineUnavailableError(
126
+ f"Baltamatica CLI executable not found on PATH: {configured}"
127
+ )
128
+
129
+ resolved = shutil.which(DEFAULT_EXECUTABLE)
130
+ if resolved:
131
+ return resolved
132
+ raise EngineUnavailableError(
133
+ f"Baltamatica CLI executable not found. Set {ENV_EXECUTABLE} or pass --cli-executable."
134
+ )
135
+
136
+ async def _run_cli(self, argv: Sequence[str]) -> CliProcessResult:
137
+ return await asyncio.to_thread(self._run_cli_sync, argv)
138
+
139
+ async def _execute_command(self, code: str) -> ExecutionResult:
140
+ process = await self._run_cli([self._resolve_executable(), "-nodesktop", "-s", code])
141
+ output = _combine_output(process.stdout, process.stderr)
142
+ artifacts = parse_artifacts(output)
143
+ if process.returncode != 0:
144
+ return ExecutionResult(
145
+ success=False,
146
+ output=output,
147
+ error=_process_error(process),
148
+ artifacts=artifacts,
149
+ )
150
+ output_error = detect_baltamatica_error(output)
151
+ if output_error:
152
+ return ExecutionResult(
153
+ success=False,
154
+ output=output,
155
+ error=output_error,
156
+ artifacts=artifacts,
157
+ )
158
+ return ExecutionResult(success=True, output=output, artifacts=artifacts)
159
+
160
+ def _wrap_stateful_code(self, code: str) -> str:
161
+ state_path = _escape_baltamatica_string(self.state_file.as_posix())
162
+ return (
163
+ f"if exist('{state_path}','file'), load('{state_path}'); end; "
164
+ f"{code}; "
165
+ f"save('{state_path}');"
166
+ )
167
+
168
+ def _wrap_readonly_code(self, code: str) -> str:
169
+ state_path = _escape_baltamatica_string(self.state_file.as_posix())
170
+ return f"if exist('{state_path}','file'), load('{state_path}'); end; {code};"
171
+
172
+ def _run_cli_sync(self, argv: Sequence[str]) -> CliProcessResult:
173
+ creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
174
+ try:
175
+ process = subprocess.run(
176
+ argv,
177
+ capture_output=True,
178
+ check=False,
179
+ stdin=subprocess.DEVNULL,
180
+ creationflags=creationflags,
181
+ timeout=self.timeout,
182
+ )
183
+ except subprocess.TimeoutExpired as exc:
184
+ raise TimeoutError(
185
+ f"Baltamatica CLI timed out after {self.timeout:g} seconds."
186
+ ) from exc
187
+ except OSError as exc:
188
+ raise EngineUnavailableError(f"Failed to start Baltamatica CLI: {exc}") from exc
189
+
190
+ return CliProcessResult(
191
+ returncode=process.returncode,
192
+ stdout=_decode_process_output(process.stdout),
193
+ stderr=_decode_process_output(process.stderr),
194
+ )
195
+
196
+
197
+ def _combine_output(stdout: str | None, stderr: str | None) -> str:
198
+ output = _clean_process_output(stdout or "")
199
+ error_output = _clean_process_output(stderr or "")
200
+ if output and error_output:
201
+ return f"{output}\n{error_output}"
202
+ return output or error_output
203
+
204
+
205
+ def _decode_process_output(output: bytes | None) -> str:
206
+ if not output:
207
+ return ""
208
+ for encoding in ("utf-8", locale.getpreferredencoding(False), "gbk"):
209
+ try:
210
+ return output.decode(encoding)
211
+ except UnicodeDecodeError:
212
+ continue
213
+ return output.decode("utf-8", errors="replace")
214
+
215
+
216
+ def _clean_process_output(output: str) -> str:
217
+ lines = []
218
+ for line in _strip_ansi(output).splitlines():
219
+ stripped = line.strip()
220
+ if any(stripped.startswith(pattern) for pattern in NOISE_OUTPUT_PATTERNS):
221
+ continue
222
+ lines.append(line.rstrip())
223
+ return "\n".join(lines).strip()
224
+
225
+
226
+ def _process_error(process: CliProcessResult) -> str:
227
+ message = process.stderr.strip() or process.stdout.strip()
228
+ if message:
229
+ return f"Baltamatica CLI exited with code {process.returncode}: {message}"
230
+ return f"Baltamatica CLI exited with code {process.returncode}."
231
+
232
+
233
+ def detect_baltamatica_error(output: str) -> str | None:
234
+ """Detect Baltamatica error text when the CLI exits with status 0."""
235
+
236
+ plain_output = _strip_ansi(output)
237
+ for pattern in ERROR_OUTPUT_PATTERNS:
238
+ if pattern in plain_output:
239
+ first_line = next(
240
+ (line.strip() for line in plain_output.splitlines() if line.strip()),
241
+ plain_output.strip(),
242
+ )
243
+ return f"Baltamatica reported an error: {first_line}"
244
+ return None
245
+
246
+
247
+ def _strip_ansi(value: str) -> str:
248
+ return ANSI_PATTERN.sub("", value)
249
+
250
+
251
+ def _escape_baltamatica_string(value: str) -> str:
252
+ return value.replace("'", "''")
253
+
254
+
255
+ def _default_state_file() -> Path:
256
+ return Path(
257
+ tempfile.NamedTemporaryFile(prefix="baltamatica_mcp_", suffix=".mat", delete=True).name
258
+ )
259
+
260
+
261
+ def parse_whos_output(output: str) -> list[VariableInfo]:
262
+ variables: list[VariableInfo] = []
263
+ for line in output.splitlines():
264
+ match = WHOS_ROW_PATTERN.match(line)
265
+ if not match:
266
+ continue
267
+ variables.append(
268
+ VariableInfo(
269
+ name=match.group("name"),
270
+ size=match.group("size"),
271
+ bytes=int(match.group("bytes")),
272
+ class_name=match.group("class_name"),
273
+ attributes=(match.group("attributes") or "").strip(),
274
+ )
275
+ )
276
+ return variables
277
+
278
+
279
+ def parse_artifacts(output: str) -> list[Artifact]:
280
+ artifacts: list[Artifact] = []
281
+ for line in output.splitlines():
282
+ stripped = line.strip()
283
+ if not stripped.startswith(ARTIFACT_PREFIX):
284
+ continue
285
+ spec = stripped.removeprefix(ARTIFACT_PREFIX).strip()
286
+ if not spec:
287
+ continue
288
+ artifact_type, artifact_path = _parse_artifact_spec(spec)
289
+ path = Path(artifact_path).expanduser().resolve()
290
+ artifacts.append(
291
+ Artifact(
292
+ path=str(path),
293
+ type=artifact_type,
294
+ exists=path.exists() and path.is_file(),
295
+ size=path.stat().st_size if path.exists() and path.is_file() else 0,
296
+ )
297
+ )
298
+ return artifacts
299
+
300
+
301
+ def _parse_artifact_spec(spec: str) -> tuple[str, str]:
302
+ if ":" not in spec:
303
+ return _guess_artifact_type(spec), spec
304
+
305
+ maybe_type, maybe_path = spec.split(":", 1)
306
+ if "/" not in maybe_type:
307
+ return _guess_artifact_type(spec), spec
308
+ return maybe_type.strip(), maybe_path.strip()
309
+
310
+
311
+ def _guess_artifact_type(path: str) -> str:
312
+ suffix = Path(path).suffix.lower()
313
+ if suffix == ".png":
314
+ return "image/png"
315
+ if suffix in {".jpg", ".jpeg"}:
316
+ return "image/jpeg"
317
+ if suffix == ".svg":
318
+ return "image/svg+xml"
319
+ if suffix == ".pdf":
320
+ return "application/pdf"
321
+ if suffix == ".csv":
322
+ return "text/csv"
323
+ return "application/octet-stream"