diff --git a/CHANGELOG.md b/CHANGELOG.md index 022874184b..ddebec747d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ * Fix the bundled Pyodide URL being origin-absolute in `--no-cdn` builds, so a sub-path deployment (`flet build web --base-url myapp`) requested `/pyodide/pyodide.mjs` and got a 404 while the file sat at `/myapp/pyodide/pyodide.mjs`. It now renders relative to the configured base URL, as `canvasKitBaseUrl` does. Builds without `--base-url` render exactly the same URL as before by @FeodorFitsner. +* Fix integration tests failing to start when the host Python environment carries IDE configuration: `flutter test` exited with code 79 and "No tests were found" while the `flet_app` fixture failed during setup. `FletTestApp` launched the Flutter test process with the host environment inherited wholesale, and the interpreter embedded in the app under test reads `PYTHONPATH`/`PYTHONHOME` at initialization - so the debugger and `sitecustomize` paths PyCharm injects landed on the packaged app's `sys.path` and killed it before it could connect to `RemoteTester`. Since the failure happened inside the app rather than in the test process, it surfaced only as a Flutter exit code, which made it look like the tests themselves were missing. The Flutter subprocess now gets an explicit environment with `PYTHONPATH`, `PYTHONHOME` and `PYTHONEXECUTABLE` removed and `PYTHONNOUSERSITE=1` set - user site-packages is opt-out, so a host `~/.local/lib/pythonX.Y/site-packages` matching the embedded interpreter's version leaks in the same way. `PATH`, and every `FLET_*` and `SERIOUS_PYTHON_*` variable the native build phase needs, are untouched ([#6747](https://github.com/flet-dev/flet/pull/6747)) by @PythBuster. + ### Improvements * `flet build web` and `flet publish` no longer bundle CanvasKit and Pyodide when CDN mode is on (the default), taking a minimal web build from **71 MB to 19 MB**. In CDN mode Flutter loads CanvasKit from `gstatic.com` and Flet points `pyodideUrl` at jsdelivr, so both copies were dead weight the browser never requested — yet `flutter build web` always emits `canvaskit/` (~37 MB), and `ensure_pyodide()` ran unconditionally in both commands, downloading and copying a further ~15 MB. Neither is fetched, so nothing about how a CDN-mode app loads changes; verified with a network log showing the built app pulling `chromium/canvaskit.{js,wasm}` from gstatic and the full Pyodide runtime from jsdelivr, with no request to a local `canvaskit/` or `pyodide/` path. `--no-cdn` (or `[tool.flet.web] cdn = false`) still bundles everything and is unchanged. `flet build` also clears a `pyodide/` left in the reused Flutter project by an earlier `--no-cdn` build, so switching modes doesn't silently keep shipping it by @FeodorFitsner. diff --git a/sdk/python/packages/flet/src/flet/testing/flet_test_app.py b/sdk/python/packages/flet/src/flet/testing/flet_test_app.py index 4d2d7e2238..bdbee946be 100644 --- a/sdk/python/packages/flet/src/flet/testing/flet_test_app.py +++ b/sdk/python/packages/flet/src/flet/testing/flet_test_app.py @@ -18,6 +18,7 @@ from flet.controls.control import Control from flet.testing.remote_tester import RemoteTester from flet.testing.tester import Tester +from flet.utils.environment import without_host_python_config from flet.utils.network import get_free_tcp_port from flet.utils.platform_utils import get_bool_env_var @@ -314,19 +315,16 @@ async def main(page: ft.Page): f"--dart-define=FLET_TEST_ASSETS_DIR={self.__assets_dir}" ] - # Do not leak host-Python configuration into Flutter's embedded Python - # IDEs such as PyCharm add debugger/sitecustomize modules through - # PYTHONPATH, which can break the packaged integration-test app. - flutter_env = os.environ.copy() - flutter_env.pop("PYTHONPATH", None) - flutter_env.pop("PYTHONHOME", None) - self.__flutter_process = await asyncio.create_subprocess_exec( *flutter_args, cwd=str(self.__flutter_app_dir), stdout=stdout, stderr=stderr, - env=flutter_env, + # `flutter test` builds and runs the app under test, which embeds + # its own interpreter - the host's Python configuration must not + # reach it. PATH and the FLET_*/SERIOUS_PYTHON_* variables that + # `flet test` sets for the native build phase are preserved. + env=without_host_python_config(), ) if self.__flutter_process.stdout is not None: diff --git a/sdk/python/packages/flet/src/flet/utils/environment.py b/sdk/python/packages/flet/src/flet/utils/environment.py new file mode 100644 index 0000000000..fd6f3a6006 --- /dev/null +++ b/sdk/python/packages/flet/src/flet/utils/environment.py @@ -0,0 +1,38 @@ +import os +from collections.abc import Mapping +from typing import Optional + +# Variables through which a host interpreter's configuration reaches any Python +# started underneath it. `PYTHONNOUSERSITE` is absent on purpose: user +# site-packages is opt-out, so it has to be *set* rather than removed. +_HOST_PYTHON_CONFIG_VARS = ("PYTHONPATH", "PYTHONHOME", "PYTHONEXECUTABLE") + + +def without_host_python_config( + env: Optional[Mapping[str, str]] = None, +) -> dict[str, str]: + """ + Copy `env` (defaults to `os.environ`) with this process's Python + configuration removed, for handing to a child that embeds its own + interpreter. + + An embedded interpreter reads `PYTHONPATH`/`PYTHONHOME` at initialization, + so anything the host has on them - notably the debugger and + `sitecustomize` paths an IDE injects, PyCharm being the usual source - + lands on the child's `sys.path` and is imported at its startup, where those + modules do not belong and typically kill it. `PYTHONEXECUTABLE` is dropped + for the same reason: macOS framework builds use it to seed + `sys.executable`, and the host's value points at the host's interpreter. + + `PYTHONNOUSERSITE` is set rather than removed, because user site-packages + is opt-out: leaving it unset lets a host `~/.local/lib/pythonX.Y/site-packages` + whose version happens to match the embedded interpreter's leak in the same + way. + + Every other variable is preserved. + """ + result = dict(os.environ if env is None else env) + for name in _HOST_PYTHON_CONFIG_VARS: + result.pop(name, None) + result["PYTHONNOUSERSITE"] = "1" + return result diff --git a/sdk/python/packages/flet/tests/test_environment.py b/sdk/python/packages/flet/tests/test_environment.py new file mode 100644 index 0000000000..f7bbc51c27 --- /dev/null +++ b/sdk/python/packages/flet/tests/test_environment.py @@ -0,0 +1,70 @@ +import os +from unittest.mock import patch + +from flet.utils.environment import without_host_python_config + +# A host environment as an IDE-launched pytest run sees it: PyCharm's debugger +# and sitecustomize helpers on PYTHONPATH, a Homebrew interpreter's PYTHONHOME, +# plus the variables `flet test` sets for the native build phase. +HOST_ENV = { + "PATH": "/usr/local/bin:/usr/bin", + "PYTHONPATH": "/Applications/PyCharm.app/Contents/plugins/python/helpers/pydev", + "PYTHONHOME": "/opt/homebrew/opt/python@3.13/Frameworks/Python.framework", + "PYTHONEXECUTABLE": "/opt/homebrew/bin/python3.13", + "FLET_TEST_FLUTTER_EXE": "/opt/flutter/bin/flutter", + "FLET_TEST_DEVICE_MODE": "1", + "SERIOUS_PYTHON_SITE_PACKAGES": "/tmp/app/site-packages", + "SP_NATIVE_SET": "1", +} + + +def test_host_python_config_is_stripped(): + env = without_host_python_config(HOST_ENV) + + assert "PYTHONPATH" not in env + assert "PYTHONHOME" not in env + assert "PYTHONEXECUTABLE" not in env + + +def test_user_site_packages_is_disabled(): + # Opt-out, so it must be set rather than removed - a host user site dir + # matching the embedded interpreter's version leaks in otherwise. + assert without_host_python_config(HOST_ENV)["PYTHONNOUSERSITE"] == "1" + + +def test_build_env_is_preserved(): + env = without_host_python_config(HOST_ENV) + + for name in ( + "PATH", + "FLET_TEST_FLUTTER_EXE", + "FLET_TEST_DEVICE_MODE", + "SERIOUS_PYTHON_SITE_PACKAGES", + "SP_NATIVE_SET", + ): + assert env[name] == HOST_ENV[name] + + +def test_missing_vars_are_not_an_error(): + assert without_host_python_config({"PATH": "/usr/bin"}) == { + "PATH": "/usr/bin", + "PYTHONNOUSERSITE": "1", + } + + +def test_source_mapping_is_not_mutated(): + source = dict(HOST_ENV) + without_host_python_config(source) + + assert source == HOST_ENV + + +def test_defaults_to_os_environ(): + with patch.dict(os.environ, HOST_ENV, clear=True): + env = without_host_python_config() + + assert "PYTHONPATH" not in env + assert env["PATH"] == HOST_ENV["PATH"] + # The live environment of the *host* process is left alone. + assert os.environ["PYTHONPATH"] == HOST_ENV["PYTHONPATH"] + assert "PYTHONNOUSERSITE" not in os.environ