Skip to content
15 changes: 15 additions & 0 deletions .codex/skills/prepare-flet-release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,26 @@ description: Use when asked to prepare new Flet release by bumping versions and
* Add a new entry into packages/flet/CHANGELOG.md from a git log since the last release. Go through all commits and collect all mentioned issues and pull requests. There could be several issues done in a single PR (commit) - group them by creating a single descriptive change/fix/feature item and put all issues and PR links in `[#<issue_number>](<issue_url>)` format in braces next to it. Do not add chore/trivial/duplicate items.
Every changelog item must include both related issue link(s) and PR link(s) when available (issue first, PR second). If no issue exists, include PR link(s) only.
Also include issue-only items when a change was done via direct commit without PR (for example, an issue referenced in commit context but no PR exists).
Every changelog item must include author attribution in GitHub style: `by @<github_login>`.
Place attribution at the end of each item after links, for example:
`* Added feature X ([#123](...), [#456](...)) by @contributor.`
Use PR author login for PR-based items. For issue-only direct-commit items, use the commit author GitHub login if available.
If one item groups multiple PRs by different authors, attribute all relevant authors:
`by @user1, @user2`.
As it's a Flutter package prefer items having changes on Flutter side.
* Add a new entry into /CHANGELOG.md. Do not add chore/trivial/duplicate items, add "worth while" items with related issue or PR.
Every changelog item must include both related issue link(s) and PR link(s) when available (issue first, PR second). If no issue exists, include PR link(s) only.
Also include issue-only items when a change was done via direct commit without PR (for example, an issue referenced in commit context but no PR exists).
Every changelog item must include author attribution in GitHub style: `by @<github_login>`.
Use PR author login for PR-based items. For issue-only direct-commit items, use the commit author GitHub login if available.
* Scan all changelogs for `Unreleased` sections, not only the root ones:
* `/CHANGELOG.md`
* `packages/flet/CHANGELOG.md`
* `sdk/python/packages/*/CHANGELOG.md`
Recommended check command:
`rg -n "^##\\s*\\[?Unreleased\\]?|^##\\s*Unreleased" -S CHANGELOG.md packages/flet/CHANGELOG.md sdk/python/packages/*/CHANGELOG.md`
* If any changelog has an `Unreleased` section, convert that section into the new release section (`## {new_version}`), preserving and re-sorting its items. Do not leave duplicate release content in both `Unreleased` and `{new_version}`.
This conversion must be done for every matched changelog from the scan above.
* Sort items in changelogs as following:
* New features
* Improvements
Expand Down
42 changes: 35 additions & 7 deletions sdk/python/packages/flet/src/flet/auth/__init__.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
from typing import TYPE_CHECKING, Any

from flet.auth.authorization import Authorization
from flet.auth.authorization_service import AuthorizationService
from flet.auth.group import Group
from flet.auth.oauth_provider import OAuthProvider
from flet.auth.oauth_token import OAuthToken
from flet.auth.providers import (
Auth0OAuthProvider,
AzureOAuthProvider,
GitHubOAuthProvider,
GoogleOAuthProvider,
)
from flet.auth.user import User

if TYPE_CHECKING:
from flet.auth.authorization_service import AuthorizationService
from flet.auth.providers import (
Auth0OAuthProvider,
AzureOAuthProvider,
GitHubOAuthProvider,
GoogleOAuthProvider,
)

__all__ = [
"Auth0OAuthProvider",
"Authorization",
Expand All @@ -23,3 +27,27 @@
"OAuthToken",
"User",
]


def __getattr__(name: str) -> Any:
if name == "AuthorizationService":
from flet.auth.authorization_service import AuthorizationService

return AuthorizationService
if name == "Auth0OAuthProvider":
from flet.auth.providers.auth0_oauth_provider import Auth0OAuthProvider

return Auth0OAuthProvider
if name == "AzureOAuthProvider":
from flet.auth.providers.azure_oauth_provider import AzureOAuthProvider

