scmcp-shared 0.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.
@@ -0,0 +1,131 @@
1
+
2
+ from pydantic import (
3
+ BaseModel,
4
+ Field,
5
+ ValidationInfo,
6
+ computed_field,
7
+ field_validator,
8
+ model_validator,
9
+ )
10
+ from typing import Optional, Union, List, Dict, Any, Callable, Collection, Literal
11
+
12
+
13
+
14
+ class MarkVarModel(BaseModel):
15
+ """Determine or mark if each gene meets specific conditions and store results in adata.var as boolean values"""
16
+
17
+ var_name: str = Field(
18
+ default=None,
19
+ description="Column name that will be added to adata.var, do not set if user does not ask"
20
+ )
21
+ pattern_type: Optional[Literal["startswith", "endswith", "contains"]] = Field(
22
+ default=None,
23
+ description="Pattern matching type (startswith/endswith/contains), it should be None when gene_class is not None"
24
+ )
25
+ patterns: Optional[str] = Field(
26
+ default=None,
27
+ description="gene pattern to match, must be a string, it should be None when gene_class is not None"
28
+ )
29
+
30
+ gene_class: Optional[Literal["mitochondrion", "ribosomal", "hemoglobin"]] = Field(
31
+ default=None,
32
+ description="Gene class type (Mitochondrion/Ribosomal/Hemoglobin)"
33
+ )
34
+
35
+
36
+ class ListVarModel(BaseModel):
37
+ """ListVarModel"""
38
+ pass
39
+
40
+ class ListObsModel(BaseModel):
41
+ """ListObsModel"""
42
+ pass
43
+
44
+ class VarNamesModel(BaseModel):
45
+ """ListObsModel"""
46
+ var_names: List[str] = Field(
47
+ default=None,
48
+ description="gene names."
49
+ )
50
+
51
+
52
+ class ConcatAdataModel(BaseModel):
53
+ """Model for concatenating AnnData objects"""
54
+
55
+ axis: Literal['obs', 0, 'var', 1] = Field(
56
+ default='obs',
57
+ description="Which axis to concatenate along. 'obs' or 0 for observations, 'var' or 1 for variables."
58
+ )
59
+ join: Literal['inner', 'outer'] = Field(
60
+ default='inner',
61
+ description="How to align values when concatenating. If 'outer', the union of the other axis is taken. If 'inner', the intersection."
62
+ )
63
+ merge: Optional[Literal['same', 'unique', 'first', 'only']] = Field(
64
+ default=None,
65
+ description="How elements not aligned to the axis being concatenated along are selected."
66
+ )
67
+ uns_merge: Optional[Literal['same', 'unique', 'first', 'only']] = Field(
68
+ default=None,
69
+ description="How the elements of .uns are selected. Uses the same set of strategies as the merge argument, except applied recursively."
70
+ )
71
+ label: Optional[str] = Field(
72
+ default=None,
73
+ description="label different adata, Column in axis annotation (i.e. .obs or .var) to place batch information in. "
74
+ )
75
+ keys: Optional[List[str]] = Field(
76
+ default=None,
77
+ description="Names for each object being added. These values are used for column values for label or appended to the index if index_unique is not None."
78
+ )
79
+ index_unique: Optional[str] = Field(
80
+ default=None,
81
+ description="Whether to make the index unique by using the keys. If provided, this is the delimiter between '{orig_idx}{index_unique}{key}'."
82
+ )
83
+ fill_value: Optional[Any] = Field(
84
+ default=None,
85
+ description="When join='outer', this is the value that will be used to fill the introduced indices."
86
+ )
87
+ pairwise: bool = Field(
88
+ default=False,
89
+ description="Whether pairwise elements along the concatenated dimension should be included."
90
+ )
91
+
92
+
93
+ class DPTIROOTModel(BaseModel):
94
+ """Input schema for setting the root cell for diffusion pseudotime."""
95
+ diffmap_key: str = Field(
96
+ default="X_diffmap",
97
+ description="Key for diffusion map coordinates stored in adata.obsm."
98
+ )
99
+ dimension: int = Field(
100
+ description="Dimension index to use for finding the root cell."
101
+ )
102
+ direction: Literal["min", "max"] = Field(
103
+ description="use the minimum or maximum value along the selected dimension to identify the root cell."
104
+ )
105
+
106
+
107
+ class CelltypeMapCellTypeModel(BaseModel):
108
+ """Input schema for mapping cluster IDs to cell type names."""
109
+ cluster_key: str = Field(
110
+ description="Key in adata.obs containing cluster IDs."
111
+ )
112
+ added_key: str = Field(
113
+ description="Key to add to adata.obs for cell type names."
114
+ )
115
+ mapping: Dict[str, str] = Field(
116
+ default=None,
117
+ description="Mapping Dictionary from cluster IDs to cell type names."
118
+ )
119
+ new_names: Optional[List[str]] = Field(
120
+ default=None,
121
+ description="a list of new cell type names."
122
+ )
123
+
124
+
125
+
126
+ class AddLayerModel(BaseModel):
127
+ """Input schema for adding a layer to AnnData object."""
128
+ layer_name: str = Field(
129
+ description="Name of the layer to add to adata.layers."
130
+ )
131
+
@@ -0,0 +1 @@
1
+ from .io import io_mcp
@@ -0,0 +1,80 @@
1
+ import os
2
+ import inspect
3
+ from pathlib import Path
4
+ import scanpy as sc
5
+ from fastmcp import FastMCP , Context
6
+ from ..schema.io import *
7
+ from ..util import filter_args, forward_request
8
+
9
+
10
+ io_mcp = FastMCP("SCMCP-IO-Server")
11
+
12
+
13
+ @io_mcp.tool()
14
+ async def read(
15
+ request: ReadModel,
16
+ ctx: Context,
17
+ sampleid: str = Field(default=None, description="adata sampleid"),
18
+ dtype: str = Field(default="exp", description="adata.X data type")
19
+ ):
20
+ """
21
+ Read data from various file formats (h5ad, 10x, text files, etc.) or directory path.
22
+ """
23
+ try:
24
+ result = await forward_request("io_read", request, sampleid=sampleid, dtype=dtype)
25
+ if result is not None:
26
+ return result
27
+ kwargs = request.model_dump()
28
+ ads = ctx.request_context.lifespan_context
29
+ if sampleid is not None:
30
+ ads.active_id = sampleid
31
+ else:
32
+ ads.active_id = f"adata{len(ads.adata_dic[dtype])}"
33
+
34
+ file = Path(kwargs.get("filename", None))
35
+ if file.is_dir():
36
+ kwargs["path"] = kwargs["filename"]
37
+ func_kwargs = filter_args(request, sc.read_10x_mtx)
38
+ adata = sc.read_10x_mtx(kwargs["path"], **func_kwargs)
39
+ elif file.is_file():
40
+ func_kwargs = filter_args(request, sc.read)
41
+ adata = sc.read(**func_kwargs)
42
+ if not kwargs.get("first_column_obs", True):
43
+ adata = adata.T
44
+ else:
45
+ raise FileNotFoundError(f"{kwargs['filename']} does not exist")
46
+ adata.layers["counts"] = adata.X
47
+ adata.var_names_make_unique()
48
+ adata.obs_names_make_unique()
49
+ ads.set_adata(adata, sampleid=sampleid, sdtype=dtype)
50
+ return {"sampleid": sampleid or ads.active_id, "dtype": dtype, "adata": adata}
51
+ except Exception as e:
52
+ if hasattr(e, '__context__') and e.__context__:
53
+ raise Exception(f"{str(e.__context__)}")
54
+ else:
55
+ raise e
56
+
57
+
58
+ @io_mcp.tool()
59
+ async def write(
60
+ request: WriteModel,
61
+ ctx: Context,
62
+ sampleid: str = Field(default=None, description="adata sampleid"),
63
+ dtype: str = Field(default="exp", description="adata.X data type")
64
+ ):
65
+ """save adata into a file.
66
+ """
67
+ try:
68
+ result = await forward_request("io_write", request, sampleid=sampleid, dtype=dtype)
69
+ if result is not None:
70
+ return result
71
+ ads = ctx.request_context.lifespan_context
72
+ adata = ads.get_adata(sampleid=sampleid, dtype=dtype)
73
+ kwargs = request.model_dump()
74
+ sc.write(kwargs["filename"], adata)
75
+ return {"filename": kwargs["filename"], "msg": "success to save file"}
76
+ except Exception as e:
77
+ if hasattr(e, '__context__') and e.__context__:
78
+ raise Exception(f"{str(e.__context__)}")
79
+ else:
80
+ raise e
scmcp_shared/util.py ADDED
@@ -0,0 +1,186 @@
1
+ import inspect
2
+ import os
3
+ from pathlib import Path
4
+
5
+
6
+
7
+ def get_env(key):
8
+ return os.environ.get(f"SCMCP_{key.upper()}")
9
+
10
+
11
+ def filter_args(request, func):
12
+ # sometime,it is a bit redundant, but I think it adds robustness in case the function parameters change
13
+ kwargs = request.model_dump()
14
+ args = request.model_fields_set
15
+ parameters = inspect.signature(func).parameters
16
+ func_kwargs = {k: kwargs.get(k) for k in args if k in parameters}
17
+ return func_kwargs
18
+
19
+
20
+ def add_op_log(adata, func, kwargs):
21
+ import hashlib
22
+ import json
23
+
24
+ if "operation" not in adata.uns:
25
+ adata.uns["operation"] = {}
26
+ adata.uns["operation"]["op"] = {}
27
+ adata.uns["operation"]["opid"] = []
28
+ # Handle different function types to get the function name
29
+ if hasattr(func, "func") and hasattr(func.func, "__name__"):
30
+ # For partial functions, use the original function name
31
+ func_name = func.func.__name__
32
+ elif hasattr(func, "__name__"):
33
+ func_name = func.__name__
34
+ elif hasattr(func, "__class__"):
35
+ func_name = func.__class__.__name__
36
+ else:
37
+ func_name = str(func)
38
+ new_kwargs = {}
39
+ for k,v in kwargs.items():
40
+ if isinstance(v, tuple):
41
+ new_kwargs[k] = list(v)
42
+ else:
43
+ new_kwargs[k] = v
44
+ try:
45
+ kwargs_str = json.dumps(new_kwargs, sort_keys=True)
46
+ except:
47
+ kwargs_str = str(new_kwargs)
48
+ hash_input = f"{func_name}:{kwargs_str}"
49
+ hash_key = hashlib.md5(hash_input.encode()).hexdigest()
50
+ adata.uns["operation"]["op"][hash_key] = {func_name: new_kwargs}
51
+ adata.uns["operation"]["opid"].append(hash_key)
52
+ from .logging_config import setup_logger
53
+ logger = setup_logger(log_file=get_env("LOG_FILE"))
54
+ logger.info(f"{func}: {new_kwargs}")
55
+
56
+
57
+
58
+ def savefig(fig, file):
59
+ try:
60
+ file_path = Path(file)
61
+ file_path.parent.mkdir(parents=True, exist_ok=True)
62
+ if hasattr(fig, 'figure'): # if Axes
63
+ fig.figure.savefig(file_path)
64
+ elif hasattr(fig, 'save'): # for plotnine.ggplot.ggplot
65
+ fig.save(file_path)
66
+ else: # if Figure
67
+ fig.savefig(file_path)
68
+ return file_path
69
+ except Exception as e:
70
+ raise e
71
+
72
+
73
+ def set_fig_path(func, fig=None, **kwargs):
74
+ "maybe I need to save figure by myself, instead of using scanpy save function..."
75
+ fig_dir = Path(os.getcwd()) / "figures"
76
+
77
+ kwargs.pop("save", None)
78
+ kwargs.pop("show", None)
79
+ args = []
80
+ for k,v in kwargs.items():
81
+ if isinstance(v, (tuple, list, set)):
82
+ args.append(f"{k}-{'-'.join([str(i) for i in v])}")
83
+ else:
84
+ args.append(f"{k}-{v}")
85
+ args_str = "_".join(args)
86
+ if func == "rank_genes_groups_dotplot":
87
+ old_path = fig_dir / 'dotplot_.png'
88
+ fig_path = fig_dir / f"{func}_{args_str}.png"
89
+ elif func in ["scatter", "embedding"]:
90
+ if "basis" in kwargs and kwargs['basis'] is not None:
91
+ old_path = fig_dir / f"{kwargs['basis']}.png"
92
+ fig_path = fig_dir / f"{func}_{args_str}.png"
93
+ else:
94
+ old_path = fig_dir / f"{func}.png"
95
+ fig_path = fig_dir / f"{func}_{args_str}.png"
96
+ elif func == "highly_variable_genes":
97
+ old_path = fig_dir / 'filter_genes_dispersion.png'
98
+ fig_path = fig_dir / f"{func}_{args_str}.png"
99
+ elif func == "scvelo_projection":
100
+ old_path = fig_dir / f"scvelo_{kwargs['kernel']}.png"
101
+ fig_path = fig_dir / f"{func}_{args_str}.png"
102
+ else:
103
+ if (fig_dir / f"{func}_.png").is_file():
104
+ old_path = fig_dir / f"{func}_.png"
105
+ else:
106
+ old_path = fig_dir / f"{func}.png"
107
+ fig_path = fig_dir / f"{func}_{args_str}.png"
108
+ try:
109
+ if fig is not None:
110
+ savefig(fig, fig_path)
111
+ else:
112
+ os.rename(old_path, fig_path)
113
+ return fig_path
114
+ except FileNotFoundError:
115
+ print(f"The file {old_path} does not exist")
116
+ except FileExistsError:
117
+ print(f"The file {fig_path} already exists")
118
+ except PermissionError:
119
+ print("You don't have permission to rename this file")
120
+ transport = get_env("TRANSPORT")
121
+ if transport == "stdio":
122
+ return fig_path
123
+ else:
124
+ host = get_env("HOST")
125
+ port = get_env("PORT")
126
+ fig_path = f"http://{host}:{port}/figures/{Path(fig_path).name}"
127
+ return fig_path
128
+
129
+
130
+
131
+ async def get_figure(request):
132
+ from starlette.responses import FileResponse, Response
133
+
134
+ figure_name = request.path_params["figure_name"]
135
+ figure_path = f"./figures/{figure_name}"
136
+
137
+ # 检查文件是否存在
138
+ if not os.path.isfile(figure_path):
139
+ return Response(content={"error": "figure not found"}, media_type="application/json")
140
+
141
+ return FileResponse(figure_path)
142
+
143
+
144
+ async def forward_request(func, request, **kwargs):
145
+ from fastmcp import Client
146
+ forward_url = get_env("FORWARD")
147
+ request_kwargs = request.model_dump()
148
+ request_args = request.model_fields_set
149
+ func_kwargs = {"request": {k: request_kwargs.get(k) for k in request_args}}
150
+ func_kwargs.update({k:v for k,v in kwargs.items() if v is not None})
151
+ if not forward_url:
152
+ return None
153
+
154
+ client = Client(forward_url)
155
+ async with client:
156
+ tools = await client.list_tools()
157
+ func = [t.name for t in tools if t.name.endswith(func)][0]
158
+ try:
159
+ result = await client.call_tool(func, func_kwargs)
160
+ return result
161
+ except Exception as e:
162
+ raise e
163
+
164
+ def obsm2adata(adata, obsm_key):
165
+ from anndata import AnnData
166
+
167
+ if obsm_key not in adata.obsm_keys():
168
+ raise ValueError(f"key {obsm_key} not found in adata.obsm")
169
+ else:
170
+ return AnnData(adata.obsm[obsm_key], obs=adata.obs, obsm=adata.obsm)
171
+
172
+
173
+ async def get_figure(request):
174
+ figure_name = request.path_params["figure_name"]
175
+ figure_path = f"./figures/{figure_name}"
176
+
177
+ # 检查文件是否存在
178
+ if not os.path.isfile(figure_path):
179
+ return Response(content={"error": "figure not found"}, media_type="application/json")
180
+
181
+ return FileResponse(figure_path)
182
+
183
+
184
+ def add_figure_route(server):
185
+ from starlette.routing import Route
186
+ server._additional_http_routes = [Route("/figures/{figure_name}", endpoint=get_figure)]
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.4
2
+ Name: scmcp_shared
3
+ Version: 0.1.0
4
+ Summary: A shared function libray for scmcphub
5
+ Author-email: shuang <hsh-me@outlook.com>
6
+ License: BSD 3-Clause License
7
+
8
+ Copyright (c) 2025, scmcphub
9
+
10
+ Redistribution and use in source and binary forms, with or without
11
+ modification, are permitted provided that the following conditions are met:
12
+
13
+ 1. Redistributions of source code must retain the above copyright notice, this
14
+ list of conditions and the following disclaimer.
15
+
16
+ 2. Redistributions in binary form must reproduce the above copyright notice,
17
+ this list of conditions and the following disclaimer in the documentation
18
+ and/or other materials provided with the distribution.
19
+
20
+ 3. Neither the name of the copyright holder nor the names of its
21
+ contributors may be used to endorse or promote products derived from
22
+ this software without specific prior written permission.
23
+
24
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
25
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
26
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
27
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
28
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
29
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
30
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
31
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
32
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
33
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
34
+ License-File: LICENSE
35
+ Requires-Python: >=3.10
36
+ Requires-Dist: fastmcp>=2.3.0
37
+ Requires-Dist: mcp>=1.8.0
38
+ Requires-Dist: pydantic
39
+ Requires-Dist: scanpy
40
+ Description-Content-Type: text/markdown
41
+
42
+ # scmcp-shared
43
+
44
+ Shared functions for scmcphub
@@ -0,0 +1,15 @@
1
+ scmcp_shared/__init__.py,sha256=2Ff5FiaFaDmrGrcJagyK7JKuraTfI0CnRahAnXLS53w,24
2
+ scmcp_shared/logging_config.py,sha256=eCuLuyxMmbj8A1E0VqYWoKA5JPTSbo6cmjS4LOyd0RQ,872
3
+ scmcp_shared/util.py,sha256=jY-HbMzpqKQpzm0UhiyrUwtjtx31_KKUmewlyZYpfZA,6300
4
+ scmcp_shared/schema/__init__.py,sha256=uEIJaRLssrbI9KYOH3RxtMRU63Bes9SkLBskxNBt0hA,18
5
+ scmcp_shared/schema/io.py,sha256=V6Bv6No_FH0Ps2E6VkkHBu7z2EQku9XcG4iWkxMPjiU,4965
6
+ scmcp_shared/schema/pl.py,sha256=yP9XnotdEvY6iPKfvz0otfJFyDJh7FkaIJx7FG4Em44,29548
7
+ scmcp_shared/schema/pp.py,sha256=aAHECfM1LSCRDZkgtPpoMxLXurEyVdG_9dyhj0HSOlw,23507
8
+ scmcp_shared/schema/tl.py,sha256=VNoNBf7hFF_4SG31vNYX52i0M38Bj6ryBJpCJYtedVw,32848
9
+ scmcp_shared/schema/util.py,sha256=rMb29eCu2hVhkggYeOPA2b72Aa8VPDq58nd2Rz8OmoI,4652
10
+ scmcp_shared/server/__init__.py,sha256=V4eLId2ZvwoPTE_53uimF02hLnY8X9iXlA4ri-WfvjM,22
11
+ scmcp_shared/server/io.py,sha256=AQ90QtQdfv2IFo_IPU6dmfk1redF3HPt30DHHv5_3qo,2815
12
+ scmcp_shared-0.1.0.dist-info/METADATA,sha256=VpPvS7tbQQJLtsCNbCJfIAi-iiDf2mm9VCBdqaelcbg,2099
13
+ scmcp_shared-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
+ scmcp_shared-0.1.0.dist-info/licenses/LICENSE,sha256=YNr1hpea195yq-wGtB8j-2dGtt7A5G00WENmxa7JGco,1495
15
+ scmcp_shared-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2025, scmcphub
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.