Skip to content
Merged
8 changes: 3 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,15 @@

**Compatibility:** Apps built with `flet build` and the macOS desktop client now require macOS 12 or later, and iOS apps require iOS 15 or later. Custom build templates keep building, because Flutter 3.47 raises the deployment targets during the build; apply the same `Podfile` and Xcode project changes to skip that step. See the [macOS 12 and iOS 15 minimum](/docs/updates/breaking-changes/v1-1-0/minimum-macos-12-ios-15) guide.

### Bug fixes

* Fix `page.query` being empty in `main()` when the app is opened with a query string, e.g. `/?name=Joe`, and percent-encoded values such as `%26` being decoded twice ([#5898](https://github.com/flet-dev/flet/issues/5898), [#6942](https://github.com/flet-dev/flet/pull/6942)) by @ndonkoHenri.
* Fix `TemplateRoute.match()` failing to match a route with a query string or fragment, e.g. `/store?ref=ad` against `/store`, or capturing them in the last parameter ([#5898](https://github.com/flet-dev/flet/issues/5898), [#6942](https://github.com/flet-dev/flet/pull/6942)) by @ndonkoHenri.
* Fix `flet run` ignoring the `FLET_ASSETS_DIR` environment variable and the app's own `ft.run(assets_dir=...)` ([#5631](https://github.com/flet-dev/flet/issues/5631), [#6935](https://github.com/flet-dev/flet/pull/6935)) by @ndonkoHenri.

### Bug fixes

* Fix `requests.Session` ignoring its `verify` setting in apps built with `flet build` ([#5695](https://github.com/flet-dev/flet/issues/5695), [#6936](https://github.com/flet-dev/flet/pull/6936)) by @ndonkoHenri.

### Documentation

* Group the Reference > Controls section of the docs into Core, Material, Cupertino, Shadcn and Extensions ([#6918](https://github.com/flet-dev/flet/pull/6918)) by @InesaFitsner.
* Document reading query string parameters with `page.query` in the [Navigation and Routing](/docs/cookbook/navigation-and-routing#query-strings) guide, and add a `QueryString` reference page ([#5898](https://github.com/flet-dev/flet/issues/5898), [#6942](https://github.com/flet-dev/flet/pull/6942)) by @ndonkoHenri.

## 1.0.4

Expand Down
5 changes: 3 additions & 2 deletions sdk/python/packages/flet/src/flet/controls/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -868,7 +868,6 @@ def before_event(self, e: ControlEvent):
if self.__last_route == e.route:
return False
self.__last_route = e.route
self.query()

elif isinstance(e, ViewPopEvent | ViewsPopUntilEvent):
for v in unwrap_component(self.views):
Expand Down Expand Up @@ -1312,7 +1311,9 @@ def session(self) -> "Session":
@property
def query(self) -> QueryString:
"""
The query parameters of the current page.
The query string parameters of the current :attr:`route`.

See :class:`~flet.QueryString`.
"""
return self.__query

Expand Down
104 changes: 40 additions & 64 deletions sdk/python/packages/flet/src/flet/controls/query_string.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import re
import urllib.parse
import weakref

Expand All @@ -23,63 +22,43 @@ def _decode_url_component(self, url: str) -> str:
"""
return urllib.parse.unquote(url)

def _is_encoded(self) -> bool:
"""
Function returns True if URL is already encoded
"""
if "?" in self.url:
q_result = self._querystring_part()
return (
self._decode_url_component(
self.url[q_result.start() + 1 : q_result.end()]
)
!= self.url[q_result.start() + 1 : q_result.end()]
)

def _querystring_part(self, url_string: bool = False):
"""
Function sliced url part and returns querystring part.\n Use case: checking \
querystring part for encode, assigning decoded value
"""
pattern = re.compile(r"\?[\w\D]+")
data = pattern.search(self.url)
return data if url_string is False else self.url[data.start() + 1 : data.end()]


class QueryString(UrlComponents):
"""
Note: `QueryString` class is meant to be for internal use inside of page. Hence, \
methods such as `get()` or `to_dict()` must be\n called from `page` object\n
The query string of the current page route, available as :attr:`flet.Page.query`.

Constructor:
`page` takes `Page` class an argument and extracts URL automatically\n
It is parsed from :attr:`flet.Page.route` on every access, so it can be read
in `main()` as well as in a :attr:`flet.Page.on_route_change` handler. For
example, with the route `/products?id=1&sort=price`:

Methods:
Public:
`get()` method takes `key` an argument and returns value according to
key. (Ex: .../?name=Joe -> `get('name')` -> `Joe`)\n
`to_dict` returns all the key-value pairs of querystring as a `dict`\n
`path` returns url path (Ex: .../products?id=1 -> /products)

Private(meant to be used only inside of page class):
`post()` method takes key-value pair as an argument and returns
proceeded querystring ready to be merged with URL
- `page.query.get("id")` returns `"1"`;
- `page.query.to_dict` returns `{"id": "1", "sort": "price"}`;
- `page.query.path` returns `"/products"`.

Values are always strings. To navigate to a route with a query string, pass
the parameters as keyword arguments to :meth:`flet.Page.navigate` or
:meth:`flet.Page.push_route`.
"""

def __init__(self, page):
self.__page = weakref.ref(page)
self.url = None

def get(self, key: str) -> str:
"""
Return the query parameter value for `key` from the current URL.
Return the value of the query parameter `key` in the current page route.

Args:
key: The name of the query parameter.

Returns:
The decoded value. When `key` appears more than once, its last
non-empty value.

Raises:
KeyError: If `key` does not exist in the parsed query parameters.
KeyError: If the route has no query parameter `key` with a non-empty
value.
"""
self._data = self.to_dict
return self._data[key]
return self.to_dict[key]

def post(self, kwargs: dict):
"""
Expand All @@ -93,34 +72,31 @@ def post(self, kwargs: dict):
@property
def to_dict(self) -> dict:
"""
Parse the current URL query component into a dictionary.
Parse the query component of the current page route into a dictionary.

The route is read on every access, so the result always matches
:attr:`flet.Page.route`. Keys and values are percent-decoded, with `+`
decoded as a space. Parameters without a value, such as `debug` in
`?debug` or `?debug=`, are left out. When a key appears more than once,
its last non-empty value is returned.
"""
self._data = urllib.parse.urlparse(self.url).query
return dict(urllib.parse.parse_qsl(self._data))
return dict(urllib.parse.parse_qsl(self._split_route()[1]))

# Path
@property
def path(self):
def path(self) -> str:
"""
Return the URL path, normalizing hash-style routes when present.
Return the path component of the current page route, without the query
string and fragment.
"""
self._updated_url = self.url.replace("#/", "") if "#" in self.url else self.url
return urllib.parse.urlparse(self._updated_url).path
return self._split_route()[0]

def __call__(self):
def _split_route(self) -> tuple[str, str]:
"""
Call dunder method updates url after updating `Page`
Split the current page route into its path and query string, dropping a
`#` fragment.
"""
if page := self.__page():
self.url = page.url + page.route

# Checking if self.url is encoded and decoding it accordingly
if self._is_encoded():
self.url = (
page.url
+ urllib.parse.urlparse(self.url).path
+ "?"
+ self._decode_url_component(
self._querystring_part(url_string=True)
)
)
page = self.__page()
route = (page.route if page else None) or ""
path, _, query = route.partition("#")[0].partition("?")
return path, query
11 changes: 8 additions & 3 deletions sdk/python/packages/flet/src/flet/controls/template_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ class TemplateRoute:
Parameter attributes captured by a previous match are reset to `None` before
each new matching attempt, preventing stale values from leaking between
checks.

Only the path of the route is matched; a query string and a `#` fragment are
ignored, so `/books/42?tab=reviews` matches `/books/:id` with `id` set to
`"42"`. Read query string parameters with :attr:`flet.Page.query`.
"""

def __init__(self, route: str) -> None:
Expand All @@ -22,9 +26,10 @@ def __init__(self, route: str) -> None:

def match(self, route_template: str) -> bool:
"""
Tries to match this instance route against a route template.
Tries to match the path of this instance route against a route template.

The template is compiled with `repath.pattern()`. If matching succeeds,
The template is compiled with `repath.pattern()` and matched against the
route without its query string and fragment. If matching succeeds,
named parameters are stored and also assigned as attributes on this
object (for example, `self.user_id`). If matching fails, previously
captured attributes remain cleared.
Expand All @@ -42,7 +47,7 @@ def match(self, route_template: str) -> bool:

# perform new match
pattern = repath.pattern(route_template)
match = re.match(pattern, self.route)
match = re.match(pattern, self.route.partition("#")[0].partition("?")[0])

if match:
self.__last_params = match.groupdict()
Expand Down
115 changes: 115 additions & 0 deletions sdk/python/packages/flet/tests/test_query_string.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Tests for `page.query`, which parses the query string of `page.route` on read."""

import gc

import pytest

from flet.messaging.connection import Connection
from flet.messaging.session import Session
from flet.pubsub.pubsub_hub import PubSubHub


class _Connection(Connection):
def __init__(self, page_url: str):
super().__init__()
self.pubsubhub = PubSubHub()
self.page_url = page_url

def send_message(self, message):
pass


def _open(route: str, page_url: str = "ws://127.0.0.1:8550") -> Session:
"""Return a session whose page was opened at `route`, as on client connect."""
session = Session(_Connection(page_url))
session.apply_page_patch({"route": route})
return session


async def _change_route(session: Session, route: str):
"""Change the route the way the client does: patch it, then fire the event."""
session.apply_page_patch({"route": route})
await session.page._trigger_event("route_change", {"route": route})


@pytest.mark.parametrize(
"page_url",
["ws://127.0.0.1:8550", "/tmp/flet.sock", "http://127.0.0.1:8550/?name=Joe"],
ids=["web", "desktop", "pyodide"],
)
@pytest.mark.asyncio
async def test_query_follows_the_current_route(page_url):
"""`page.query` follows `page.route` in `main()`, `on_route_change` and later."""
session = _open("/products?name=Joe&id=1", page_url)
page = session.page

assert page.query.get("name") == "Joe"
assert page.query.to_dict == {"name": "Joe", "id": "1"}
assert page.query.path == "/products"

seen = []
page.on_route_change = lambda e: seen.append((page.query.path, page.query.to_dict))
await _change_route(session, "/store?name=Ann")
assert seen == [("/store", {"name": "Ann"})]

page.route = "/"
assert page.query.path == "/"
assert page.query.to_dict == {}
with pytest.raises(KeyError):
page.query.get("name")


@pytest.mark.parametrize(
"route, path, params",
[
("/a?next=/b?c=1", "/a", {"next": "/b?c=1"}),
("/a?tag=x&tag=y&tag=&debug", "/a", {"tag": "y"}),
("/books/42?tab=reviews#details", "/books/42", {"tab": "reviews"}),
("/books/42#details?tab=reviews", "/books/42", {}),
("//books/42?x=1", "//books/42", {"x": "1"}),
],
)
@pytest.mark.asyncio
async def test_query_splits_path_query_and_fragment(route, path, params):
"""The route splits at the first `?`, without the fragment or blank values."""
session = _open("/")
await _change_route(session, route)

assert session.page.query.path == path
assert session.page.query.to_dict == params


@pytest.mark.asyncio
async def test_push_route_parameters_read_back_decoded_once(monkeypatch):
"""Parameters passed to `push_route` read back from `page.query` as strings."""
session = _open("/")
page = session.page
pushed = []

async def invoke_method(method_name, arguments=None, timeout=None):
pushed.append(arguments["route"])

monkeypatch.setattr(page, "_invoke_method", invoke_method)
await page.push_route(
"/search", q="salt & pepper", tag="c++", next="/cart#pay", limit=20
)
await _change_route(session, pushed[0])

assert page.query.path == "/search"
assert page.query.to_dict == {
"q": "salt & pepper",
"tag": "c++",
"next": "/cart#pay",
"limit": "20",
}


def test_query_is_empty_once_the_page_is_gone():
"""A `QueryString` that outlives its page has an empty path and no parameters."""
session = _open("/products?name=Joe")
query = session.page.query
del session
gc.collect()

assert query.path == ""
assert query.to_dict == {}
35 changes: 35 additions & 0 deletions sdk/python/packages/flet/tests/test_template_route.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Tests for `TemplateRoute`, which matches route templates against a route path."""

import pytest

import flet as ft


@pytest.mark.parametrize(
"route, template, params",
[
("/store?ref=ad", "/store", {}),
("/books/42?tab=reviews", "/books/:id", {"id": "42"}),
("/books/42/?tab=reviews", "/books/:id", {"id": "42"}),
("/books/42?next=/books/43", "/books/:id", {"id": "42"}),
("/books/42#details?tab=reviews", "/books/:id", {"id": "42"}),
("/books?tab=reviews", "/books/:id?", {"id": None}),
("//books/42?x=1", "//books/:id", {"id": "42"}),
],
)
def test_template_route_matches_the_path_only(route, template, params):
"""A route matches the template of its path whatever its query and fragment."""
troute = ft.TemplateRoute(route)

assert troute.match(template)
assert {k: getattr(troute, k) for k in params} == params
assert troute.route == route


def test_template_route_clears_parameters_on_a_failed_match():
"""A failed match returns `False` and resets the previous match's parameters."""
troute = ft.TemplateRoute("/books/42?next=/store")
assert troute.match("/books/:id")

assert not troute.match("/store")
assert troute.id is None
Loading
Loading