return AzureOAuthProvider
if name == "GitHubOAuthProvider":
from flet.auth.providers.github_oauth_provider import GitHubOAuthProvider

return GitHubOAuthProvider
if name == "GoogleOAuthProvider":
from flet.auth.providers.google_oauth_provider import GoogleOAuthProvider

return GoogleOAuthProvider
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
16 changes: 10 additions & 6 deletions sdk/python/packages/flet/src/flet/auth/authorization_service.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
import json
import secrets
import time
from typing import Optional

import httpx
from oauthlib.oauth2 import WebApplicationClient
from oauthlib.oauth2.rfc6749.tokens import OAuth2Token
from collections.abc import Mapping
from typing import Any, Optional

from flet.auth.authorization import Authorization
from flet.auth.oauth_provider import OAuthProvider
Expand Down Expand Up @@ -94,6 +91,7 @@ def get_authorization_data(self) -> tuple[str, str]:
Returns:
A tuple of `(authorization_url, state)`.
"""
from oauthlib.oauth2 import WebApplicationClient

self.state = secrets.token_urlsafe(16)
client = WebApplicationClient(self.provider.client_id)
Expand All @@ -118,6 +116,8 @@ async def request_token(self, code: str):
Raises:
httpx.HTTPStatusError: If token endpoint returns a non-success status.
"""
import httpx
from oauthlib.oauth2 import WebApplicationClient

client = WebApplicationClient(self.provider.client_id)
data = client.prepare_request_body(
Expand Down Expand Up @@ -165,7 +165,7 @@ async def __fetch_user_and_groups(self):
self.__token.access_token
)

def __convert_token(self, t: OAuth2Token):
def __convert_token(self, t: Mapping[str, Any]):
"""
Convert oauthlib token mapping to [`OAuthToken`][(p).oauth_token.].
Expand Down Expand Up @@ -204,6 +204,9 @@ async def __refresh_token(self):
):
return None

import httpx
from oauthlib.oauth2 import WebApplicationClient

assert self.__token is not None
client = WebApplicationClient(self.provider.client_id)
data = client.prepare_refresh_body(
Expand Down Expand Up @@ -239,6 +242,7 @@ async def __get_user(self):
Raises:
httpx.HTTPStatusError: If user endpoint request fails.
"""
import httpx

assert self.__token is not None
assert self.provider.user_endpoint is not None
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import json
from typing import Optional

import httpx

from flet.auth.group import Group
from flet.auth.oauth_provider import OAuthProvider
from flet.auth.user import User
Expand Down Expand Up @@ -40,6 +38,8 @@ async def _fetch_groups(self, access_token: str) -> list[Group]:
Returns:
A list of [`Group`][flet.auth.] mapped from `/user/teams`.
"""
import httpx

async with httpx.AsyncClient(follow_redirects=True) as client:
teams_resp = await client.send(
httpx.Request(
Expand Down Expand Up @@ -71,6 +71,8 @@ async def _fetch_user(self, access_token: str) -> Optional[User]:
A [`User`][flet.auth.] built from `/user`; its `email` is populated
from the primary address in `/user/emails` when available.
"""
import httpx

