Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
## 0.86.1

### Improvements

* `flet-mcp`'s `get_api` tool now covers **top-level callables** — the app entry point (`run`), reactive hooks (`use_state`, `use_ref`, `use_effect`, …), and decorators (`component`, `memo`, `observable`) — which previously returned `not found` because only classes were indexed. A new `functions` bucket in the API builder extracts each package's re-exported public callables and renders them with a `signature:` line. `get_api` also **resolves enum member lookups inline**: `get_api("Colors", query="RED")` now returns the matching members instead of erroring with a redirect to `search_enum_members`. Both changes remove wasted agent round-trips observed in production usage by @FeodorFitsner.

### Bug fixes

* Fix the cryptic `shutil.ReadError: ... is not a zip file` failure in web apps when the app package download fails. The Pyodide worker piped the `pyfetch(app_package_url)` response straight into `unpack_archive()` without a status check, so any non-2xx response (transient server 500, expired auth 401, deleted app 404) wrote the JSON/HTML error body to a temp file and crashed while unpacking it. The worker now checks `response.ok` and raises a readable `Failed to download app package: HTTP <status> <url> — <body>` error, including the first 200 chars of the error body. Applied to both the Flet web client and the `flet build` template ([#6680](https://github.com/flet-dev/flet/pull/6680)) by @FeodorFitsner.
* Remove unused `BasePage` return type and import from `BaseControl` and `ControlEvent` ([#6606](https://github.com/flet-dev/flet/pull/6606)) by @Iaw4tch.

## 0.86.0

### New features
Expand Down
15 changes: 15 additions & 0 deletions client/web/python-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,21 @@ self.initPyodide = async function () {
if "script" not in py_args:
print("Downloading app archive")
response = await pyfetch(app_package_url)
# Fail with a readable error on a non-2xx response.
# Without this check the error body (JSON/HTML from the
# server) lands in unpack_archive and surfaces as a
# cryptic "... is not a zip file" ReadError.
if not response.ok:
_err_body = ""
try:
_err_body = (await response.text())[:200].strip()
except Exception:
pass
raise RuntimeError(
f"Failed to download app package: "
f"HTTP {response.status} {app_package_url}"
+ (f" — {_err_body}" if _err_body else "")
)
# Pick format from the URL's path extension. Pyodide's
# filename-based sniff trips over query strings like
# ?v=42. We only support zip and tar.gz (matching what
Expand Down
4 changes: 4 additions & 0 deletions packages/flet/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 0.86.1

_No changes in the `flet` Dart package; version bumped for release coordination with the web client's readable app-package download errors ([#6680](https://github.com/flet-dev/flet/pull/6680))._

## 0.86.0

_No changes in the `flet` Dart package; version bumped for release coordination with the multi-version bundled CPython support on the Python side ([#6577](https://github.com/flet-dev/flet/pull/6577))._
Expand Down
2 changes: 1 addition & 1 deletion packages/flet/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: flet
description: Write entire Flutter app in Python or add server-driven UI experience into existing Flutter app.
homepage: https://flet.dev
repository: https://github.com/flet-dev/flet/tree/main/packages/flet
version: 0.86.0
version: 0.86.1

# Supported platforms
platforms:
Expand Down
61 changes: 49 additions & 12 deletions sdk/python/packages/flet-mcp/src/flet_mcp/api_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@

# Buckets in preference order for ambiguous names, and the `kind` value
# stamped onto entries from buckets that don't carry their own.
_BUCKET_RANK = {"controls": 0, "types": 1, "events": 2, "enums": 3}
_BUCKET_KIND = {"types": "type", "events": "event"}
_BUCKET_RANK = {"controls": 0, "types": 1, "events": 2, "functions": 3, "enums": 4}
_BUCKET_KIND = {"types": "type", "events": "event", "functions": "function"}

# Sphinx roles in docstrings (":attr:`style`", ":class:`~flet.Text`") are
# docs-site markup — render them as plain backticked names.
Expand Down Expand Up @@ -191,6 +191,21 @@ def render_text(hit: dict[str, Any]) -> str:
if hit.get("note"):
lines.append(f"note: {hit['note']}")

# Top-level callables (ft.run, hooks, decorators): render a signature.
if hit.get("kind") == "function":
params = ", ".join(
a["name"]
+ (f": {a['type']}" if a.get("type") else "")
+ (f" = {a['default']}" if a.get("default") else "")
for a in hit.get("args") or []
)
prefix = "async " if hit.get("async") else ""
sig = f"{prefix}{name}({params})"
if hit.get("return_type"):
sig += f" -> {hit['return_type']}"
lines.append(f"signature: {sig}")
return "\n".join(lines)

if hit.get("kind") == "large_enum":
lines.append(f"total members: {hit.get('total_members')}")
samples = [
Expand Down Expand Up @@ -240,6 +255,7 @@ def __init__(self) -> None:
self._events: dict[str, dict] | None = None
self._types: dict[str, dict] | None = None
self._enums: dict[str, dict] | None = None
self._functions: dict[str, dict] | None = None
self._by_name: dict[str, list[tuple[str, dict]]] | None = None

def _load(self) -> dict[str, Any]:
Expand All @@ -251,12 +267,13 @@ def _load(self) -> dict[str, Any]:
self._events = {e["name"]: e for e in self._raw.get("events", [])}
self._types = {t["name"]: t for t in self._raw.get("types", [])}
self._enums = {e["name"]: e for e in self._raw.get("enums", [])}
self._functions = {f["name"]: f for f in self._raw.get("functions", [])}
# Names are NOT unique across (or even within) buckets — e.g.
# the Text control vs the canvas Text shape. Keep every entry
# per name so `get` can rank candidates instead of letting the
# last dict insert silently win.
self._by_name = {}
for bucket in ("controls", "types", "events", "enums"):
for bucket in ("controls", "types", "events", "functions", "enums"):
for e in self._raw.get(bucket, []):
self._by_name.setdefault(e["name"], []).append((bucket, e))
return self._raw
Expand Down Expand Up @@ -346,9 +363,11 @@ def get(
Member docstrings (properties/events/methods) are trimmed to their
first sentence. Pass `member` for one member's full entry, or `query`
for a case-insensitive substring filter over member names (`member`
wins if both are given). Enum responses use the same truncation as
`get_enum` for large enums; `member`/`query` don't apply to enums —
use search_enum_members / enum_has_member there.
wins if both are given). On an enum, `member`/`query` resolve inline
to a ranked member search (same result as `search_enum_members`); a
bare enum name returns the same truncation as `get_enum` for large
enums. Top-level callables (functions bucket) return a `function`
entry with no members.

Names are not globally unique (e.g. the Text control vs the canvas
Text shape): the best candidate wins and the response notes the
Expand All @@ -360,16 +379,34 @@ def get(
bucket, entry = cands[0]

if bucket == "enums":
if member is not None or query is not None:
ename = entry["name"]
# Resolve enum members inline rather than bouncing the caller to
# search_enum_members: production traces show models burning a
# round-trip per value on get_api("Colors", query="RED"). `member`
# and `query` are both treated as a member search here.
term = member if member is not None else query
if term is not None:
matches = self.search_enum_members(ename, term, limit=20)
note = (
f"members of {ename} matching '{term}'"
if matches
else f"no {ename} member matches '{term}' — try "
"search_enum_members for a broader search"
)
return {
"error": (
f"'{name}' is an enum — use search_enum_members or "
"enum_has_member to look up its members"
)
"kind": "enum",
"name": ename,
"note": note,
"members": [{"name": m} for m in matches],
}
enum = self.get_enum(entry["name"])
enum = self.get_enum(ename)
return {"kind": enum.get("kind", "enum"), **enum}

if bucket == "functions":
# Top-level callables (ft.run, hooks, decorators) carry no
# members — return the entry as-is for render_text.
return {"kind": "function", **entry}

hit = entry if bucket == "controls" else {"kind": _BUCKET_KIND[bucket], **entry}
if member is not None:
return self._get_member(hit, name, member)
Expand Down
64 changes: 64 additions & 0 deletions sdk/python/packages/flet-mcp/src/flet_mcp/build/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,62 @@ def _extract_methods(cls: griffe.Class) -> list[dict[str, Any]]:
return methods


def _function_entry(func: griffe.Function, name: str) -> dict[str, Any]:
"""Serialize a top-level callable (function/decorator/hook)."""
args: list[dict[str, Any]] = []
for param in func.parameters:
if param.name in ("self", "cls"):
continue
args.append(
{
"name": param.name,
"type": _annotation_str(param.annotation),
"default": _default_str(param.default),
}
)
module_path = func.canonical_path.rsplit(".", 1)[0]
entry: dict[str, Any] = {
"name": name,
"module": module_path,
"package": _package_from_module(module_path),
"kind": "function",
"summary": _first_line(func.docstring),
"docstring": _full_docstring(func.docstring),
"args": args,
"return_type": _annotation_str(func.annotation),
}
if "async" in func.labels:
entry["async"] = True
return entry


def _collect_public_functions(
module: griffe.Module, functions: list[dict], seen: set[str]
) -> None:
"""Index a package's top-level public callables — the module-level
functions re-exported from the package root: the app entry point
(``run``/``app``), reactive hooks (``use_state``/``use_ref``/…), and
decorators (``component``/``memo``/``observable``). Only the top module's
direct members are scanned — that is the public surface; nested/internal
helpers are skipped. Re-export aliases are resolved to their target."""
for name, obj in module.members.items():
if name.startswith("_"):
continue
target: Any = obj
if isinstance(obj, griffe.Alias):
try:
target = obj.final_target
except Exception:
continue
if not isinstance(target, griffe.Function):
continue
cp = target.canonical_path
if cp in seen:
continue
seen.add(cp)
functions.append(_function_entry(target, name))


def _extract_enum_members(cls: griffe.Class) -> list[dict[str, str]]:
members: list[dict[str, str]] = []
for name, member in cls.members.items():
Expand Down Expand Up @@ -563,6 +619,7 @@ def build_api(output_path: Path, packages: list[str] | None = None) -> dict[str,
events: list[dict] = []
types: list[dict] = []
enums: list[dict] = []
functions: list[dict] = []

loaded: list[griffe.Module] = []
for pkg in packages:
Expand All @@ -581,6 +638,11 @@ def build_api(output_path: Path, packages: list[str] | None = None) -> dict[str,
for module in loaded:
_walk_module(module, controls, events, types, enums)

# Pass 3: top-level public callables (entry point, hooks, decorators).
seen_functions: set[str] = set()
for module in loaded:
_collect_public_functions(module, functions, seen_functions)

# Inject Icons and CupertinoIcons from JSON files (not Python Enums)
_inject_icon_enums(enums)

Expand All @@ -591,6 +653,7 @@ def build_api(output_path: Path, packages: list[str] | None = None) -> dict[str,
"events": events,
"types": types,
"enums": enums,
"functions": functions,
"cli": cli_help,
}

Expand All @@ -602,6 +665,7 @@ def build_api(output_path: Path, packages: list[str] | None = None) -> dict[str,
"events": len(events),
"types": len(types),
"enums": len(enums),
"functions": len(functions),
"cli_commands": len(cli_help),
}
logger.info("API build stats: %s", stats)
Expand Down
14 changes: 10 additions & 4 deletions sdk/python/packages/flet-mcp/src/flet_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,10 @@ def get_api(

Looks across visual controls, non-visual services, dataclass types
(ButtonStyle, Padding, TextStyle, Border, Theme, ColorScheme, ...),
event classes, and enums (MainAxisAlignment, TextAlign, Icons, ...).
event classes, enums (MainAxisAlignment, TextAlign, Icons, ...), and
top-level callables — the app entry point (run), reactive hooks
(use_state, use_ref, use_effect, ...), and decorators (component,
memo, observable), rendered with a `signature:` line.

This is the primary verification tool — when you have a name in mind,
call this first. A "not found" response is a definitive negative: the
Expand Down Expand Up @@ -414,9 +417,12 @@ def get_api(
vs the canvas Text shape) — the primary one is returned and the
`note:` line lists the others with a dotted name that selects
them, e.g. `get_api("canvas.Text")`.
* Neither applies to enums — use search_enum_members /
enum_has_member / find_icon there. Large enums (Icons,
CupertinoIcons) are always truncated to a sample.
* On an enum, `member`/`query` resolve inline to a ranked member
search (same result as search_enum_members) instead of erroring —
e.g. `get_api("Colors", query="RED")` lists the matching members.
For concept-based icon lookup ("delete", "user") prefer find_icon.
Large enums (Icons, CupertinoIcons) are truncated to a sample when
fetched whole.

Errors are returned as JSON objects (`{"error": ...}`), sometimes
with `available_members`/`available_files` hints.
Expand Down
60 changes: 56 additions & 4 deletions sdk/python/packages/flet-mcp/tests/test_api_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,28 @@
],
},
],
"functions": [
{
"name": "run",
"module": "flet",
"package": "flet",
"kind": "function",
"summary": "Run the app.",
"docstring": "Run the app.",
"args": [{"name": "target", "type": "Callable", "default": ""}],
"return_type": "None",
},
{
"name": "use_state",
"module": "flet.core.reactive",
"package": "flet",
"kind": "function",
"summary": "Reactive state hook.",
"docstring": "Reactive state hook.",
"args": [{"name": "initial", "type": "T", "default": ""}],
"return_type": "tuple[T, Callable]",
},
],
}


Expand All @@ -198,8 +220,9 @@ def store() -> ApiStore:
s._events = {e["name"]: e for e in RAW["events"]}
s._types = {t["name"]: t for t in RAW["types"]}
s._enums = {e["name"]: e for e in RAW["enums"]}
s._functions = {f["name"]: f for f in RAW.get("functions", [])}
s._by_name = {}
for bucket in ("controls", "types", "events", "enums"):
for bucket in ("controls", "types", "events", "functions", "enums"):
for e in RAW[bucket]:
s._by_name.setdefault(e["name"], []).append((bucket, e))
return s
Expand Down Expand Up @@ -340,9 +363,38 @@ def test_member_wins_over_query(store):
assert hit["member"]["name"] == "push_route"


def test_enum_rejects_member_and_query(store):
assert "error" in store.get("TextAlign", query="left")
assert "error" in store.get("TextAlign", member="LEFT")
def test_enum_member_and_query_resolve_inline(store):
# get_api on an enum with query/member now searches members inline
# instead of erroring (saves the caller a round-trip).
hit = store.get("TextAlign", query="left")
assert "error" not in hit
assert hit["kind"] == "enum"
assert [m["name"] for m in hit["members"]] == ["LEFT"]
# member= is treated as the same member search.
assert [m["name"] for m in store.get("TextAlign", member="LEFT")["members"]] == [
"LEFT"
]
# Works for large enums too, rendered as a member list.
assert "REMOVE" in render_text(store.get("Icons", query="remove"))


def test_enum_query_no_match_notes_search(store):
hit = store.get("TextAlign", query="zzz")
assert "error" not in hit
assert hit["members"] == []
assert "search_enum_members" in hit["note"]


def test_function_lookup_and_render(store):
hit = store.get("run")
assert hit["kind"] == "function"
text = render_text(hit)
assert text.startswith("run (function) — Run the app.")
assert "signature: run(target: Callable) -> None" in text


def test_hook_function_lookup(store):
assert store.get("use_state")["kind"] == "function"


# ---------- enum member search ranking ----------
Expand Down
3 changes: 1 addition & 2 deletions sdk/python/packages/flet/src/flet/controls/base_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@


if TYPE_CHECKING:
from .base_page import BasePage
from .keys import KeyValue
from .page import Page

Expand Down Expand Up @@ -296,7 +295,7 @@ def parent(self) -> Optional["BaseControl"]:
return parent_ref() if parent_ref else None

@property
def page(self) -> "Union[Page, BasePage]":
def page(self) -> "Page":
"""
The page to which this control belongs to.
"""
Expand Down
Loading
Loading