cellpy-mcp 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.
- cellpy_mcp/__init__.py +83 -0
- cellpy_mcp/__main__.py +61 -0
- cellpy_mcp/api.py +406 -0
- cellpy_mcp/cells.py +325 -0
- cellpy_mcp/clients.py +108 -0
- cellpy_mcp/errors.py +30 -0
- cellpy_mcp/projects.py +117 -0
- cellpy_mcp/prompts.py +70 -0
- cellpy_mcp/sandbox.py +162 -0
- cellpy_mcp/server.py +56 -0
- cellpy_mcp/state.py +61 -0
- cellpy_mcp-0.1.0.dist-info/METADATA +159 -0
- cellpy_mcp-0.1.0.dist-info/RECORD +16 -0
- cellpy_mcp-0.1.0.dist-info/WHEEL +4 -0
- cellpy_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- cellpy_mcp-0.1.0.dist-info/licenses/LICENSE +9 -0
cellpy_mcp/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""An MCP server for cellpy — battery cell data, plots, and the cellpy API.
|
|
2
|
+
|
|
3
|
+
Four things a caller can do without writing Python: load cells and collect them
|
|
4
|
+
into frames; render figures and export data; ask what any cellpy call takes and
|
|
5
|
+
what its arguments mean; and set up a batch project from a template.
|
|
6
|
+
|
|
7
|
+
**This module is the contract `cellpy mcp` depends on.** cellpy ships a thin
|
|
8
|
+
command group (`cellpy mcp serve | install | status`) that imports this package
|
|
9
|
+
and, when it is absent, says how to install it. cellpy deliberately does not
|
|
10
|
+
depend on the MCP SDK — it is young and moving, and a long-lived network-facing
|
|
11
|
+
process is a security surface a data library should not carry — so the four
|
|
12
|
+
names below are load-bearing across a repository boundary. Changing them
|
|
13
|
+
without changing cellpy breaks the command:
|
|
14
|
+
|
|
15
|
+
__version__
|
|
16
|
+
serve(root=None)
|
|
17
|
+
install(root=None, client=None, dry_run=False) -> str
|
|
18
|
+
describe() -> dict
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
__all__ = ["__version__", "serve", "install", "describe", "build_server", "Sandbox"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_server(sandbox=None, state=None):
|
|
31
|
+
"""An `MCPServer` with every tool and prompt registered."""
|
|
32
|
+
from .server import build_server as _build
|
|
33
|
+
|
|
34
|
+
return _build(sandbox=sandbox, state=state)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def serve(root: str | Path | None = None) -> None:
|
|
38
|
+
"""Run the server over stdio. Blocks until the client disconnects.
|
|
39
|
+
|
|
40
|
+
Prints nothing: stdout *is* the protocol channel, and a friendly banner on
|
|
41
|
+
it is a parse error at the other end.
|
|
42
|
+
"""
|
|
43
|
+
from .sandbox import Sandbox
|
|
44
|
+
|
|
45
|
+
build_server(Sandbox.from_environment(root)).run(transport="stdio")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def install(
|
|
49
|
+
root: str | Path | None = None,
|
|
50
|
+
client: str | None = None,
|
|
51
|
+
dry_run: bool = False,
|
|
52
|
+
) -> str:
|
|
53
|
+
"""Register this server with a chat client; return the path written."""
|
|
54
|
+
from .clients import install as _install
|
|
55
|
+
from .sandbox import Sandbox
|
|
56
|
+
|
|
57
|
+
return _install(Sandbox.from_environment(root).roots, client=client, dry_run=dry_run)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def describe() -> dict:
|
|
61
|
+
"""What `cellpy mcp status` reports beyond the two version numbers."""
|
|
62
|
+
from .clients import config_path
|
|
63
|
+
from .sandbox import Sandbox
|
|
64
|
+
|
|
65
|
+
sandbox = Sandbox.from_environment()
|
|
66
|
+
described = {"roots": ", ".join(str(root) for root in sandbox.roots)}
|
|
67
|
+
try:
|
|
68
|
+
target = config_path()
|
|
69
|
+
except ValueError: # pragma: no cover - only if CLIENTS shrinks
|
|
70
|
+
return described
|
|
71
|
+
described["client config"] = f"{target}{'' if target.exists() else ' (not present)'}"
|
|
72
|
+
return described
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def __getattr__(name: str):
|
|
76
|
+
# `Sandbox` is re-exported for callers who want to build a server with an
|
|
77
|
+
# explicit set of roots, but importing it eagerly would drag cellpy's
|
|
78
|
+
# config in just because someone imported the package.
|
|
79
|
+
if name == "Sandbox":
|
|
80
|
+
from .sandbox import Sandbox
|
|
81
|
+
|
|
82
|
+
return Sandbox
|
|
83
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
cellpy_mcp/__main__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""`python -m cellpy_mcp` — what a chat client spawns.
|
|
2
|
+
|
|
3
|
+
`cellpy mcp serve` is the discoverable spelling and goes through cellpy's shim.
|
|
4
|
+
This is the same thing without it, and it is what `install` writes into a client
|
|
5
|
+
config: naming the interpreter and the module directly is one fewer layer to be
|
|
6
|
+
wrong about when a GUI launches it from nowhere in particular.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: list[str] | None = None) -> int:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="cellpy-mcp", description="MCP server for cellpy."
|
|
18
|
+
)
|
|
19
|
+
subcommands = parser.add_subparsers(dest="command")
|
|
20
|
+
|
|
21
|
+
serve = subcommands.add_parser("serve", help="run over stdio (the default)")
|
|
22
|
+
serve.add_argument("--root", help="a directory the server may read and write")
|
|
23
|
+
|
|
24
|
+
install = subcommands.add_parser("install", help="register with a chat client")
|
|
25
|
+
install.add_argument("--root", help="a directory the server may read and write")
|
|
26
|
+
install.add_argument("--client", help="which chat client to register with")
|
|
27
|
+
install.add_argument(
|
|
28
|
+
"--dry-run", action="store_true", help="print the target instead of writing"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
subcommands.add_parser("status", help="report the roots and the client config")
|
|
32
|
+
|
|
33
|
+
args = parser.parse_args(argv)
|
|
34
|
+
|
|
35
|
+
from . import describe, install as do_install, serve as do_serve
|
|
36
|
+
|
|
37
|
+
# No subcommand means serve: a client config that says `-m cellpy_mcp` and
|
|
38
|
+
# nothing else must start a server, not print usage to the protocol channel.
|
|
39
|
+
if args.command in (None, "serve"):
|
|
40
|
+
do_serve(root=getattr(args, "root", None))
|
|
41
|
+
return 0
|
|
42
|
+
|
|
43
|
+
if args.command == "install":
|
|
44
|
+
try:
|
|
45
|
+
target = do_install(root=args.root, client=args.client, dry_run=args.dry_run)
|
|
46
|
+
except ValueError as exc:
|
|
47
|
+
print(exc, file=sys.stderr)
|
|
48
|
+
return 1
|
|
49
|
+
verb = "would register" if args.dry_run else "registered"
|
|
50
|
+
print(f"{verb} 'cellpy' in {target}")
|
|
51
|
+
if not args.dry_run:
|
|
52
|
+
print("restart the client to pick it up.")
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
for key, value in describe().items():
|
|
56
|
+
print(f"{key}: {value}")
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
raise SystemExit(main())
|
cellpy_mcp/api.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Answering "how does this cellpy call work" from the installed package.
|
|
2
|
+
|
|
3
|
+
The observation this family exists for: people do not read API documentation.
|
|
4
|
+
No amount of rewriting fixes it, because the cost was never the reading — it is
|
|
5
|
+
knowing a page exists, finding it, and trusting that it describes the version
|
|
6
|
+
installed. A tool call removes all three.
|
|
7
|
+
|
|
8
|
+
What it cannot do is invent documentation that was never written. Measured over
|
|
9
|
+
the 44 calls in cellpy's own API reference against cellpy 2.1.3:
|
|
10
|
+
|
|
11
|
+
no docstring at all 3
|
|
12
|
+
one-line docstring 13
|
|
13
|
+
has an Args:/Parameters: section 14 of 44
|
|
14
|
+
parameters named in their own docstring 100 of 195 (51%)
|
|
15
|
+
|
|
16
|
+
`CellpyCell.get_cap` takes 23 arguments and documents none of them, so a tool
|
|
17
|
+
that returned the docstring and stopped would answer half its questions with a
|
|
18
|
+
sentence and a shrug — and, worse, let a model fill the silence. Three things
|
|
19
|
+
follow, and they are the design:
|
|
20
|
+
|
|
21
|
+
1. The *signature* is always there. Names, annotations and especially defaults
|
|
22
|
+
survive when prose does not, so they are returned structured rather than
|
|
23
|
+
rendered into one string.
|
|
24
|
+
2. `undocumented_parameters` is returned explicitly. Same principle as
|
|
25
|
+
`render`'s `trace_types`: the result carries the trap, so a model that would
|
|
26
|
+
otherwise guess at `categorical_column=` can see the package never said.
|
|
27
|
+
3. `include_source` exists because a model reads Python well. It is the honest
|
|
28
|
+
fallback for the thin half, and the one thing a chat user cannot do for
|
|
29
|
+
themselves — they will not be grepping site-packages.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import re
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from .sandbox import Refused
|
|
38
|
+
|
|
39
|
+
__all__ = ["register"]
|
|
40
|
+
|
|
41
|
+
#: Import is execution, and the caller is a language model acting on text it
|
|
42
|
+
#: may have read in a file. `describe_api("os.system")` must not become a way
|
|
43
|
+
#: to import arbitrary modules, so resolution is confined to these roots.
|
|
44
|
+
API_ROOTS = ("cellpy", "cellpycore")
|
|
45
|
+
|
|
46
|
+
#: Modules the index covers. Walking a package imports every submodule it finds
|
|
47
|
+
#: — including optional loaders whose third-party dependencies are absent — so
|
|
48
|
+
#: the list is written down rather than discovered.
|
|
49
|
+
API_MODULES = (
|
|
50
|
+
"cellpy",
|
|
51
|
+
"cellpy.collect",
|
|
52
|
+
"cellpy.config",
|
|
53
|
+
"cellpy.plotting",
|
|
54
|
+
"cellpy.plotting.registry",
|
|
55
|
+
"cellpy.readers.capacity_curves",
|
|
56
|
+
"cellpy.readers.cellreader",
|
|
57
|
+
"cellpy.exporters.tabular",
|
|
58
|
+
"cellpy.utils.batch",
|
|
59
|
+
"cellpy.utils.example_data",
|
|
60
|
+
"cellpy.utils.helpers",
|
|
61
|
+
"cellpy.utils.ica",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
#: Docstrings can be long (`Collection.plot` is 24 lines) and a tool result is
|
|
65
|
+
#: context. Long enough for an Args: section, short enough not to be the reply.
|
|
66
|
+
MAX_DOC_CHARS = 4000
|
|
67
|
+
MAX_SOURCE_LINES = 200
|
|
68
|
+
|
|
69
|
+
#: ``See :func:`cellpy.readers.capacity_curves.get_cap` `` — cellpy 2.1.3 and
|
|
70
|
+
#: earlier, and anything written to Sphinx conventions.
|
|
71
|
+
_ROLE_REFERENCE = re.compile(r":(?:func|meth|obj|class):`~?([\w.]+)`")
|
|
72
|
+
|
|
73
|
+
#: ``See `get_cap`.`` — what cellpy 2.1.3.post2 leaves behind. The negative
|
|
74
|
+
#: lookbehind keeps this from also matching the tail of a role reference.
|
|
75
|
+
_BARE_REFERENCE = re.compile(r"(?<![:`\w])`~?([\w.]+)`")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _is_class_member(path: str) -> bool:
|
|
79
|
+
"""True for `module.Class.method`, false for `module.function`.
|
|
80
|
+
|
|
81
|
+
cellpy has no capitalised module names, so a capitalised segment before the
|
|
82
|
+
last one means the entry hangs off a class.
|
|
83
|
+
"""
|
|
84
|
+
parts = path.split(".")
|
|
85
|
+
return len(parts) > 1 and parts[-2][:1].isupper()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def resolve_dotted(path: str) -> Any:
|
|
89
|
+
"""Import as far as the path allows, then getattr the rest — inside API_ROOTS."""
|
|
90
|
+
import importlib
|
|
91
|
+
|
|
92
|
+
if path.split(".")[0] not in API_ROOTS:
|
|
93
|
+
joined = " and ".join(API_ROOTS)
|
|
94
|
+
raise Refused(f"{path!r} is not part of cellpy. This tool only describes {joined}.")
|
|
95
|
+
|
|
96
|
+
parts = path.split(".")
|
|
97
|
+
for split in range(len(parts), 0, -1):
|
|
98
|
+
try:
|
|
99
|
+
obj = importlib.import_module(".".join(parts[:split]))
|
|
100
|
+
except ImportError:
|
|
101
|
+
continue
|
|
102
|
+
try:
|
|
103
|
+
for attr in parts[split:]:
|
|
104
|
+
obj = getattr(obj, attr)
|
|
105
|
+
except AttributeError:
|
|
106
|
+
continue
|
|
107
|
+
return obj
|
|
108
|
+
raise Refused(f"Nothing called {path!r} in cellpy.")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def follow_reference(doc: str, index: list[dict] | None = None) -> tuple[str | None, str]:
|
|
112
|
+
"""Resolve a cross-reference in `doc` to the docstring it points at.
|
|
113
|
+
|
|
114
|
+
This is the single biggest win in the family, and it is worth saying why.
|
|
115
|
+
`CellpyCell.get_cap` takes 23 arguments, documents none of them, and spends
|
|
116
|
+
its whole docstring pointing at `cellpy.readers.capacity_curves.get_cap`.
|
|
117
|
+
The delegate documents 22 of its 24 in a full ``Args:`` block. `to_csv`
|
|
118
|
+
(9 arguments) and `to_excel` (7) are the same shape.
|
|
119
|
+
|
|
120
|
+
So the documentation is not missing — it is one hop away, behind a marker
|
|
121
|
+
that only a docs *site* resolves. Everywhere a docstring is actually met —
|
|
122
|
+
an IDE tooltip, `help()`, a chat window, an agent — the reader gets the
|
|
123
|
+
pointer and not the text.
|
|
124
|
+
|
|
125
|
+
**Two spellings, because cellpy changed one.** Up to 2.1.3 these were
|
|
126
|
+
Sphinx roles: ``See :func:`cellpy.readers.capacity_curves.get_cap```. In
|
|
127
|
+
2.1.3.post2 the roles were stripped to stop them leaking into the rendered
|
|
128
|
+
API docs, which also stripped the module path — `get_cap`'s docstring is
|
|
129
|
+
now ``See `get_cap`.`` So a bare name is resolved through the index, and
|
|
130
|
+
accepted only when it lands on a *different* object with its own docstring.
|
|
131
|
+
`delegates_to` is always returned alongside `delegate_doc`, so the caller
|
|
132
|
+
can see exactly which call the text came from rather than trusting a guess.
|
|
133
|
+
"""
|
|
134
|
+
import inspect
|
|
135
|
+
|
|
136
|
+
candidates: list[str] = []
|
|
137
|
+
role = _ROLE_REFERENCE.search(doc)
|
|
138
|
+
if role:
|
|
139
|
+
candidates.append(role.group(1))
|
|
140
|
+
candidates.extend(_BARE_REFERENCE.findall(doc))
|
|
141
|
+
|
|
142
|
+
for target in candidates:
|
|
143
|
+
if "." in target:
|
|
144
|
+
if target.split(".")[0] not in API_ROOTS:
|
|
145
|
+
continue
|
|
146
|
+
try:
|
|
147
|
+
referenced = resolve_dotted(target)
|
|
148
|
+
except Exception: # noqa: BLE001 - a dead reference is not an error
|
|
149
|
+
continue
|
|
150
|
+
path = target
|
|
151
|
+
else:
|
|
152
|
+
# A bare name, and the index is the only thing that can say what it
|
|
153
|
+
# means. Restricted to *module-level* functions, which is both the
|
|
154
|
+
# shape cellpy's delegation actually takes — a thin method calling
|
|
155
|
+
# the real implementation — and what keeps `get_cap` from matching
|
|
156
|
+
# the very method whose docstring we are reading.
|
|
157
|
+
matches = [
|
|
158
|
+
entry
|
|
159
|
+
for entry in (index or [])
|
|
160
|
+
if entry["name"] == target and not _is_class_member(entry["path"])
|
|
161
|
+
]
|
|
162
|
+
if len(matches) != 1:
|
|
163
|
+
# Nothing, or still ambiguous. Guessing between two calls would
|
|
164
|
+
# attach someone else's arguments to this one.
|
|
165
|
+
continue
|
|
166
|
+
path = matches[0]["path"]
|
|
167
|
+
try:
|
|
168
|
+
referenced = resolve_dotted(path)
|
|
169
|
+
except Exception: # noqa: BLE001
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
referenced_doc = inspect.getdoc(referenced) or ""
|
|
173
|
+
# A reference resolving back to the same text says nothing.
|
|
174
|
+
if not referenced_doc or referenced_doc == doc:
|
|
175
|
+
continue
|
|
176
|
+
return path, referenced_doc
|
|
177
|
+
|
|
178
|
+
return None, ""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def summarise(doc: str) -> str:
|
|
182
|
+
"""The first line of a docstring that says something.
|
|
183
|
+
|
|
184
|
+
`CellpyCell.set_mass` opens with a bare ``Warning:``, so taking line one
|
|
185
|
+
verbatim produces an index in which several unrelated calls are described
|
|
186
|
+
as "Warning:". A heading is carried into the line below it instead.
|
|
187
|
+
"""
|
|
188
|
+
lines = [line.strip() for line in doc.strip().splitlines() if line.strip()]
|
|
189
|
+
if not lines:
|
|
190
|
+
return ""
|
|
191
|
+
first = lines[0]
|
|
192
|
+
if first.endswith(":") and len(first) < 24 and len(lines) > 1:
|
|
193
|
+
return f"{first} {lines[1]}"
|
|
194
|
+
return first
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def build_index() -> list[dict]:
|
|
198
|
+
"""Every public callable in API_MODULES, one path each.
|
|
199
|
+
|
|
200
|
+
Re-exports are the norm in cellpy: `get` is reachable as `cellpy.get` and
|
|
201
|
+
`cellpy.readers.cellreader.get`, and `utils.helpers` re-exports
|
|
202
|
+
`CellpyCell` — which is how the first version of this answered "get_cap"
|
|
203
|
+
with `cellpy.utils.helpers.CellpyCell.get_cap`, a true path that sends the
|
|
204
|
+
reader to the wrong file. So index by object identity and keep the shortest
|
|
205
|
+
path, because that is the one people type, breaking ties toward the module
|
|
206
|
+
that actually defines it.
|
|
207
|
+
"""
|
|
208
|
+
import importlib
|
|
209
|
+
import inspect
|
|
210
|
+
|
|
211
|
+
best: dict[int, tuple[tuple[int, int], str, Any]] = {}
|
|
212
|
+
|
|
213
|
+
def offer(path: str, entity: Any) -> None:
|
|
214
|
+
home = getattr(entity, "__module__", "") or ""
|
|
215
|
+
rank = (len(path.split(".")), 0 if path.startswith(home) else 1)
|
|
216
|
+
current = best.get(id(entity))
|
|
217
|
+
if current is None or rank < current[0]:
|
|
218
|
+
best[id(entity)] = (rank, path, entity)
|
|
219
|
+
|
|
220
|
+
for module_name in API_MODULES:
|
|
221
|
+
try:
|
|
222
|
+
module = importlib.import_module(module_name)
|
|
223
|
+
except Exception: # noqa: BLE001 - a module that will not import is not an error
|
|
224
|
+
continue
|
|
225
|
+
for name, obj in vars(module).items():
|
|
226
|
+
if name.startswith("_") or not callable(obj):
|
|
227
|
+
continue
|
|
228
|
+
# Anything that merely passed through from outside cellpy (pandas,
|
|
229
|
+
# pathlib) is not ours to describe.
|
|
230
|
+
if not (getattr(obj, "__module__", "") or "").startswith(API_ROOTS):
|
|
231
|
+
continue
|
|
232
|
+
offer(f"{module_name}.{name}", obj)
|
|
233
|
+
if isinstance(obj, type):
|
|
234
|
+
for attr in vars(obj):
|
|
235
|
+
member = getattr(obj, attr, None)
|
|
236
|
+
if not attr.startswith("_") and callable(member):
|
|
237
|
+
offer(f"{module_name}.{name}.{attr}", member)
|
|
238
|
+
|
|
239
|
+
entries = [
|
|
240
|
+
{"path": path, "name": path.rsplit(".", 1)[-1], "summary": summarise(inspect.getdoc(entity) or "")}
|
|
241
|
+
for _rank, path, entity in best.values()
|
|
242
|
+
]
|
|
243
|
+
entries.sort(key=lambda entry: entry["path"])
|
|
244
|
+
return entries
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def register(server, state) -> None:
|
|
248
|
+
def index() -> list[dict]:
|
|
249
|
+
if state.api_index is None:
|
|
250
|
+
state.api_index = build_index()
|
|
251
|
+
return state.api_index
|
|
252
|
+
|
|
253
|
+
@server.tool()
|
|
254
|
+
def search_api(query: str, limit: int = 15) -> dict:
|
|
255
|
+
"""Find cellpy calls by name, or by what their first docstring line says.
|
|
256
|
+
|
|
257
|
+
Use this when you know the task but not the call — "average cycles",
|
|
258
|
+
"loading", "mass". `describe_api` then gives the arguments.
|
|
259
|
+
"""
|
|
260
|
+
if not query.strip():
|
|
261
|
+
raise Refused("Give something to search for.")
|
|
262
|
+
needle = query.strip().lower()
|
|
263
|
+
limit = max(1, min(int(limit), 50))
|
|
264
|
+
|
|
265
|
+
scored: list[tuple[int, dict]] = []
|
|
266
|
+
for entry in index():
|
|
267
|
+
name = entry["name"].lower()
|
|
268
|
+
if needle == name:
|
|
269
|
+
score = 0
|
|
270
|
+
elif needle in name:
|
|
271
|
+
score = 1
|
|
272
|
+
elif needle in entry["path"].lower():
|
|
273
|
+
score = 2
|
|
274
|
+
elif needle in entry["summary"].lower():
|
|
275
|
+
score = 3
|
|
276
|
+
else:
|
|
277
|
+
continue
|
|
278
|
+
scored.append((score, entry))
|
|
279
|
+
|
|
280
|
+
scored.sort(key=lambda pair: (pair[0], len(pair[1]["path"])))
|
|
281
|
+
return {
|
|
282
|
+
"query": query,
|
|
283
|
+
"indexed": len(index()),
|
|
284
|
+
"matches": [entry for _score, entry in scored[:limit]],
|
|
285
|
+
"truncated": len(scored) > limit,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
@server.tool()
|
|
289
|
+
def describe_api(name: str, include_source: bool = False) -> dict:
|
|
290
|
+
"""What a cellpy call takes and what it does, from the installed package.
|
|
291
|
+
|
|
292
|
+
`name` is a dotted path (`cellpy.get`, `cellpy.collect.collect_summary`)
|
|
293
|
+
or a bare name (`get_cap`) looked up in the index.
|
|
294
|
+
|
|
295
|
+
Read `undocumented_parameters` before answering a question about one of
|
|
296
|
+
them. cellpy documents roughly half its arguments, so an argument
|
|
297
|
+
missing from `doc` means the package never said what it does — not that
|
|
298
|
+
it does not matter. Ask again with `include_source=True` rather than
|
|
299
|
+
guessing.
|
|
300
|
+
|
|
301
|
+
When `delegates_to` is set, the docstring pointed at another call with a
|
|
302
|
+
Sphinx reference and `delegate_doc` is where the arguments are actually
|
|
303
|
+
described — read it, it is usually the real documentation.
|
|
304
|
+
"""
|
|
305
|
+
import inspect
|
|
306
|
+
|
|
307
|
+
if "." in name:
|
|
308
|
+
# A dotted path is a request to import. `resolve_dotted` is the
|
|
309
|
+
# guard, so send every dotted name through it rather than falling
|
|
310
|
+
# back to the index and reporting "no such cellpy call" for
|
|
311
|
+
# `os.system` — true, but the wrong reason, and the wrong reason
|
|
312
|
+
# teaches the caller that a different spelling might work.
|
|
313
|
+
path = name
|
|
314
|
+
obj = resolve_dotted(path)
|
|
315
|
+
else:
|
|
316
|
+
matches = [entry for entry in index() if entry["name"] == name]
|
|
317
|
+
if not matches:
|
|
318
|
+
hits = search_api(name, limit=5)["matches"]
|
|
319
|
+
suffix = ""
|
|
320
|
+
if hits:
|
|
321
|
+
suffix = " Did you mean: " + ", ".join(h["path"] for h in hits) + "?"
|
|
322
|
+
raise Refused(f"No cellpy call called {name!r}.{suffix}")
|
|
323
|
+
# Prefer how people actually call it. `get_cap` names both
|
|
324
|
+
# `CellpyCell.get_cap` and the module-level `capacity_curves.get_cap`
|
|
325
|
+
# it delegates to; someone asking about "get_cap" means the one they
|
|
326
|
+
# would write as `cell.get_cap(...)`, and the delegate's arguments
|
|
327
|
+
# arrive anyway through `delegates_to`.
|
|
328
|
+
matches.sort(key=lambda entry: (not _is_class_member(entry["path"]), len(entry["path"])))
|
|
329
|
+
path = matches[0]["path"]
|
|
330
|
+
obj = resolve_dotted(path)
|
|
331
|
+
|
|
332
|
+
doc = inspect.getdoc(obj) or ""
|
|
333
|
+
delegate_path, delegate_doc = follow_reference(doc, index())
|
|
334
|
+
# A parameter counts as documented if *either* docstring names it.
|
|
335
|
+
searchable = doc + "\n" + delegate_doc
|
|
336
|
+
|
|
337
|
+
if isinstance(obj, type):
|
|
338
|
+
kind = "class"
|
|
339
|
+
elif isinstance(obj, property):
|
|
340
|
+
kind = "property"
|
|
341
|
+
else:
|
|
342
|
+
kind = "function"
|
|
343
|
+
|
|
344
|
+
target = obj.fget if isinstance(obj, property) else obj
|
|
345
|
+
parameters: list[dict] = []
|
|
346
|
+
signature_text = None
|
|
347
|
+
try:
|
|
348
|
+
signature = inspect.signature(target)
|
|
349
|
+
except (TypeError, ValueError):
|
|
350
|
+
signature = None
|
|
351
|
+
|
|
352
|
+
if signature is not None:
|
|
353
|
+
# Methods are shown as you would call them. Leaving `self` in the
|
|
354
|
+
# rendered signature invites a model to pass it as an argument.
|
|
355
|
+
rendered = str(signature)
|
|
356
|
+
if rendered.startswith("(self, "):
|
|
357
|
+
rendered = "(" + rendered[len("(self, ") :]
|
|
358
|
+
elif rendered == "(self)":
|
|
359
|
+
rendered = "()"
|
|
360
|
+
signature_text = f"{path.rsplit('.', 1)[-1]}{rendered}"
|
|
361
|
+
|
|
362
|
+
empty = inspect.Parameter.empty
|
|
363
|
+
for pname, parameter in signature.parameters.items():
|
|
364
|
+
if pname in ("self", "cls"):
|
|
365
|
+
continue
|
|
366
|
+
variadic = (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD)
|
|
367
|
+
parameters.append(
|
|
368
|
+
{
|
|
369
|
+
"name": pname,
|
|
370
|
+
"annotation": (
|
|
371
|
+
None if parameter.annotation is empty else str(parameter.annotation)
|
|
372
|
+
),
|
|
373
|
+
"default": (
|
|
374
|
+
None if parameter.default is empty else repr(parameter.default)
|
|
375
|
+
),
|
|
376
|
+
"required": parameter.default is empty
|
|
377
|
+
and parameter.kind not in variadic,
|
|
378
|
+
"documented": bool(re.search(rf"\b{re.escape(pname)}\b", searchable)),
|
|
379
|
+
}
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
result: dict[str, Any] = {
|
|
383
|
+
"path": path,
|
|
384
|
+
"kind": kind,
|
|
385
|
+
"module": getattr(obj, "__module__", None),
|
|
386
|
+
"signature": signature_text,
|
|
387
|
+
"parameters": parameters,
|
|
388
|
+
"doc": doc[:MAX_DOC_CHARS] + ("…" if len(doc) > MAX_DOC_CHARS else ""),
|
|
389
|
+
# The trap, carried in the result rather than left to be discovered.
|
|
390
|
+
"undocumented_parameters": [p["name"] for p in parameters if not p["documented"]],
|
|
391
|
+
}
|
|
392
|
+
if delegate_path:
|
|
393
|
+
result["delegates_to"] = delegate_path
|
|
394
|
+
result["delegate_doc"] = delegate_doc[:MAX_DOC_CHARS] + (
|
|
395
|
+
"…" if len(delegate_doc) > MAX_DOC_CHARS else ""
|
|
396
|
+
)
|
|
397
|
+
if include_source:
|
|
398
|
+
try:
|
|
399
|
+
source = inspect.getsource(target)
|
|
400
|
+
except (OSError, TypeError) as exc:
|
|
401
|
+
result["source_error"] = str(exc)
|
|
402
|
+
else:
|
|
403
|
+
lines = source.splitlines()
|
|
404
|
+
result["source"] = "\n".join(lines[:MAX_SOURCE_LINES])
|
|
405
|
+
result["source_truncated"] = len(lines) > MAX_SOURCE_LINES
|
|
406
|
+
return result
|