flet-mcp 0.86.0.dev1__tar.gz → 0.86.0.dev2__tar.gz
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.
- {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/PKG-INFO +13 -4
- {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/README.md +10 -0
- {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/pyproject.toml +3 -4
- flet_mcp-0.86.0.dev2/src/flet_mcp/api_store.py +611 -0
- flet_mcp-0.86.0.dev2/src/flet_mcp/build/icons.py +74 -0
- {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/build/indexer.py +4 -0
- {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/data/api.json +13 -7
- flet_mcp-0.86.0.dev2/src/flet_mcp/data/icons.json +84638 -0
- {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/data/mcp.db +0 -0
- flet_mcp-0.86.0.dev2/src/flet_mcp/icons_store.py +141 -0
- {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/server.py +145 -33
- {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp.egg-info/PKG-INFO +13 -4
- {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp.egg-info/SOURCES.txt +5 -2
- {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp.egg-info/requires.txt +2 -3
- flet_mcp-0.86.0.dev2/tests/test_api_store.py +413 -0
- flet_mcp-0.86.0.dev2/tests/test_icons_store.py +113 -0
- flet_mcp-0.86.0.dev1/src/flet_mcp/api_store.py +0 -159
- flet_mcp-0.86.0.dev1/src/flet_mcp/data/icons.yml +0 -2097
- flet_mcp-0.86.0.dev1/src/flet_mcp/icons_store.py +0 -138
- {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/setup.cfg +0 -0
- {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/__init__.py +0 -0
- {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/build/__init__.py +0 -0
- {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/build/api.py +0 -0
- {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/build/docs.py +0 -0
- {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/build/examples.py +0 -0
- {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp/db.py +0 -0
- {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp.egg-info/dependency_links.txt +0 -0
- {flet_mcp-0.86.0.dev1 → flet_mcp-0.86.0.dev2}/src/flet_mcp.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flet-mcp
|
|
3
|
-
Version: 0.86.0.
|
|
3
|
+
Version: 0.86.0.dev2
|
|
4
4
|
Summary: Flet MCP server for LLM agents
|
|
5
5
|
Author-email: "Appveyor Systems Inc." <hello@flet.dev>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -10,12 +10,11 @@ Project-URL: Documentation, https://flet.dev/docs
|
|
|
10
10
|
Requires-Python: >=3.10
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
Requires-Dist: fastmcp>=2.0.0
|
|
13
|
-
Requires-Dist: pyyaml>=6.0
|
|
14
13
|
Provides-Extra: build
|
|
15
14
|
Requires-Dist: markdownify>=0.14.1; extra == "build"
|
|
16
15
|
Requires-Dist: griffe>=1.6.2; extra == "build"
|
|
17
|
-
Requires-Dist: flet==0.86.0.
|
|
18
|
-
Requires-Dist: flet-cli==0.86.0.
|
|
16
|
+
Requires-Dist: flet==0.86.0.dev2; extra == "build"
|
|
17
|
+
Requires-Dist: flet-cli==0.86.0.dev2; extra == "build"
|
|
19
18
|
|
|
20
19
|
# flet-mcp
|
|
21
20
|
|
|
@@ -43,6 +42,16 @@ uv run flet mcp build # api.json only
|
|
|
43
42
|
uv run flet mcp build --examples ./examples # add examples index
|
|
44
43
|
```
|
|
45
44
|
|
|
45
|
+
The icon search metadata (`data/icons.json`) is a *committed* file, not a
|
|
46
|
+
build product — it is generated from Google's fonts.google.com icon
|
|
47
|
+
metadata (names, synonym tags, popularity; Apache-2.0, the same data that
|
|
48
|
+
powers the fonts.google.com icon search) and only changes when Google
|
|
49
|
+
ships new icons. Refresh it with:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
uv run python -m flet_mcp.build.icons
|
|
53
|
+
```
|
|
54
|
+
|
|
46
55
|
> **Docs index is currently deferred.** The `--docs` flag still expects a mkdocs
|
|
47
56
|
> `search_index.json`, which the site no longer produces after the migration to
|
|
48
57
|
> Docusaurus + Algolia. The `DOCS` tool group stays off by default; rebuilding
|
|
@@ -24,6 +24,16 @@ uv run flet mcp build # api.json only
|
|
|
24
24
|
uv run flet mcp build --examples ./examples # add examples index
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
The icon search metadata (`data/icons.json`) is a *committed* file, not a
|
|
28
|
+
build product — it is generated from Google's fonts.google.com icon
|
|
29
|
+
metadata (names, synonym tags, popularity; Apache-2.0, the same data that
|
|
30
|
+
powers the fonts.google.com icon search) and only changes when Google
|
|
31
|
+
ships new icons. Refresh it with:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv run python -m flet_mcp.build.icons
|
|
35
|
+
```
|
|
36
|
+
|
|
27
37
|
> **Docs index is currently deferred.** The `--docs` flag still expects a mkdocs
|
|
28
38
|
> `search_index.json`, which the site no longer produces after the migration to
|
|
29
39
|
> Docusaurus + Algolia. The `DOCS` tool group stays off by default; rebuilding
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "flet-mcp"
|
|
3
|
-
version = "0.86.0.
|
|
3
|
+
version = "0.86.0.dev2"
|
|
4
4
|
description = "Flet MCP server for LLM agents"
|
|
5
5
|
authors = [{ name = "Appveyor Systems Inc.", email = "hello@flet.dev" }]
|
|
6
6
|
license = "Apache-2.0"
|
|
@@ -8,15 +8,14 @@ readme = "README.md"
|
|
|
8
8
|
requires-python = ">=3.10"
|
|
9
9
|
dependencies = [
|
|
10
10
|
"fastmcp >=2.0.0",
|
|
11
|
-
"pyyaml >=6.0",
|
|
12
11
|
]
|
|
13
12
|
|
|
14
13
|
[project.optional-dependencies]
|
|
15
14
|
build = [
|
|
16
15
|
"markdownify >=0.14.1",
|
|
17
16
|
"griffe >=1.6.2",
|
|
18
|
-
"flet==0.86.0.
|
|
19
|
-
"flet-cli==0.86.0.
|
|
17
|
+
"flet==0.86.0.dev2",
|
|
18
|
+
"flet-cli==0.86.0.dev2",
|
|
20
19
|
]
|
|
21
20
|
|
|
22
21
|
[project.urls]
|
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
"""Load and query the Griffe-generated API reference (api.json)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.resources
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
# Member docstrings are trimmed to their first sentence in listings (full
|
|
12
|
+
# docstrings often embed multi-paragraph examples — Page's methods alone
|
|
13
|
+
# carry ~9k chars of doc text). The full text stays reachable per member
|
|
14
|
+
# via `get(name, member=...)`.
|
|
15
|
+
_SUMMARY_MAX_CHARS = 160
|
|
16
|
+
|
|
17
|
+
# Controls use properties/events/methods; dataclass types and event
|
|
18
|
+
# classes keep their members under "fields" (same entry shape).
|
|
19
|
+
_MEMBER_SECTIONS = ("properties", "fields", "events", "methods")
|
|
20
|
+
|
|
21
|
+
_MEMBER_KIND = {
|
|
22
|
+
"properties": "property",
|
|
23
|
+
"fields": "field",
|
|
24
|
+
"events": "event",
|
|
25
|
+
"methods": "method",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Buckets in preference order for ambiguous names, and the `kind` value
|
|
29
|
+
# stamped onto entries from buckets that don't carry their own.
|
|
30
|
+
_BUCKET_RANK = {"controls": 0, "types": 1, "events": 2, "enums": 3}
|
|
31
|
+
_BUCKET_KIND = {"types": "type", "events": "event"}
|
|
32
|
+
|
|
33
|
+
# Sphinx roles in docstrings (":attr:`style`", ":class:`~flet.Text`") are
|
|
34
|
+
# docs-site markup — render them as plain backticked names.
|
|
35
|
+
_RST_ROLE = re.compile(r":\w+(?::\w+)?:`~?([^`]+)`")
|
|
36
|
+
|
|
37
|
+
# Material icon style-variant suffixes: collapsed in member search so
|
|
38
|
+
# ADD / ADD_OUTLINED / ADD_ROUNDED / ADD_SHARP don't spend four result
|
|
39
|
+
# slots on one icon.
|
|
40
|
+
_STYLE_SUFFIXES = ("_OUTLINED", "_ROUNDED", "_SHARP")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def clean_rst(text: str) -> str:
|
|
44
|
+
return _RST_ROLE.sub(r"`\1`", text)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def first_sentence(text: str, limit: int = _SUMMARY_MAX_CHARS) -> str:
|
|
48
|
+
"""First sentence (or line) of a docstring, capped at `limit` chars."""
|
|
49
|
+
if not text:
|
|
50
|
+
return text
|
|
51
|
+
text = clean_rst(text)
|
|
52
|
+
head = text.split("\n\n", 1)[0].split("\n", 1)[0]
|
|
53
|
+
dot = head.find(". ")
|
|
54
|
+
if dot != -1:
|
|
55
|
+
head = head[: dot + 1]
|
|
56
|
+
if len(head) > limit:
|
|
57
|
+
head = head[: limit - 1].rstrip() + "…"
|
|
58
|
+
return head
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _compact_member(entry: dict[str, Any]) -> dict[str, Any]:
|
|
62
|
+
doc = entry.get("docstring")
|
|
63
|
+
if not doc:
|
|
64
|
+
return entry
|
|
65
|
+
summary = first_sentence(doc)
|
|
66
|
+
if summary == doc:
|
|
67
|
+
return entry
|
|
68
|
+
return {**entry, "docstring": summary}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── Text rendering ──────────────────────────────────────────────────────
|
|
72
|
+
#
|
|
73
|
+
# The default `get_api` response is signature-style text, not JSON: one
|
|
74
|
+
# line per member. Same information, ~44% fewer chars and far fewer
|
|
75
|
+
# tokens than the JSON encoding (keys/quoting repeat per member).
|
|
76
|
+
# `format="json"` on the tool returns the dict shape instead.
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _optional_inner(type_str: str) -> str | None:
|
|
80
|
+
"""`Optional[X]` -> `X`, else None."""
|
|
81
|
+
if type_str.startswith("Optional[") and type_str.endswith("]"):
|
|
82
|
+
return type_str[len("Optional[") : -1]
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _event_payload(type_str: str) -> str | None:
|
|
87
|
+
"""The event object type a handler receives, or None if the type
|
|
88
|
+
string doesn't match the known handler shapes (caller falls back to
|
|
89
|
+
the raw string — never guess):
|
|
90
|
+
|
|
91
|
+
* `Optional[ControlEventHandler[X]]` -> `ControlEvent`
|
|
92
|
+
* `Optional[EventHandler[XEvent]]` -> `XEvent`
|
|
93
|
+
* `Optional[EventHandler[XEvent[Subject]]]` -> `XEvent`
|
|
94
|
+
"""
|
|
95
|
+
s = _optional_inner(type_str) or type_str
|
|
96
|
+
wrapper, bracket, rest = s.partition("[")
|
|
97
|
+
if not bracket or not rest.endswith("]"):
|
|
98
|
+
return None
|
|
99
|
+
wrapper = wrapper.removeprefix("ft.")
|
|
100
|
+
inner = rest[:-1]
|
|
101
|
+
if wrapper == "ControlEventHandler":
|
|
102
|
+
return "ControlEvent"
|
|
103
|
+
if wrapper == "EventHandler":
|
|
104
|
+
return inner.split("[", 1)[0]
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _fmt_default(default: Any) -> str | None:
|
|
109
|
+
if default in (None, "None", ""):
|
|
110
|
+
return None
|
|
111
|
+
text = str(default)
|
|
112
|
+
# Unwrap dataclass `field(...)` declarations — the metadata is
|
|
113
|
+
# serialization plumbing, only the actual default value informs.
|
|
114
|
+
if text.startswith("field("):
|
|
115
|
+
if m := re.search(r"default_factory=(\w+)", text):
|
|
116
|
+
factory = m.group(1)
|
|
117
|
+
return {"list": "[]", "dict": "{}"}.get(factory, f"{factory}()")
|
|
118
|
+
if m := re.search(r"default=([^,)]+)", text):
|
|
119
|
+
return _fmt_default(m.group(1).strip())
|
|
120
|
+
return None
|
|
121
|
+
return text
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _member_line(section: str, entry: dict[str, Any]) -> str:
|
|
125
|
+
"""One text line for a property/event/method entry (docstring already
|
|
126
|
+
compacted by `_compact_member`)."""
|
|
127
|
+
name = entry.get("name", "")
|
|
128
|
+
if section == "methods":
|
|
129
|
+
params = ", ".join(p.get("name", "") for p in entry.get("params") or [])
|
|
130
|
+
sig = f"{name}({params})"
|
|
131
|
+
ret = entry.get("return_type")
|
|
132
|
+
if ret:
|
|
133
|
+
sig += f" -> {ret}"
|
|
134
|
+
if entry.get("async"):
|
|
135
|
+
sig = "async " + sig
|
|
136
|
+
elif section == "events":
|
|
137
|
+
payload = _event_payload(entry.get("type", ""))
|
|
138
|
+
sig = f"{name}({payload})" if payload else f"{name}: {entry.get('type', '')}"
|
|
139
|
+
else:
|
|
140
|
+
type_str = entry.get("type", "")
|
|
141
|
+
inner = _optional_inner(type_str)
|
|
142
|
+
sig = f"{name}: {inner}?" if inner else f"{name}: {type_str}"
|
|
143
|
+
default = _fmt_default(entry.get("default"))
|
|
144
|
+
if default:
|
|
145
|
+
sig += f" = {default}"
|
|
146
|
+
dep = entry.get("deprecated")
|
|
147
|
+
if dep:
|
|
148
|
+
reason = dep.get("reason") if isinstance(dep, dict) else str(dep)
|
|
149
|
+
sig += f" [DEPRECATED: {reason}]"
|
|
150
|
+
doc = entry.get("docstring")
|
|
151
|
+
if doc:
|
|
152
|
+
sig += f" — {doc}"
|
|
153
|
+
return sig
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def render_text(hit: dict[str, Any]) -> str:
|
|
157
|
+
"""Render a `get()` result as signature-style text (see the `get_api`
|
|
158
|
+
tool docstring for the layout conventions the model relies on)."""
|
|
159
|
+
name = hit.get("name", "")
|
|
160
|
+
|
|
161
|
+
# Single-member drill-down (`get_api(name, member=...)`).
|
|
162
|
+
if "member" in hit and "member_kind" in hit:
|
|
163
|
+
entry = hit["member"]
|
|
164
|
+
section = next(
|
|
165
|
+
(s for s, k in _MEMBER_KIND.items() if k == hit["member_kind"]),
|
|
166
|
+
"properties",
|
|
167
|
+
)
|
|
168
|
+
lines = [f"{name}.{entry.get('name')} ({hit['member_kind']})"]
|
|
169
|
+
lines.append(_member_line(section, {**entry, "docstring": None}))
|
|
170
|
+
if entry.get("type"):
|
|
171
|
+
lines.append(f"type: {entry['type']}")
|
|
172
|
+
if entry.get("docstring"):
|
|
173
|
+
lines.append(clean_rst(entry["docstring"]))
|
|
174
|
+
return "\n".join(lines)
|
|
175
|
+
|
|
176
|
+
lines = []
|
|
177
|
+
header = f"{name} ({hit.get('kind', '')})"
|
|
178
|
+
summary = hit.get("summary") or first_sentence(hit.get("docstring") or "")
|
|
179
|
+
if summary:
|
|
180
|
+
header += f" — {clean_rst(summary)}"
|
|
181
|
+
lines.append(header)
|
|
182
|
+
dep = hit.get("deprecated")
|
|
183
|
+
if dep:
|
|
184
|
+
reason = dep.get("reason") if isinstance(dep, dict) else str(dep)
|
|
185
|
+
lines.append(f"DEPRECATED: {reason}")
|
|
186
|
+
pkg = hit.get("package")
|
|
187
|
+
if pkg and pkg != "flet":
|
|
188
|
+
lines.append(f"package: {pkg} (add this pip package to the project)")
|
|
189
|
+
if hit.get("bases"):
|
|
190
|
+
lines.append("bases: " + ", ".join(hit["bases"]))
|
|
191
|
+
if hit.get("note"):
|
|
192
|
+
lines.append(f"note: {hit['note']}")
|
|
193
|
+
|
|
194
|
+
if hit.get("kind") == "large_enum":
|
|
195
|
+
lines.append(f"total members: {hit.get('total_members')}")
|
|
196
|
+
samples = [
|
|
197
|
+
m.get("name") if isinstance(m, dict) else str(m)
|
|
198
|
+
for m in hit.get("sample_members") or []
|
|
199
|
+
]
|
|
200
|
+
lines.append("sample members: " + ", ".join(samples))
|
|
201
|
+
elif hit.get("members") is not None:
|
|
202
|
+
lines.append("members:")
|
|
203
|
+
for m in hit.get("members") or []:
|
|
204
|
+
if isinstance(m, dict):
|
|
205
|
+
line = f" {m.get('name')} = {m.get('value')}"
|
|
206
|
+
if m.get("docstring"):
|
|
207
|
+
line += f" — {first_sentence(m['docstring'])}"
|
|
208
|
+
lines.append(line)
|
|
209
|
+
else:
|
|
210
|
+
lines.append(f" {m}")
|
|
211
|
+
|
|
212
|
+
for section in _MEMBER_SECTIONS:
|
|
213
|
+
entries = hit.get(section)
|
|
214
|
+
if not entries:
|
|
215
|
+
continue
|
|
216
|
+
lines.append(f"{section}:")
|
|
217
|
+
for entry in entries:
|
|
218
|
+
lines.append(" " + _member_line(section, entry))
|
|
219
|
+
|
|
220
|
+
# Inherited matches from a query= base-class walk.
|
|
221
|
+
for block in hit.get("inherited") or []:
|
|
222
|
+
lines.append(f"from {block.get('from')}:")
|
|
223
|
+
for section in _MEMBER_SECTIONS:
|
|
224
|
+
for entry in block.get(section) or []:
|
|
225
|
+
lines.append(" " + _member_line(section, entry))
|
|
226
|
+
return "\n".join(lines)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class ApiStore:
|
|
230
|
+
"""Lazy-loading store for the bundled api.json data.
|
|
231
|
+
|
|
232
|
+
The api.json file uses lists for controls/events/types/enums,
|
|
233
|
+
each entry having a "name" key. This store builds name-keyed dicts
|
|
234
|
+
for fast lookup.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
def __init__(self) -> None:
|
|
238
|
+
self._raw: dict[str, Any] | None = None
|
|
239
|
+
self._controls: dict[str, dict] | None = None
|
|
240
|
+
self._events: dict[str, dict] | None = None
|
|
241
|
+
self._types: dict[str, dict] | None = None
|
|
242
|
+
self._enums: dict[str, dict] | None = None
|
|
243
|
+
self._by_name: dict[str, list[tuple[str, dict]]] | None = None
|
|
244
|
+
|
|
245
|
+
def _load(self) -> dict[str, Any]:
|
|
246
|
+
if self._raw is None:
|
|
247
|
+
ref = importlib.resources.files("flet_mcp").joinpath("data/api.json")
|
|
248
|
+
self._raw = json.loads(Path(str(ref)).read_text(encoding="utf-8"))
|
|
249
|
+
# Build name-keyed dicts from lists
|
|
250
|
+
self._controls = {c["name"]: c for c in self._raw.get("controls", [])}
|
|
251
|
+
self._events = {e["name"]: e for e in self._raw.get("events", [])}
|
|
252
|
+
self._types = {t["name"]: t for t in self._raw.get("types", [])}
|
|
253
|
+
self._enums = {e["name"]: e for e in self._raw.get("enums", [])}
|
|
254
|
+
# Names are NOT unique across (or even within) buckets — e.g.
|
|
255
|
+
# the Text control vs the canvas Text shape. Keep every entry
|
|
256
|
+
# per name so `get` can rank candidates instead of letting the
|
|
257
|
+
# last dict insert silently win.
|
|
258
|
+
self._by_name = {}
|
|
259
|
+
for bucket in ("controls", "types", "events", "enums"):
|
|
260
|
+
for e in self._raw.get(bucket, []):
|
|
261
|
+
self._by_name.setdefault(e["name"], []).append((bucket, e))
|
|
262
|
+
return self._raw
|
|
263
|
+
|
|
264
|
+
def _candidates(self, name: str) -> list[tuple[str, dict[str, Any]]]:
|
|
265
|
+
"""Entries matching `name`, best first. A dotted name
|
|
266
|
+
("canvas.Text", "flet_map.Camera") filters candidates by module
|
|
267
|
+
segment, so shadowed symbols stay reachable. Preference for bare
|
|
268
|
+
names: non-canvas modules first (the canvas shapes namespace
|
|
269
|
+
shadows core controls like Text/Image), then bucket order."""
|
|
270
|
+
self._load()
|
|
271
|
+
if name in self._by_name:
|
|
272
|
+
cands = self._by_name[name]
|
|
273
|
+
elif "." in name:
|
|
274
|
+
prefix, cls = name.rsplit(".", 1)
|
|
275
|
+
cands = [
|
|
276
|
+
(b, e)
|
|
277
|
+
for (b, e) in self._by_name.get(cls, [])
|
|
278
|
+
if f".{prefix}." in f".{e.get('module') or ''}."
|
|
279
|
+
]
|
|
280
|
+
else:
|
|
281
|
+
return []
|
|
282
|
+
|
|
283
|
+
def rank(item: tuple[str, dict[str, Any]]) -> tuple[bool, int]:
|
|
284
|
+
bucket, entry = item
|
|
285
|
+
in_canvas = ".canvas." in f".{entry.get('module') or ''}."
|
|
286
|
+
return (in_canvas, _BUCKET_RANK[bucket])
|
|
287
|
+
|
|
288
|
+
return sorted(cands, key=rank)
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def _qualified_hint(entry: dict[str, Any], name: str) -> str:
|
|
292
|
+
"""A dotted name that selects `entry` unambiguously in `get`."""
|
|
293
|
+
parts = (entry.get("module") or "").split(".")
|
|
294
|
+
if "canvas" in parts:
|
|
295
|
+
return f"canvas.{name}"
|
|
296
|
+
return f"{parts[0]}.{name}" if parts and parts[0] else name
|
|
297
|
+
|
|
298
|
+
# ------------------------------------------------------------------
|
|
299
|
+
# Controls
|
|
300
|
+
# ------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
def list_controls(
|
|
303
|
+
self,
|
|
304
|
+
category: str | None = None,
|
|
305
|
+
kind: str | None = None,
|
|
306
|
+
limit: int = 50,
|
|
307
|
+
) -> list[dict[str, Any]]:
|
|
308
|
+
"""List controls, optionally filtered by category or kind."""
|
|
309
|
+
self._load()
|
|
310
|
+
results: list[dict[str, Any]] = []
|
|
311
|
+
for name, ctrl in self._controls.items():
|
|
312
|
+
if kind and ctrl.get("kind") != kind:
|
|
313
|
+
continue
|
|
314
|
+
if category and category not in ctrl.get("categories", []):
|
|
315
|
+
continue
|
|
316
|
+
results.append(
|
|
317
|
+
{
|
|
318
|
+
"name": name,
|
|
319
|
+
"kind": ctrl.get("kind"),
|
|
320
|
+
"summary": ctrl.get("summary", ""),
|
|
321
|
+
"categories": ctrl.get("categories", []),
|
|
322
|
+
}
|
|
323
|
+
)
|
|
324
|
+
if len(results) >= limit:
|
|
325
|
+
break
|
|
326
|
+
return results
|
|
327
|
+
|
|
328
|
+
# ------------------------------------------------------------------
|
|
329
|
+
# Unified lookup (controls/services/types/events)
|
|
330
|
+
# ------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
def get(
|
|
333
|
+
self,
|
|
334
|
+
name: str,
|
|
335
|
+
member: str | None = None,
|
|
336
|
+
query: str | None = None,
|
|
337
|
+
) -> dict[str, Any] | None:
|
|
338
|
+
"""Look up `name` across controls, services, types, events, and enums.
|
|
339
|
+
|
|
340
|
+
Every match carries a `kind` field — `"control"`, `"service"`, `"type"`,
|
|
341
|
+
`"event"`, or `"enum"` (`"large_enum"` for Icons/CupertinoIcons) — so the
|
|
342
|
+
caller can distinguish them without inspecting the response shape.
|
|
343
|
+
Controls and services already carry `kind`; the other buckets are
|
|
344
|
+
augmented here. Returns `None` only when the name matches nothing.
|
|
345
|
+
|
|
346
|
+
Member docstrings (properties/events/methods) are trimmed to their
|
|
347
|
+
first sentence. Pass `member` for one member's full entry, or `query`
|
|
348
|
+
for a case-insensitive substring filter over member names (`member`
|
|
349
|
+
wins if both are given). Enum responses use the same truncation as
|
|
350
|
+
`get_enum` for large enums; `member`/`query` don't apply to enums —
|
|
351
|
+
use search_enum_members / enum_has_member there.
|
|
352
|
+
|
|
353
|
+
Names are not globally unique (e.g. the Text control vs the canvas
|
|
354
|
+
Text shape): the best candidate wins and the response notes the
|
|
355
|
+
alternatives, each reachable via a dotted name ("canvas.Text").
|
|
356
|
+
"""
|
|
357
|
+
cands = self._candidates(name)
|
|
358
|
+
if not cands:
|
|
359
|
+
return None
|
|
360
|
+
bucket, entry = cands[0]
|
|
361
|
+
|
|
362
|
+
if bucket == "enums":
|
|
363
|
+
if member is not None or query is not None:
|
|
364
|
+
return {
|
|
365
|
+
"error": (
|
|
366
|
+
f"'{name}' is an enum — use search_enum_members or "
|
|
367
|
+
"enum_has_member to look up its members"
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
enum = self.get_enum(entry["name"])
|
|
371
|
+
return {"kind": enum.get("kind", "enum"), **enum}
|
|
372
|
+
|
|
373
|
+
hit = entry if bucket == "controls" else {"kind": _BUCKET_KIND[bucket], **entry}
|
|
374
|
+
if member is not None:
|
|
375
|
+
return self._get_member(hit, name, member)
|
|
376
|
+
out = self._compact(hit)
|
|
377
|
+
if query is not None:
|
|
378
|
+
out = self._filter_members(out, name, query)
|
|
379
|
+
if "error" not in out and len(cands) > 1:
|
|
380
|
+
alternates = "; ".join(
|
|
381
|
+
f"{e.get('module')}.{e.get('name')} — get_api"
|
|
382
|
+
f"('{self._qualified_hint(e, e.get('name'))}')"
|
|
383
|
+
for _, e in cands[1:]
|
|
384
|
+
)
|
|
385
|
+
combined = f"Other symbols share this name: {alternates}."
|
|
386
|
+
if out.get("note"):
|
|
387
|
+
combined += " " + out["note"]
|
|
388
|
+
out = {**out, "note": combined}
|
|
389
|
+
# Keep the note leading so it survives downstream capping.
|
|
390
|
+
out = {"note": out.pop("note"), **out}
|
|
391
|
+
return out
|
|
392
|
+
|
|
393
|
+
def _filter_members(
|
|
394
|
+
self, compacted: dict[str, Any], name: str, query: str
|
|
395
|
+
) -> dict[str, Any]:
|
|
396
|
+
"""Keep only members whose name contains `query` (case-insensitive).
|
|
397
|
+
|
|
398
|
+
Base classes are searched too (transitively): api.json stores only
|
|
399
|
+
a class's *own* members, so e.g. TextField's border properties live
|
|
400
|
+
on FormFieldControl — without the walk, a query would return a
|
|
401
|
+
misleading "no members" for members the control actually inherits.
|
|
402
|
+
Inherited matches land under an `inherited` key, grouped by the
|
|
403
|
+
defining class.
|
|
404
|
+
"""
|
|
405
|
+
q = query.lower()
|
|
406
|
+
|
|
407
|
+
def _matches(hit: dict[str, Any]) -> tuple[dict[str, list], int]:
|
|
408
|
+
found: dict[str, list] = {}
|
|
409
|
+
count = 0
|
|
410
|
+
for section in _MEMBER_SECTIONS:
|
|
411
|
+
kept = [
|
|
412
|
+
_compact_member(e)
|
|
413
|
+
for e in hit.get(section) or []
|
|
414
|
+
if q in e.get("name", "").lower()
|
|
415
|
+
]
|
|
416
|
+
found[section] = kept
|
|
417
|
+
count += len(kept)
|
|
418
|
+
return found, count
|
|
419
|
+
|
|
420
|
+
out = dict(compacted)
|
|
421
|
+
own, matched = _matches(compacted)
|
|
422
|
+
out.update(own)
|
|
423
|
+
|
|
424
|
+
inherited: list[dict[str, Any]] = []
|
|
425
|
+
visited: set[str] = set()
|
|
426
|
+
queue = list(compacted.get("bases") or [])
|
|
427
|
+
while queue:
|
|
428
|
+
base_name = queue.pop(0)
|
|
429
|
+
if base_name in visited:
|
|
430
|
+
continue
|
|
431
|
+
visited.add(base_name)
|
|
432
|
+
base = self._controls.get(base_name) or self._types.get(base_name)
|
|
433
|
+
if base is None:
|
|
434
|
+
continue
|
|
435
|
+
base_found, base_count = _matches(base)
|
|
436
|
+
if base_count:
|
|
437
|
+
inherited.append({"from": base_name, **base_found})
|
|
438
|
+
matched += base_count
|
|
439
|
+
queue.extend(base.get("bases") or [])
|
|
440
|
+
if inherited:
|
|
441
|
+
out["inherited"] = inherited
|
|
442
|
+
|
|
443
|
+
if not matched:
|
|
444
|
+
bases_hint = (
|
|
445
|
+
f" (own and inherited via {', '.join(sorted(visited))})"
|
|
446
|
+
if visited
|
|
447
|
+
else ""
|
|
448
|
+
)
|
|
449
|
+
return {
|
|
450
|
+
"error": (f"'{name}' has no members matching '{query}'{bases_hint}"),
|
|
451
|
+
"available_members": [
|
|
452
|
+
e.get("name")
|
|
453
|
+
for section in _MEMBER_SECTIONS
|
|
454
|
+
for e in compacted.get(section) or []
|
|
455
|
+
],
|
|
456
|
+
}
|
|
457
|
+
out["note"] = (
|
|
458
|
+
f"Showing only members matching '{query}' (including inherited). "
|
|
459
|
+
f"Call get_api({name!r}) for the full listing."
|
|
460
|
+
)
|
|
461
|
+
return out
|
|
462
|
+
|
|
463
|
+
def _compact(self, hit: dict[str, Any]) -> dict[str, Any]:
|
|
464
|
+
"""Trim member docstrings to their first sentence, with a note
|
|
465
|
+
pointing at the per-member drill-down when anything was cut."""
|
|
466
|
+
out = dict(hit)
|
|
467
|
+
trimmed = False
|
|
468
|
+
for section in _MEMBER_SECTIONS:
|
|
469
|
+
entries = out.get(section)
|
|
470
|
+
if not entries:
|
|
471
|
+
continue
|
|
472
|
+
compacted = [_compact_member(e) for e in entries]
|
|
473
|
+
if any(c is not e for c, e in zip(compacted, entries)):
|
|
474
|
+
trimmed = True
|
|
475
|
+
out[section] = compacted
|
|
476
|
+
if trimmed:
|
|
477
|
+
# Lead with the note so it survives any downstream size capping
|
|
478
|
+
# (agent frameworks may truncate the serialized result tail).
|
|
479
|
+
note = (
|
|
480
|
+
"Member docstrings are trimmed to their first sentence. "
|
|
481
|
+
f"Call get_api({out.get('name')!r}, member=<name>) for one "
|
|
482
|
+
"member's full docs and examples, or query=<substring> to "
|
|
483
|
+
"list matching members only."
|
|
484
|
+
)
|
|
485
|
+
out = {"note": note, **out}
|
|
486
|
+
return out
|
|
487
|
+
|
|
488
|
+
def _get_member(
|
|
489
|
+
self, hit: dict[str, Any], name: str, member: str
|
|
490
|
+
) -> dict[str, Any]:
|
|
491
|
+
for section in _MEMBER_SECTIONS:
|
|
492
|
+
for entry in hit.get(section) or []:
|
|
493
|
+
if entry.get("name") == member:
|
|
494
|
+
return {
|
|
495
|
+
"name": name,
|
|
496
|
+
"kind": hit.get("kind"),
|
|
497
|
+
"member_kind": _MEMBER_KIND[section],
|
|
498
|
+
"member": entry,
|
|
499
|
+
}
|
|
500
|
+
available = [
|
|
501
|
+
e.get("name")
|
|
502
|
+
for section in _MEMBER_SECTIONS
|
|
503
|
+
for e in hit.get(section) or []
|
|
504
|
+
]
|
|
505
|
+
return {
|
|
506
|
+
"error": f"'{name}' has no member '{member}'",
|
|
507
|
+
"available_members": available,
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
# ------------------------------------------------------------------
|
|
511
|
+
# Enums
|
|
512
|
+
# ------------------------------------------------------------------
|
|
513
|
+
|
|
514
|
+
def get_enum(self, name: str) -> dict[str, Any] | None:
|
|
515
|
+
"""Return enum data. Large enums are truncated with a hint to search."""
|
|
516
|
+
self._load()
|
|
517
|
+
enum = self._enums.get(name)
|
|
518
|
+
if enum is None:
|
|
519
|
+
return None
|
|
520
|
+
|
|
521
|
+
if enum.get("kind") == "large_enum":
|
|
522
|
+
members = enum.get("members", [])
|
|
523
|
+
return {
|
|
524
|
+
"name": name,
|
|
525
|
+
"kind": "large_enum",
|
|
526
|
+
"total_members": len(members),
|
|
527
|
+
"sample_members": members[:10],
|
|
528
|
+
"note": (
|
|
529
|
+
f"This enum has {len(members)} members. Use find_icon() "
|
|
530
|
+
"to search icons by concept, or search_enum_members() "
|
|
531
|
+
"for literal name lookup."
|
|
532
|
+
),
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return enum
|
|
536
|
+
|
|
537
|
+
def search_enum_members(self, name: str, query: str, limit: int = 10) -> list[str]:
|
|
538
|
+
"""Search enum members by case-insensitive substring match.
|
|
539
|
+
|
|
540
|
+
Ranked: exact match, then prefix matches, then substring matches
|
|
541
|
+
(shortest/alphabetical within each tier) — so "remove" surfaces
|
|
542
|
+
REMOVE before BOOKMARK_REMOVE. Material style variants
|
|
543
|
+
(_OUTLINED/_ROUNDED/_SHARP) are collapsed into their base member
|
|
544
|
+
when the base also matches, so they don't exhaust the limit.
|
|
545
|
+
"""
|
|
546
|
+
self._load()
|
|
547
|
+
enum = self._enums.get(name)
|
|
548
|
+
if enum is None:
|
|
549
|
+
return []
|
|
550
|
+
|
|
551
|
+
q = query.lower()
|
|
552
|
+
names = [
|
|
553
|
+
m["name"] if isinstance(m, dict) else str(m)
|
|
554
|
+
for m in enum.get("members", [])
|
|
555
|
+
]
|
|
556
|
+
matched = [n for n in names if q in n.lower()]
|
|
557
|
+
|
|
558
|
+
def tier(n: str) -> int:
|
|
559
|
+
ln = n.lower()
|
|
560
|
+
if ln == q:
|
|
561
|
+
return 0
|
|
562
|
+
return 1 if ln.startswith(q) else 2
|
|
563
|
+
|
|
564
|
+
matched.sort(key=lambda n: (tier(n), len(n), n))
|
|
565
|
+
present = set(matched)
|
|
566
|
+
results: list[str] = []
|
|
567
|
+
for n in matched:
|
|
568
|
+
base = next((n[: -len(s)] for s in _STYLE_SUFFIXES if n.endswith(s)), None)
|
|
569
|
+
if base and base in present:
|
|
570
|
+
continue
|
|
571
|
+
results.append(n)
|
|
572
|
+
if len(results) >= limit:
|
|
573
|
+
break
|
|
574
|
+
return results
|
|
575
|
+
|
|
576
|
+
def enum_member_names(self, name: str) -> list[str]:
|
|
577
|
+
"""All member names of an enum (untruncated — also for large
|
|
578
|
+
enums). Used to seed the icon search index from bundled data."""
|
|
579
|
+
self._load()
|
|
580
|
+
enum = self._enums.get(name)
|
|
581
|
+
if enum is None:
|
|
582
|
+
return []
|
|
583
|
+
return [
|
|
584
|
+
m["name"] if isinstance(m, dict) else str(m)
|
|
585
|
+
for m in enum.get("members", [])
|
|
586
|
+
]
|
|
587
|
+
|
|
588
|
+
def enum_has_member(self, name: str, member: str) -> bool:
|
|
589
|
+
"""Check whether an enum contains a specific member."""
|
|
590
|
+
self._load()
|
|
591
|
+
enum = self._enums.get(name)
|
|
592
|
+
if enum is None:
|
|
593
|
+
return False
|
|
594
|
+
members = enum.get("members", [])
|
|
595
|
+
member_lower = member.lower()
|
|
596
|
+
return any(
|
|
597
|
+
(m["name"] if isinstance(m, dict) else str(m)).lower() == member_lower
|
|
598
|
+
for m in members
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
# ------------------------------------------------------------------
|
|
602
|
+
# CLI
|
|
603
|
+
# ------------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
def get_cli_help(self, command: str | None = None) -> Any:
|
|
606
|
+
"""Return CLI help. None -> list commands; otherwise command help text."""
|
|
607
|
+
self._load()
|
|
608
|
+
cli = self._raw.get("cli", {})
|
|
609
|
+
if command is None:
|
|
610
|
+
return list(cli.keys())
|
|
611
|
+
return cli.get(command)
|