chatspatial 1.1.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.
- chatspatial/__init__.py +11 -0
- chatspatial/__main__.py +141 -0
- chatspatial/cli/__init__.py +7 -0
- chatspatial/config.py +53 -0
- chatspatial/models/__init__.py +85 -0
- chatspatial/models/analysis.py +513 -0
- chatspatial/models/data.py +2462 -0
- chatspatial/server.py +1763 -0
- chatspatial/spatial_mcp_adapter.py +720 -0
- chatspatial/tools/__init__.py +3 -0
- chatspatial/tools/annotation.py +1903 -0
- chatspatial/tools/cell_communication.py +1603 -0
- chatspatial/tools/cnv_analysis.py +605 -0
- chatspatial/tools/condition_comparison.py +595 -0
- chatspatial/tools/deconvolution/__init__.py +402 -0
- chatspatial/tools/deconvolution/base.py +318 -0
- chatspatial/tools/deconvolution/card.py +244 -0
- chatspatial/tools/deconvolution/cell2location.py +326 -0
- chatspatial/tools/deconvolution/destvi.py +144 -0
- chatspatial/tools/deconvolution/flashdeconv.py +101 -0
- chatspatial/tools/deconvolution/rctd.py +317 -0
- chatspatial/tools/deconvolution/spotlight.py +216 -0
- chatspatial/tools/deconvolution/stereoscope.py +109 -0
- chatspatial/tools/deconvolution/tangram.py +135 -0
- chatspatial/tools/differential.py +625 -0
- chatspatial/tools/embeddings.py +298 -0
- chatspatial/tools/enrichment.py +1863 -0
- chatspatial/tools/integration.py +807 -0
- chatspatial/tools/preprocessing.py +723 -0
- chatspatial/tools/spatial_domains.py +808 -0
- chatspatial/tools/spatial_genes.py +836 -0
- chatspatial/tools/spatial_registration.py +441 -0
- chatspatial/tools/spatial_statistics.py +1476 -0
- chatspatial/tools/trajectory.py +495 -0
- chatspatial/tools/velocity.py +405 -0
- chatspatial/tools/visualization/__init__.py +155 -0
- chatspatial/tools/visualization/basic.py +393 -0
- chatspatial/tools/visualization/cell_comm.py +699 -0
- chatspatial/tools/visualization/cnv.py +320 -0
- chatspatial/tools/visualization/core.py +684 -0
- chatspatial/tools/visualization/deconvolution.py +852 -0
- chatspatial/tools/visualization/enrichment.py +660 -0
- chatspatial/tools/visualization/integration.py +205 -0
- chatspatial/tools/visualization/main.py +164 -0
- chatspatial/tools/visualization/multi_gene.py +739 -0
- chatspatial/tools/visualization/persistence.py +335 -0
- chatspatial/tools/visualization/spatial_stats.py +469 -0
- chatspatial/tools/visualization/trajectory.py +639 -0
- chatspatial/tools/visualization/velocity.py +411 -0
- chatspatial/utils/__init__.py +115 -0
- chatspatial/utils/adata_utils.py +1372 -0
- chatspatial/utils/compute.py +327 -0
- chatspatial/utils/data_loader.py +499 -0
- chatspatial/utils/dependency_manager.py +462 -0
- chatspatial/utils/device_utils.py +165 -0
- chatspatial/utils/exceptions.py +185 -0
- chatspatial/utils/image_utils.py +267 -0
- chatspatial/utils/mcp_utils.py +137 -0
- chatspatial/utils/path_utils.py +243 -0
- chatspatial/utils/persistence.py +78 -0
- chatspatial/utils/scipy_compat.py +143 -0
- chatspatial-1.1.0.dist-info/METADATA +242 -0
- chatspatial-1.1.0.dist-info/RECORD +67 -0
- chatspatial-1.1.0.dist-info/WHEEL +5 -0
- chatspatial-1.1.0.dist-info/entry_points.txt +2 -0
- chatspatial-1.1.0.dist-info/licenses/LICENSE +21 -0
- chatspatial-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exception classes for ChatSpatial.
|
|
3
|
+
|
|
4
|
+
Exception Hierarchy Design Principles:
|
|
5
|
+
======================================
|
|
6
|
+
1. SEMANTIC CLARITY: Exception type immediately indicates error category
|
|
7
|
+
2. MINIMAL HIERARCHY: Only add subclasses when semantically distinct
|
|
8
|
+
3. CONSISTENCY: Same semantic error uses same exception type everywhere
|
|
9
|
+
4. DEBUGGABILITY: Always preserve exception chain with `raise X from e`
|
|
10
|
+
|
|
11
|
+
Exception Hierarchy:
|
|
12
|
+
====================
|
|
13
|
+
ChatSpatialError (base)
|
|
14
|
+
├── DataError (data access/availability/format issues)
|
|
15
|
+
│ ├── DataNotFoundError (required data missing)
|
|
16
|
+
│ └── DataCompatibilityError (format/species mismatch)
|
|
17
|
+
├── ParameterError (invalid user input/parameters)
|
|
18
|
+
├── ProcessingError (algorithm/computation failures)
|
|
19
|
+
└── DependencyError (missing packages/R environment)
|
|
20
|
+
|
|
21
|
+
Usage Guidelines:
|
|
22
|
+
=================
|
|
23
|
+
|
|
24
|
+
INSTEAD OF ValueError, USE:
|
|
25
|
+
---------------------------
|
|
26
|
+
- ParameterError: Invalid parameter values, invalid combinations
|
|
27
|
+
Example: "n_clusters must be > 0", "method must be 'leiden' or 'louvain'"
|
|
28
|
+
|
|
29
|
+
- DataError: Data validation failures (missing columns, wrong format)
|
|
30
|
+
Example: "Column 'cell_type' not found", "Data contains NaN values"
|
|
31
|
+
|
|
32
|
+
INSTEAD OF RuntimeError, USE:
|
|
33
|
+
-----------------------------
|
|
34
|
+
- ProcessingError: Algorithm failures, computation errors
|
|
35
|
+
Example: "Clustering failed to converge", "Model training failed"
|
|
36
|
+
|
|
37
|
+
- DependencyError: Package/environment issues
|
|
38
|
+
Example: "scvi-tools not installed", "R environment not configured"
|
|
39
|
+
|
|
40
|
+
ALWAYS use exception chaining:
|
|
41
|
+
------------------------------
|
|
42
|
+
try:
|
|
43
|
+
result = compute()
|
|
44
|
+
except Exception as e:
|
|
45
|
+
raise ProcessingError(f"Computation failed: {e}") from e
|
|
46
|
+
|
|
47
|
+
NEVER silently swallow exceptions:
|
|
48
|
+
----------------------------------
|
|
49
|
+
# BAD
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
# GOOD
|
|
54
|
+
except ExpectedError as e:
|
|
55
|
+
logger.debug(f"Expected error: {e}")
|
|
56
|
+
# Handle gracefully
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ChatSpatialError(Exception):
|
|
61
|
+
"""Base exception for all ChatSpatial errors.
|
|
62
|
+
|
|
63
|
+
All custom exceptions inherit from this class, allowing:
|
|
64
|
+
- Catch all ChatSpatial errors: `except ChatSpatialError`
|
|
65
|
+
- Distinguish from Python built-in exceptions
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# =============================================================================
|
|
70
|
+
# Data Errors - Issues with data access, availability, or format
|
|
71
|
+
# =============================================================================
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class DataError(ChatSpatialError):
|
|
75
|
+
"""Data-related errors (missing, invalid format, validation failures).
|
|
76
|
+
|
|
77
|
+
Use when:
|
|
78
|
+
- Required data column/key is missing
|
|
79
|
+
- Data format is invalid (wrong dtype, contains NaN/Inf)
|
|
80
|
+
- Data validation fails (too few cells, no spatial coordinates)
|
|
81
|
+
|
|
82
|
+
Examples:
|
|
83
|
+
raise DataError("Spatial coordinates contain NaN values")
|
|
84
|
+
raise DataError("Expression matrix is empty")
|
|
85
|
+
raise DataError(f"Column '{col}' not found in adata.obs")
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DataNotFoundError(DataError):
|
|
90
|
+
"""Required data not found in the expected location.
|
|
91
|
+
|
|
92
|
+
Use when:
|
|
93
|
+
- Dataset ID not found in registry
|
|
94
|
+
- Required analysis results missing (e.g., deconvolution not run)
|
|
95
|
+
- Expected file/resource doesn't exist
|
|
96
|
+
|
|
97
|
+
Examples:
|
|
98
|
+
raise DataNotFoundError(f"Dataset '{data_id}' not found")
|
|
99
|
+
raise DataNotFoundError("Deconvolution results not found")
|
|
100
|
+
raise DataNotFoundError("Velocity results not found in adata.obsm")
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class DataCompatibilityError(DataError):
|
|
105
|
+
"""Data format or compatibility issues between datasets.
|
|
106
|
+
|
|
107
|
+
Use when:
|
|
108
|
+
- Gene naming convention mismatch (Ensembl vs Symbol)
|
|
109
|
+
- Species mismatch between spatial and reference data
|
|
110
|
+
- Incompatible data formats for integration
|
|
111
|
+
|
|
112
|
+
Examples:
|
|
113
|
+
raise DataCompatibilityError("Gene naming mismatch: symbols vs Ensembl")
|
|
114
|
+
raise DataCompatibilityError("Species mismatch: mouse vs human")
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# =============================================================================
|
|
119
|
+
# Parameter Errors - Invalid user input or parameter combinations
|
|
120
|
+
# =============================================================================
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class ParameterError(ChatSpatialError):
|
|
124
|
+
"""Invalid parameter values or combinations.
|
|
125
|
+
|
|
126
|
+
Use when:
|
|
127
|
+
- Parameter value out of valid range
|
|
128
|
+
- Invalid parameter combination
|
|
129
|
+
- Required parameter missing
|
|
130
|
+
- Parameter type is wrong
|
|
131
|
+
|
|
132
|
+
REPLACES: ValueError for parameter validation
|
|
133
|
+
|
|
134
|
+
Examples:
|
|
135
|
+
raise ParameterError("n_clusters must be > 0")
|
|
136
|
+
raise ParameterError("method must be one of: 'leiden', 'louvain'")
|
|
137
|
+
raise ParameterError("Cannot use both 'n_clusters' and 'resolution'")
|
|
138
|
+
raise ParameterError("species parameter is required")
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# =============================================================================
|
|
143
|
+
# Processing Errors - Algorithm or computation failures
|
|
144
|
+
# =============================================================================
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ProcessingError(ChatSpatialError):
|
|
148
|
+
"""Errors during analysis processing or computation.
|
|
149
|
+
|
|
150
|
+
Use when:
|
|
151
|
+
- Algorithm fails to converge
|
|
152
|
+
- Numerical computation errors
|
|
153
|
+
- Model training failures
|
|
154
|
+
- Unexpected processing failures
|
|
155
|
+
|
|
156
|
+
REPLACES: RuntimeError for processing failures
|
|
157
|
+
|
|
158
|
+
Examples:
|
|
159
|
+
raise ProcessingError("Leiden clustering failed to converge")
|
|
160
|
+
raise ProcessingError(f"Model training failed: {e}") from e
|
|
161
|
+
raise ProcessingError("PCA computation failed: matrix is singular")
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# =============================================================================
|
|
166
|
+
# Dependency Errors - Missing packages or environment issues
|
|
167
|
+
# =============================================================================
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class DependencyError(ChatSpatialError):
|
|
171
|
+
"""Missing or incompatible dependency.
|
|
172
|
+
|
|
173
|
+
Use when:
|
|
174
|
+
- Required Python package not installed
|
|
175
|
+
- R environment not configured
|
|
176
|
+
- R package missing
|
|
177
|
+
- Version incompatibility
|
|
178
|
+
|
|
179
|
+
REPLACES: ImportError for optional dependencies
|
|
180
|
+
|
|
181
|
+
Examples:
|
|
182
|
+
raise DependencyError("scvi-tools required: pip install scvi-tools")
|
|
183
|
+
raise DependencyError("R environment not configured")
|
|
184
|
+
raise DependencyError("CellChat R package not installed")
|
|
185
|
+
"""
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Image utilities for spatial transcriptomics MCP.
|
|
3
|
+
|
|
4
|
+
This module provides standardized functions for handling images in the MCP.
|
|
5
|
+
All functions return Image objects that can be directly used in MCP tools.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import io
|
|
10
|
+
import uuid
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
13
|
+
from collections.abc import Generator
|
|
14
|
+
|
|
15
|
+
from mcp.types import ImageContent
|
|
16
|
+
|
|
17
|
+
from .exceptions import ProcessingError
|
|
18
|
+
from .path_utils import get_safe_output_path
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from ..spatial_mcp_adapter import ToolContext
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# =============================================================================
|
|
25
|
+
# Matplotlib Backend Management
|
|
26
|
+
# =============================================================================
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _ensure_non_interactive_backend() -> None:
|
|
30
|
+
"""Ensure matplotlib uses non-interactive backend to prevent GUI popups on macOS."""
|
|
31
|
+
import matplotlib
|
|
32
|
+
|
|
33
|
+
current_backend = matplotlib.get_backend()
|
|
34
|
+
if current_backend != "Agg":
|
|
35
|
+
matplotlib.use("Agg")
|
|
36
|
+
import matplotlib.pyplot as plt
|
|
37
|
+
|
|
38
|
+
plt.ioff()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@contextmanager
|
|
42
|
+
def non_interactive_backend() -> Generator[None, None, None]:
|
|
43
|
+
"""Context manager for temporary non-interactive matplotlib backend.
|
|
44
|
+
|
|
45
|
+
Use this when calling external plotting functions (e.g., cellrank, scvelo)
|
|
46
|
+
that may trigger interactive backend behavior. The original backend is
|
|
47
|
+
restored after the context exits.
|
|
48
|
+
|
|
49
|
+
For internal plotting code that doesn't need backend restoration,
|
|
50
|
+
use _ensure_non_interactive_backend() instead.
|
|
51
|
+
|
|
52
|
+
Usage:
|
|
53
|
+
with non_interactive_backend():
|
|
54
|
+
cr.pl.circular_projection(adata, ...)
|
|
55
|
+
fig = plt.gcf()
|
|
56
|
+
|
|
57
|
+
Yields:
|
|
58
|
+
None
|
|
59
|
+
"""
|
|
60
|
+
import matplotlib
|
|
61
|
+
|
|
62
|
+
original_backend = matplotlib.get_backend()
|
|
63
|
+
needs_restore = original_backend != "Agg"
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
if needs_restore:
|
|
67
|
+
matplotlib.use("Agg")
|
|
68
|
+
import matplotlib.pyplot as plt
|
|
69
|
+
|
|
70
|
+
plt.ioff()
|
|
71
|
+
yield
|
|
72
|
+
finally:
|
|
73
|
+
if needs_restore:
|
|
74
|
+
matplotlib.use(original_backend)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if TYPE_CHECKING:
|
|
78
|
+
import matplotlib.pyplot as plt
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Standard savefig parameters for consistent figure output
|
|
82
|
+
SAVEFIG_PARAMS: dict[str, Any] = {
|
|
83
|
+
"bbox_inches": "tight",
|
|
84
|
+
"transparent": False,
|
|
85
|
+
"facecolor": "white",
|
|
86
|
+
"edgecolor": "none",
|
|
87
|
+
"pad_inches": 0.1,
|
|
88
|
+
"metadata": {"Software": "spatial-transcriptomics-mcp"},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def bytes_to_image_content(data: bytes, format: str = "png") -> ImageContent:
|
|
93
|
+
"""Convert raw image bytes to MCP ImageContent.
|
|
94
|
+
|
|
95
|
+
This unified utility function handles the conversion from raw image bytes
|
|
96
|
+
to the MCP-compatible ImageContent type with proper MIME type mapping.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
data: Raw image bytes
|
|
100
|
+
format: Image format (png, jpg, jpeg, gif, webp)
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
ImageContent object ready for MCP tool return
|
|
104
|
+
|
|
105
|
+
Examples:
|
|
106
|
+
>>> img_bytes = fig.savefig(buf, format='png')
|
|
107
|
+
>>> content = bytes_to_image_content(img_bytes, format='png')
|
|
108
|
+
"""
|
|
109
|
+
# MIME type mapping for common image formats
|
|
110
|
+
format_to_mime = {
|
|
111
|
+
"png": "image/png",
|
|
112
|
+
"jpg": "image/jpeg",
|
|
113
|
+
"jpeg": "image/jpeg",
|
|
114
|
+
"gif": "image/gif",
|
|
115
|
+
"webp": "image/webp",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# Get MIME type, default to PNG if format is unknown
|
|
119
|
+
mime_type = format_to_mime.get(format.lower(), "image/png")
|
|
120
|
+
|
|
121
|
+
# Encode to base64 string as required by ImageContent
|
|
122
|
+
encoded_data = base64.b64encode(data).decode("utf-8")
|
|
123
|
+
|
|
124
|
+
return ImageContent(type="image", data=encoded_data, mimeType=mime_type)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def fig_to_image(
|
|
128
|
+
fig: "plt.Figure",
|
|
129
|
+
dpi: int = 100,
|
|
130
|
+
format: str = "png",
|
|
131
|
+
close_fig: bool = True,
|
|
132
|
+
) -> ImageContent:
|
|
133
|
+
"""Convert matplotlib figure to ImageContent
|
|
134
|
+
|
|
135
|
+
This function respects user's DPI and format settings without any
|
|
136
|
+
automatic compression or quality reduction. Large images are handled
|
|
137
|
+
by optimize_fig_to_image_with_cache which saves them to disk.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
fig: Matplotlib figure
|
|
141
|
+
dpi: Resolution in dots per inch (user's setting is always respected)
|
|
142
|
+
format: Image format (png or jpg)
|
|
143
|
+
close_fig: Whether to close the figure after conversion
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
ImageContent object ready for MCP tool return
|
|
147
|
+
"""
|
|
148
|
+
_ensure_non_interactive_backend() # Prevent GUI popups on macOS
|
|
149
|
+
import matplotlib.pyplot as plt
|
|
150
|
+
|
|
151
|
+
buf = io.BytesIO()
|
|
152
|
+
|
|
153
|
+
# Save figure with user's exact settings - no compromise
|
|
154
|
+
# Check for extra artists (e.g., legends positioned outside plot area)
|
|
155
|
+
extra_artists = getattr(fig, "_chatspatial_extra_artists", None)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
if format == "jpg":
|
|
159
|
+
try:
|
|
160
|
+
# Try with quality parameter first (newer matplotlib)
|
|
161
|
+
fig.savefig(
|
|
162
|
+
buf,
|
|
163
|
+
format=format,
|
|
164
|
+
dpi=dpi,
|
|
165
|
+
bbox_extra_artists=extra_artists,
|
|
166
|
+
quality=85,
|
|
167
|
+
**SAVEFIG_PARAMS,
|
|
168
|
+
)
|
|
169
|
+
except TypeError:
|
|
170
|
+
# Fallback for older matplotlib without quality parameter
|
|
171
|
+
fig.savefig(
|
|
172
|
+
buf,
|
|
173
|
+
format=format,
|
|
174
|
+
dpi=dpi,
|
|
175
|
+
bbox_extra_artists=extra_artists,
|
|
176
|
+
**SAVEFIG_PARAMS,
|
|
177
|
+
)
|
|
178
|
+
else: # PNG
|
|
179
|
+
fig.savefig(
|
|
180
|
+
buf,
|
|
181
|
+
format=format,
|
|
182
|
+
dpi=dpi,
|
|
183
|
+
bbox_extra_artists=extra_artists,
|
|
184
|
+
**SAVEFIG_PARAMS,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
buf.seek(0)
|
|
188
|
+
img_data = buf.read()
|
|
189
|
+
|
|
190
|
+
if close_fig:
|
|
191
|
+
plt.close(fig)
|
|
192
|
+
|
|
193
|
+
# Convert to ImageContent using unified utility
|
|
194
|
+
return bytes_to_image_content(img_data, format=format)
|
|
195
|
+
|
|
196
|
+
except Exception as e:
|
|
197
|
+
if close_fig:
|
|
198
|
+
plt.close(fig)
|
|
199
|
+
raise ProcessingError(f"Failed to convert figure to image: {e}") from e
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ============ Token Optimization and Publication Export Support ============
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def optimize_fig_to_image_with_cache(
|
|
206
|
+
fig: "plt.Figure",
|
|
207
|
+
params: Any,
|
|
208
|
+
ctx: Optional["ToolContext"] = None,
|
|
209
|
+
data_id: Optional[str] = None,
|
|
210
|
+
plot_type: Optional[str] = None,
|
|
211
|
+
mode: str = "auto", # Kept for API compatibility, ignored
|
|
212
|
+
) -> str:
|
|
213
|
+
"""Save figure to file and return path for MCP protocol efficiency.
|
|
214
|
+
|
|
215
|
+
MCP protocol has ~3.3x overhead for embedded ImageContent, so we always
|
|
216
|
+
save to file and return path as text (following MCP best practice:
|
|
217
|
+
"Prefer using URIs over embedded content for large files").
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
fig: Matplotlib figure
|
|
221
|
+
params: Visualization parameters (used for DPI extraction)
|
|
222
|
+
ctx: ToolContext (unused, kept for API compatibility)
|
|
223
|
+
data_id: Dataset ID (unused, kept for API compatibility)
|
|
224
|
+
plot_type: Plot type (used for filename generation)
|
|
225
|
+
mode: Ignored (always saves to file)
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
str with file path (FastMCP auto-converts to TextContent)
|
|
229
|
+
"""
|
|
230
|
+
_ensure_non_interactive_backend()
|
|
231
|
+
import matplotlib.pyplot as plt
|
|
232
|
+
|
|
233
|
+
target_dpi = params.dpi if hasattr(params, "dpi") and params.dpi else 100
|
|
234
|
+
extra_artists = getattr(fig, "_chatspatial_extra_artists", None)
|
|
235
|
+
|
|
236
|
+
# Generate image
|
|
237
|
+
buf = io.BytesIO()
|
|
238
|
+
fig.savefig(
|
|
239
|
+
buf,
|
|
240
|
+
format="png",
|
|
241
|
+
dpi=target_dpi,
|
|
242
|
+
bbox_extra_artists=extra_artists,
|
|
243
|
+
**SAVEFIG_PARAMS,
|
|
244
|
+
)
|
|
245
|
+
actual_size = buf.tell()
|
|
246
|
+
|
|
247
|
+
# Save to file (use centralized path utility for consistent output handling)
|
|
248
|
+
output_dir = get_safe_output_path("./visualizations")
|
|
249
|
+
filename = (
|
|
250
|
+
f"{plot_type}_{uuid.uuid4().hex[:8]}.png"
|
|
251
|
+
if plot_type
|
|
252
|
+
else f"viz_{uuid.uuid4().hex[:8]}.png"
|
|
253
|
+
)
|
|
254
|
+
filepath = output_dir / filename
|
|
255
|
+
|
|
256
|
+
buf.seek(0)
|
|
257
|
+
with open(filepath, "wb") as f:
|
|
258
|
+
f.write(buf.read())
|
|
259
|
+
buf.close()
|
|
260
|
+
plt.close(fig)
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
f"Visualization saved: {filepath}\n"
|
|
264
|
+
f"Type: {plot_type or 'visualization'}\n"
|
|
265
|
+
f"Size: {actual_size // 1024}KB\n"
|
|
266
|
+
f"Resolution: {target_dpi} DPI"
|
|
267
|
+
)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP utilities for ChatSpatial.
|
|
3
|
+
|
|
4
|
+
Tools for MCP server: error handling decorator and output suppression.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
import logging
|
|
9
|
+
import traceback
|
|
10
|
+
import warnings
|
|
11
|
+
from contextlib import contextmanager, redirect_stderr, redirect_stdout
|
|
12
|
+
from functools import wraps
|
|
13
|
+
from typing import get_type_hints
|
|
14
|
+
|
|
15
|
+
# =============================================================================
|
|
16
|
+
# Output Suppression
|
|
17
|
+
# =============================================================================
|
|
18
|
+
@contextmanager
|
|
19
|
+
def suppress_output():
|
|
20
|
+
"""
|
|
21
|
+
Context manager to suppress stdout, stderr, warnings, and logging.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
with suppress_output():
|
|
25
|
+
noisy_function()
|
|
26
|
+
"""
|
|
27
|
+
old_level = logging.getLogger().level
|
|
28
|
+
logging.getLogger().setLevel(logging.ERROR)
|
|
29
|
+
|
|
30
|
+
with warnings.catch_warnings():
|
|
31
|
+
warnings.simplefilter("ignore")
|
|
32
|
+
stdout_buffer = io.StringIO()
|
|
33
|
+
stderr_buffer = io.StringIO()
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
with redirect_stdout(stdout_buffer), redirect_stderr(stderr_buffer):
|
|
37
|
+
yield
|
|
38
|
+
finally:
|
|
39
|
+
logging.getLogger().setLevel(old_level)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# =============================================================================
|
|
43
|
+
# MCP Tool Error Handler
|
|
44
|
+
# =============================================================================
|
|
45
|
+
def _get_return_type_category(func) -> str:
|
|
46
|
+
"""
|
|
47
|
+
Determine return type category using proper type inspection.
|
|
48
|
+
|
|
49
|
+
Returns one of: "image", "basemodel", "str", "simple", "unknown"
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
from typing import Union, get_args, get_origin
|
|
53
|
+
|
|
54
|
+
from mcp.types import ImageContent
|
|
55
|
+
from pydantic import BaseModel
|
|
56
|
+
|
|
57
|
+
hints = get_type_hints(func)
|
|
58
|
+
return_type = hints.get("return")
|
|
59
|
+
|
|
60
|
+
if return_type is None:
|
|
61
|
+
return "unknown"
|
|
62
|
+
|
|
63
|
+
# Handle Union types (including Optional which is Union[X, None])
|
|
64
|
+
origin = get_origin(return_type)
|
|
65
|
+
if origin is Union:
|
|
66
|
+
types = [t for t in get_args(return_type) if t is not type(None)]
|
|
67
|
+
else:
|
|
68
|
+
types = [return_type]
|
|
69
|
+
|
|
70
|
+
# Check ImageContent first (it's a BaseModel subclass, so order matters)
|
|
71
|
+
for t in types:
|
|
72
|
+
if isinstance(t, type) and issubclass(t, ImageContent):
|
|
73
|
+
return "image"
|
|
74
|
+
|
|
75
|
+
# Check for BaseModel subclasses
|
|
76
|
+
for t in types:
|
|
77
|
+
if isinstance(t, type) and issubclass(t, BaseModel):
|
|
78
|
+
return "basemodel"
|
|
79
|
+
|
|
80
|
+
# Check for str
|
|
81
|
+
if str in types:
|
|
82
|
+
return "str"
|
|
83
|
+
|
|
84
|
+
return "simple"
|
|
85
|
+
|
|
86
|
+
except Exception:
|
|
87
|
+
return "unknown"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def mcp_tool_error_handler(include_traceback: bool = True):
|
|
91
|
+
"""
|
|
92
|
+
Decorator for MCP tools to handle errors gracefully.
|
|
93
|
+
|
|
94
|
+
Errors are returned in the result object (not raised as exceptions),
|
|
95
|
+
allowing LLMs to see and potentially handle them.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def decorator(func):
|
|
99
|
+
return_type_category = _get_return_type_category(func)
|
|
100
|
+
|
|
101
|
+
@wraps(func)
|
|
102
|
+
async def wrapper(*args, **kwargs):
|
|
103
|
+
try:
|
|
104
|
+
return await func(*args, **kwargs)
|
|
105
|
+
|
|
106
|
+
except Exception as e:
|
|
107
|
+
error_msg = str(e)
|
|
108
|
+
|
|
109
|
+
if return_type_category == "image":
|
|
110
|
+
# Return error as text (Union[ImageContent, str] allows this)
|
|
111
|
+
msg = f"Visualization Error: {error_msg}"
|
|
112
|
+
if include_traceback and not isinstance(e, (ValueError, KeyError)):
|
|
113
|
+
msg += f"\n\nDetails:\n{traceback.format_exc()}"
|
|
114
|
+
return msg
|
|
115
|
+
|
|
116
|
+
elif return_type_category == "basemodel":
|
|
117
|
+
# Re-raise for FastMCP to handle
|
|
118
|
+
raise
|
|
119
|
+
|
|
120
|
+
elif return_type_category == "str":
|
|
121
|
+
return f"Error: {error_msg}"
|
|
122
|
+
|
|
123
|
+
else:
|
|
124
|
+
# Return error dict for simple types
|
|
125
|
+
content = [{"type": "text", "text": f"Error: {error_msg}"}]
|
|
126
|
+
if include_traceback and not isinstance(e, ValueError):
|
|
127
|
+
content.append(
|
|
128
|
+
{
|
|
129
|
+
"type": "text",
|
|
130
|
+
"text": f"Traceback:\n{traceback.format_exc()}",
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
return {"content": content, "isError": True}
|
|
134
|
+
|
|
135
|
+
return wrapper
|
|
136
|
+
|
|
137
|
+
return decorator
|