From 913f7feb232001e2fec050e7eb4c8befffad3d7f Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Fri, 14 Aug 2026 13:07:07 -0700 Subject: [PATCH 1/2] fix(testing): harden Flutter test env against host-Python leakage Follow-up to #6747, which stripped PYTHONPATH/PYTHONHOME from the `flutter test` subprocess so an IDE-injected debugger/sitecustomize path could not reach the interpreter embedded in the app under test. Two more host-Python knobs reach that interpreter the same way: * PYTHONEXECUTABLE - macOS framework builds use it to seed sys.executable, and a host value points at the IDE's interpreter, not the packaged app's. * PYTHONNOUSERSITE - user site-packages is opt-out, so leaving it unset lets a host ~/.local/lib/pythonX.Y/site-packages whose version matches the embedded interpreter leak in. It is now set rather than removed. The environment is built in a `_flutter_subprocess_env()` helper instead of inline at the call site, which gives the rationale one home and makes the part that is actually testable - which variables are dropped and which survive - unit-testable without launching Flutter. Adds the changelog entry #6747 shipped without. --- CHANGELOG.md | 2 + .../flet/src/flet/testing/flet_test_app.py | 35 +++++++--- .../flet/tests/test_flet_test_app_env.py | 64 +++++++++++++++++++ 3 files changed, 93 insertions(+), 8 deletions(-) create mode 100644 sdk/python/packages/flet/tests/test_flet_test_app_env.py 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..1167b62a1d 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 @@ -27,6 +27,32 @@ __all__ = ["FletTestApp"] +def _flutter_subprocess_env() -> dict[str, str]: + """ + Build the environment for the `flutter test` child process. + + Host-Python configuration must not reach the interpreter embedded in the + app under test. It reads `PYTHONPATH`/`PYTHONHOME` at initialization, so + the debugger and `sitecustomize` paths an IDE injects (PyCharm does) land + on the packaged app's `sys.path` and kill it at startup - which surfaces + as Flutter exiting with code 79 and "No tests were found", before the app + ever connects to `RemoteTester`. + + `PYTHONNOUSERSITE` is *set* rather than removed: user site-packages is + opt-out, so leaving it unset lets a version-matched host + `~/.local/lib/pythonX.Y/site-packages` leak in the same way. + + Everything else is preserved - `flutter test` needs `PATH`, and its native + build phase needs the `FLET_*` and `SERIOUS_PYTHON_*` variables that + `flet test` sets (see `_flutter_path_env` in `flet_cli.commands.test`). + """ + env = os.environ.copy() + for name in ("PYTHONPATH", "PYTHONHOME", "PYTHONEXECUTABLE"): + env.pop(name, None) + env["PYTHONNOUSERSITE"] = "1" + return env + + class DisposalMode(Enum): """ Indicates the way in which a frame is treated after being displayed. @@ -314,19 +340,12 @@ 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, + env=_flutter_subprocess_env(), ) if self.__flutter_process.stdout is not None: diff --git a/sdk/python/packages/flet/tests/test_flet_test_app_env.py b/sdk/python/packages/flet/tests/test_flet_test_app_env.py new file mode 100644 index 0000000000..7a83ed0a90 --- /dev/null +++ b/sdk/python/packages/flet/tests/test_flet_test_app_env.py @@ -0,0 +1,64 @@ +import os +from unittest.mock import patch + +from flet.testing.flet_test_app import _flutter_subprocess_env + +# 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(): + with patch.dict(os.environ, HOST_ENV, clear=True): + env = _flutter_subprocess_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. + with patch.dict(os.environ, HOST_ENV, clear=True): + assert _flutter_subprocess_env()["PYTHONNOUSERSITE"] == "1" + + +def test_build_env_is_preserved(): + with patch.dict(os.environ, HOST_ENV, clear=True): + env = _flutter_subprocess_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(): + with patch.dict(os.environ, {"PATH": "/usr/bin"}, clear=True): + env = _flutter_subprocess_env() + + assert env == {"PATH": "/usr/bin", "PYTHONNOUSERSITE": "1"} + + +def test_host_environ_is_not_mutated(): + with patch.dict(os.environ, HOST_ENV, clear=True): + _flutter_subprocess_env() + + assert os.environ["PYTHONPATH"] == HOST_ENV["PYTHONPATH"] + assert os.environ["PYTHONHOME"] == HOST_ENV["PYTHONHOME"] + assert "PYTHONNOUSERSITE" not in os.environ From 5561c64ad5a8a2b8b4247fce0f894d4cdcdd15ab Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Fri, 14 Aug 2026 13:27:25 -0700 Subject: [PATCH 2/2] fix(testing): keep the env helper out of the numpy-importing module The unit suite runs as `uv run --no-dev --group test`, whose environment has only pytest and pytest-asyncio - numpy, pillow and scikit-image live in the `dev` group. Importing `flet.testing.flet_test_app` therefore fails at collection in CI with `ModuleNotFoundError: No module named 'numpy'`, which is why no unit test imported that subtree before. Move the helper to `flet.utils.environment.without_host_python_config()`, a module with no dependencies beyond the stdlib, and have `flet_test_app` call it. The function never needed anything from `FletTestApp` - it is a pure transform over an environment mapping - and it now takes the mapping as an optional argument, so the tests exercise it without touching `os.environ`. Verified by importing the module with numpy/PIL/skimage blocked on the meta path, reproducing the CI environment. --- .../flet/src/flet/testing/flet_test_app.py | 33 +++------------- .../flet/src/flet/utils/environment.py | 38 +++++++++++++++++++ ...et_test_app_env.py => test_environment.py} | 32 +++++++++------- 3 files changed, 63 insertions(+), 40 deletions(-) create mode 100644 sdk/python/packages/flet/src/flet/utils/environment.py rename sdk/python/packages/flet/tests/{test_flet_test_app_env.py => test_environment.py} (67%) 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 1167b62a1d..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 @@ -27,32 +28,6 @@ __all__ = ["FletTestApp"] -def _flutter_subprocess_env() -> dict[str, str]: - """ - Build the environment for the `flutter test` child process. - - Host-Python configuration must not reach the interpreter embedded in the - app under test. It reads `PYTHONPATH`/`PYTHONHOME` at initialization, so - the debugger and `sitecustomize` paths an IDE injects (PyCharm does) land - on the packaged app's `sys.path` and kill it at startup - which surfaces - as Flutter exiting with code 79 and "No tests were found", before the app - ever connects to `RemoteTester`. - - `PYTHONNOUSERSITE` is *set* rather than removed: user site-packages is - opt-out, so leaving it unset lets a version-matched host - `~/.local/lib/pythonX.Y/site-packages` leak in the same way. - - Everything else is preserved - `flutter test` needs `PATH`, and its native - build phase needs the `FLET_*` and `SERIOUS_PYTHON_*` variables that - `flet test` sets (see `_flutter_path_env` in `flet_cli.commands.test`). - """ - env = os.environ.copy() - for name in ("PYTHONPATH", "PYTHONHOME", "PYTHONEXECUTABLE"): - env.pop(name, None) - env["PYTHONNOUSERSITE"] = "1" - return env - - class DisposalMode(Enum): """ Indicates the way in which a frame is treated after being displayed. @@ -345,7 +320,11 @@ async def main(page: ft.Page): cwd=str(self.__flutter_app_dir), stdout=stdout, stderr=stderr, - env=_flutter_subprocess_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_flet_test_app_env.py b/sdk/python/packages/flet/tests/test_environment.py similarity index 67% rename from sdk/python/packages/flet/tests/test_flet_test_app_env.py rename to sdk/python/packages/flet/tests/test_environment.py index 7a83ed0a90..f7bbc51c27 100644 --- a/sdk/python/packages/flet/tests/test_flet_test_app_env.py +++ b/sdk/python/packages/flet/tests/test_environment.py @@ -1,7 +1,7 @@ import os from unittest.mock import patch -from flet.testing.flet_test_app import _flutter_subprocess_env +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, @@ -19,8 +19,7 @@ def test_host_python_config_is_stripped(): - with patch.dict(os.environ, HOST_ENV, clear=True): - env = _flutter_subprocess_env() + env = without_host_python_config(HOST_ENV) assert "PYTHONPATH" not in env assert "PYTHONHOME" not in env @@ -30,13 +29,11 @@ def test_host_python_config_is_stripped(): 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. - with patch.dict(os.environ, HOST_ENV, clear=True): - assert _flutter_subprocess_env()["PYTHONNOUSERSITE"] == "1" + assert without_host_python_config(HOST_ENV)["PYTHONNOUSERSITE"] == "1" def test_build_env_is_preserved(): - with patch.dict(os.environ, HOST_ENV, clear=True): - env = _flutter_subprocess_env() + env = without_host_python_config(HOST_ENV) for name in ( "PATH", @@ -49,16 +46,25 @@ def test_build_env_is_preserved(): def test_missing_vars_are_not_an_error(): - with patch.dict(os.environ, {"PATH": "/usr/bin"}, clear=True): - env = _flutter_subprocess_env() + 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 env == {"PATH": "/usr/bin", "PYTHONNOUSERSITE": "1"} + assert source == HOST_ENV -def test_host_environ_is_not_mutated(): +def test_defaults_to_os_environ(): with patch.dict(os.environ, HOST_ENV, clear=True): - _flutter_subprocess_env() + 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 os.environ["PYTHONHOME"] == HOST_ENV["PYTHONHOME"] assert "PYTHONNOUSERSITE" not in os.environ