async with httpx.AsyncClient(follow_redirects=True) as client:
user_resp = await client.send(
httpx.Request(
Expand Down
6 changes: 3 additions & 3 deletions sdk/python/packages/flet/src/flet/controls/object_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -1081,7 +1081,7 @@ def _compare_dataclasses(self, parent, path, src, dst, frozen):
parent,
)
for field in dataclasses.fields(dst):
if "skip" not in field.metadata:
if not field.metadata.get("skip", False):
old = getattr(src, field.name)
new = getattr(dst, field.name)
if field.name.startswith("on_") and field.metadata.get(
Expand Down Expand Up @@ -1257,7 +1257,7 @@ def _configure_dataclass(self, item, parent, frozen, configure_setattr_only=Fals
# recurse through fields
if not configure_setattr_only:
for field in dataclasses.fields(item):
if "skip" not in field.metadata:
if not field.metadata.get("skip", False):
yield from self._configure_dataclass(
getattr(item, field.name), item, frozen
)
Expand Down Expand Up @@ -1290,7 +1290,7 @@ def _removed_controls(self, item, recurse):
elif recurse:
# recurse through fields
for field in dataclasses.fields(item):
if "skip" not in field.metadata:
if not field.metadata.get("skip", False):
yield from self._removed_controls(
getattr(item, field.name),
recurse,
Expand Down
98 changes: 98 additions & 0 deletions sdk/python/packages/flet/tests/test_auth_lazy_imports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import builtins
import importlib
import sys

import pytest


def _clear_flet_modules():
"""Remove loaded `flet` modules to force fresh import behavior in each test."""
for module_name in list(sys.modules):
if module_name == "flet" or module_name.startswith("flet."):
del sys.modules[module_name]


@pytest.fixture
def fresh_flet_modules():
"""Run a test with a clean flet import state, then restore previous modules."""
original_flet_modules = {
name: module
for name, module in sys.modules.items()
if name == "flet" or name.startswith("flet.")
}
_clear_flet_modules()
try:
yield
finally:
_clear_flet_modules()
sys.modules.update(original_flet_modules)


def _blocked_import_factory(blocked_modules: set[str]):
"""Create an import hook that raises for selected top-level module names."""
original_import = builtins.__import__

def blocked_import(name, globals=None, locals=None, fromlist=(), level=0):
"""Raise `ModuleNotFoundError` for blocked modules and delegate otherwise."""
top_name = name.split(".")[0]
if top_name in blocked_modules:
raise ModuleNotFoundError(f"No module named '{top_name}'", name=top_name)
return original_import(name, globals, locals, fromlist, level)

return blocked_import


def test_import_flet_without_httpx_oauthlib(monkeypatch, fresh_flet_modules):
"""Ensure `import flet` succeeds when optional auth dependencies are absent."""
monkeypatch.setattr(
builtins,
"__import__",
_blocked_import_factory({"httpx", "oauthlib"}),
)

flet = importlib.import_module("flet")

assert flet is not None
assert hasattr(flet, "Page")


def test_auth_exports_stay_importable_without_httpx_oauthlib(
monkeypatch, fresh_flet_modules
):
"""Ensure lazy `flet.auth` exports resolve without importing optional deps."""
monkeypatch.setattr(
builtins,
"__import__",
_blocked_import_factory({"httpx", "oauthlib"}),
)

auth = importlib.import_module("flet.auth")

assert auth.AuthorizationService.__name__ == "AuthorizationService"
assert auth.GitHubOAuthProvider.__name__ == "GitHubOAuthProvider"


def test_authorization_service_loads_oauthlib_on_use(monkeypatch, fresh_flet_modules):
"""Ensure oauthlib is required only when auth service logic is actually used."""
monkeypatch.setattr(
builtins,
"__import__",
_blocked_import_factory({"httpx", "oauthlib"}),
)

auth = importlib.import_module("flet.auth")
provider = auth.OAuthProvider(
client_id="client_id",
client_secret="client_secret",
authorization_endpoint="https://example.com/authorize",
token_endpoint="https://example.com/token",
redirect_url="https://example.com/callback",
)
service = auth.AuthorizationService(
provider=provider,
fetch_user=False,
fetch_groups=False,
)

with pytest.raises(ModuleNotFoundError, match="oauthlib"):
service.get_authorization_data()
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@
import tracemalloc

import flet as ft
import pytest
from flet.controls.base_control import BaseControl
from flet.controls.object_patch import ObjectPatch

pytestmark = pytest.mark.skip(reason="Temporarily disabled")


def _make_tree(iteration: int, size: int = 36) -> ft.Column:
controls: list[ft.Text] = []
Expand Down