diff --git a/.github/workflows/flet-build-test-matrix.yml b/.github/workflows/flet-build-test-matrix.yml index 8a5951789c..17793821d6 100644 --- a/.github/workflows/flet-build-test-matrix.yml +++ b/.github/workflows/flet-build-test-matrix.yml @@ -24,7 +24,8 @@ env: PYTHONUTF8: 1 # https://flet.dev/docs/reference/environment-variables - FLET_CLI_NO_RICH_OUTPUT: 1 + # Plain output plus GitHub Actions groups/annotations for each build step. + FLET_CLI_LOG_FORMAT: github FLET_BUILD_EXTRA_ARGS: ${{ inputs.extra_build_args }} diff --git a/.github/workflows/flet-build-test.yml b/.github/workflows/flet-build-test.yml index 085017de32..93c07c723d 100644 --- a/.github/workflows/flet-build-test.yml +++ b/.github/workflows/flet-build-test.yml @@ -49,7 +49,8 @@ env: PYTHONUTF8: 1 # https://flet.dev/docs/reference/environment-variables - FLET_CLI_NO_RICH_OUTPUT: 1 + # Plain output plus GitHub Actions groups/annotations for each build step. + FLET_CLI_LOG_FORMAT: github # Extra args passed via workflow_dispatch (empty for push/PR) FLET_PACK_EXTRA_ARGS: ${{ github.event_name == 'workflow_dispatch' && inputs.extra_pack_args || '' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 05403a6120..72e7955757 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ * Add `Theme.surfaces` and the `ThemeSurfaces` enum (`NEUTRAL`, `TONAL`) to choose whether surface colors are neutral or tinted with the seed color ([#6921](https://github.com/flet-dev/flet/issues/6921)) by @FeodorFitsner. * Add `flet test --update-failed-goldens` (`FLET_TEST_GOLDEN=failed`): compares screenshots as usual and overwrites only golden images that are missing or no longer match, so unchanged goldens stay untouched ([#6921](https://github.com/flet-dev/flet/issues/6921)) by @FeodorFitsner. * Add `bgcolor` to `FletTestApp.take_page_controls_screenshot()`, `assert_control_screenshot()` and `wrap_page_controls_in_screenshot()` to paint an opaque background behind the captured controls instead of a transparent one ([#6918](https://github.com/flet-dev/flet/pull/6918)) by @InesaFitsner. +* Add `--log-format {rich,plain,github}` (`FLET_CLI_LOG_FORMAT`) to `flet build`, `flet debug` and `flet test`. `github` prints plain output plus GitHub Actions workflow commands: a collapsible group per build step and annotations for warnings and errors; `plain` is the same as `--no-rich-output`. See [Log formats](/docs/cli/flet-build#log-formats) ([#6947](https://github.com/flet-dev/flet/issues/6947)) by @FeodorFitsner. * Install `uvicorn[standard]` with `flet-web` again on Windows on ARM with Python 3.12 or later, now that `httptools` 0.9.0 ships wheels for it; Python 3.10 and 3.11 there keep plain `uvicorn`, because PyYAML has no wheels for them ([#6927](https://github.com/flet-dev/flet/pull/6927)) by @ndonkoHenri. ### Bug fixes diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py index e01d8db339..afdc4bf964 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build.py @@ -6,9 +6,6 @@ from pathlib import Path from typing import Callable, Optional -from rich.console import Group -from rich.live import Live - from flet_cli.commands.build_base import BaseBuildCommand, console from flet_cli.commands.flutter_base import verbose1_style from flet_cli.utils.android import flutter_target_platforms @@ -94,11 +91,9 @@ def handle(self, options: argparse.Namespace) -> None: super().handle(options) assert self.target_platform - self.status = console.status( - f"[bold blue]Initializing {self.target_platform} build...", - spinner="bouncingBall", - ) - with Live(Group(self.status, self.progress), console=console) as self.live: + with self.live_status( + f"[bold blue]Initializing {self.target_platform} build..." + ): self.initialize_command() self.validate_target_platform() self.validate_entry_point() @@ -119,6 +114,21 @@ def handle(self, options: argparse.Namespace) -> None: if self.target_platform == "macos": self.sign_macos_app() + unsigned_ipa = self.target_platform == "ipa" and not self.built_ipa() + if self.no_rich_output: + # Non-interactive: the exit code is the result, no banner; only + # the missing .ipa is worth a warning. + if unsigned_ipa: + self.warn( + "No .ipa was produced: Xcode exports one only for a " + "signed app. Configure a provisioning profile and a " + "signing certificate to get an uploadable bundle: " + "https://flet.dev/docs/publish/ios", + title="iOS signing", + ) + self.cleanup(0) + return + self.cleanup( 0, message=( @@ -139,7 +149,7 @@ def handle(self, options: argparse.Namespace) -> None: "[cyan]provisioning profile[/cyan] and a " "[cyan]signing certificate[/cyan] to get an uploadable " "bundle: https://flet.dev/docs/publish/ios" - if self.target_platform == "ipa" and not self.built_ipa() + if unsigned_ipa else "" ) ), @@ -219,33 +229,32 @@ def run_flutter(self): assert self.platforms assert self.target_platform - self.update_status( + with self.step( f"[bold blue]Building [cyan]" f"{self.platforms[self.target_platform]['status_text']}[/cyan]..." - ) - - # Clear the build output directories of artifacts from previous runs. Flutter - # only ever adds files to them, and copy_build_output harvests them wholesale — - # so without this, a previous build with different options (e.g. --arch, - # --split-per-abi, or a renamed product) would leak its artifacts into the - # user's output directory. - assert self.flutter_dir - flutter_dir = self.flutter_dir.resolve() - for output in self.platforms[self.target_platform]["outputs"]: - output_dir = Path( - os.path.dirname(self.resolve_output_path(output)) - ).resolve() - # only delete directories that are strictly inside the generated Flutter - # project (and never the project directory itself). - if output_dir != flutter_dir and output_dir.is_relative_to(flutter_dir): - shutil.rmtree(output_dir, ignore_errors=True) - - self._run_flutter_command() + ): + # Clear the build output directories of artifacts from previous runs. + # Flutter only ever adds files to them, and copy_build_output harvests + # them wholesale — so without this, a previous build with different + # options (e.g. --arch, --split-per-abi, or a renamed product) would + # leak its artifacts into the user's output directory. + assert self.flutter_dir + flutter_dir = self.flutter_dir.resolve() + for output in self.platforms[self.target_platform]["outputs"]: + output_dir = Path( + os.path.dirname(self.resolve_output_path(output)) + ).resolve() + # only delete directories that are strictly inside the generated Flutter + # project (and never the project directory itself). + if output_dir != flutter_dir and output_dir.is_relative_to(flutter_dir): + shutil.rmtree(output_dir, ignore_errors=True) + + self._run_flutter_command() - console.log( - f"Built [cyan]{self.describe_build_output()}" - f"[/cyan] {self.emojis['checkmark']}", - ) + console.log( + f"Built [cyan]{self.describe_build_output()}" + f"[/cyan] {self.emojis['checkmark']}", + ) def built_ipa(self) -> bool: """ @@ -646,58 +655,60 @@ def log(message: str): if self.verbose > 0: console.log(message, style=verbose1_style) - self.update_status(f"[bold blue]Signing [cyan]{app_path.name}[/cyan]...") - try: - # Each lane scopes resolution to the certificate type Apple's - # services accept for it — which also lets an unset identity - # auto-discover the only candidate. The plain lane stays - # unscoped: Apple Development or corporate certificates are - # legitimate there. - if distribution == "app-store": - resolved = resolve_identity(identity, types=APP_STORE_CERTIFICATE_TYPES) - elif distribution == "developer-id": - resolved = resolve_identity( - identity, types=DEVELOPER_ID_CERTIFICATE_TYPES - ) - else: - resolved = resolve_identity(identity) - if not identity: - console.log(f"Signing identity: {resolved.name}") - if distribution == "developer-id" and resolved.is_adhoc: - self.cleanup( - 1, - "Developer ID distribution requires a Developer ID " - 'identity; ad-hoc ("-") signed apps cannot be notarized.', - ) - - if distribution == "app-store": - self._sign_macos_app_store(app_path, resolved, entitlements, log) - return + with self.step(f"[bold blue]Signing [cyan]{app_path.name}[/cyan]..."): + try: + # Each lane scopes resolution to the certificate type Apple's + # services accept for it — which also lets an unset identity + # auto-discover the only candidate. The plain lane stays + # unscoped: Apple Development or corporate certificates are + # legitimate there. + if distribution == "app-store": + resolved = resolve_identity( + identity, types=APP_STORE_CERTIFICATE_TYPES + ) + elif distribution == "developer-id": + resolved = resolve_identity( + identity, types=DEVELOPER_ID_CERTIFICATE_TYPES + ) + else: + resolved = resolve_identity(identity) + if not identity: + console.log(f"Signing identity: {resolved.name}") + if distribution == "developer-id" and resolved.is_adhoc: + self.cleanup( + 1, + "Developer ID distribution requires a Developer ID " + 'identity; ad-hoc ("-") signed apps cannot be notarized.', + ) - signed_count = sign_app( - app_path, - resolved, - entitlements=entitlements, - log=log, - ) - console.log( - f"Signed [cyan]{app_path.name}[/cyan] ({signed_count} binaries, " - f"identity: {resolved.description}) {self.emojis['checkmark']}" - ) + if distribution == "app-store": + self._sign_macos_app_store(app_path, resolved, entitlements, log) + return - if distribution == "developer-id": - credentials = self._macos_notary_credentials() - self.update_status( - f"[bold blue]Notarizing [cyan]{app_path.name}[/cyan] " - "(this can take a few minutes)...", + signed_count = sign_app( + app_path, + resolved, + entitlements=entitlements, + log=log, ) - notarize_and_staple(app_path, credentials, log=log) console.log( - f"Notarized and stapled [cyan]{app_path.name}[/cyan] " - f"{self.emojis['checkmark']}" + f"Signed [cyan]{app_path.name}[/cyan] ({signed_count} binaries, " + f"identity: {resolved.description}) {self.emojis['checkmark']}" ) - except MacOSSigningError as e: - self.cleanup(1, str(e)) + + if distribution == "developer-id": + credentials = self._macos_notary_credentials() + with self.step( + f"[bold blue]Notarizing [cyan]{app_path.name}[/cyan] " + "(this can take a few minutes)...", + ): + notarize_and_staple(app_path, credentials, log=log) + console.log( + f"Notarized and stapled [cyan]{app_path.name}[/cyan] " + f"{self.emojis['checkmark']}" + ) + except MacOSSigningError as e: + self.cleanup(1, str(e)) def _sign_macos_app_store( self, @@ -797,12 +808,14 @@ def _sign_macos_app_store( "or `[tool.flet.macos.info]` in pyproject.toml.", ) if "ITSAppUsesNonExemptEncryption" not in info: - console.log( - "[yellow]Warning: ITSAppUsesNonExemptEncryption is not set in " + self.warn( + "ITSAppUsesNonExemptEncryption is not set in " "Info.plist — App Store Connect will ask the export-compliance " "question manually for every build. Set it with --info-plist " "ITSAppUsesNonExemptEncryption=False if your app only uses " - "standard encryption.[/yellow]" + "standard encryption.", + title="App Store", + style="yellow", ) profile_app_id = profile_application_identifier(profile_path) @@ -845,17 +858,17 @@ def _sign_macos_app_store( f"{self.emojis['checkmark']}" ) - self.update_status(f"[bold blue]Packaging [cyan]{app_path.stem}.pkg[/cyan]...") - pkg = build_pkg( - app_path, - installer, - self.out_dir / f"{app_path.stem}.pkg", - log=log, - ) - console.log( - f"Packaged [cyan]{pkg.name}[/cyan] for App Store Connect " - f"(installer identity: {installer.name}) {self.emojis['checkmark']}" - ) + with self.step(f"[bold blue]Packaging [cyan]{app_path.stem}.pkg[/cyan]..."): + pkg = build_pkg( + app_path, + installer, + self.out_dir / f"{app_path.stem}.pkg", + log=log, + ) + console.log( + f"Packaged [cyan]{pkg.name}[/cyan] for App Store Connect " + f"(installer identity: {installer.name}) {self.emojis['checkmark']}" + ) def _macos_notary_credentials(self) -> NotaryCredentials: """ diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py index 2a90003fac..956a59f72f 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/build_base.py @@ -1,5 +1,6 @@ import argparse import base64 +import contextlib import copy import glob import json @@ -29,7 +30,6 @@ ) from packaging.requirements import Requirement from rich.markup import escape -from rich.panel import Panel from rich.table import Column, Table import flet.version @@ -39,9 +39,9 @@ BaseFlutterCommand, console, error_style, + output, verbose1_style, verbose2_style, - warning_style, ) from flet_cli.commands.options import PassThroughArgsAction from flet_cli.utils.android import ( @@ -52,6 +52,7 @@ from flet_cli.utils.cli import parse_cli_bool_value from flet_cli.utils.flutter import get_flutter_dir from flet_cli.utils.hash_stamp import HashStamp +from flet_cli.utils.log_format import annotation_path from flet_cli.utils.merge import merge_dict from flet_cli.utils.plist import is_supported_plist_value, parse_cli_plist_value from flet_cli.utils.project_dependencies import ( @@ -1104,10 +1105,10 @@ def setup_template_data(self): self.options.impeller is False or self.get_pyproject(f"tool.flet.{self.config_platform}.impeller") is False ): - console.log( - "Warning: turning Impeller off has no effect on " + self.warn( + "turning Impeller off has no effect on " f"{'iOS' if self.package_platform == 'iOS' else 'web'}.", - style=warning_style, + title="Impeller", ) # Explicit `[tool.flet.macos.info]` or `--info-plist` entries override @@ -1522,13 +1523,12 @@ def _xml_attr_value(v): and not self.debug_platform and not getattr(self, "test_mode", False) ): - console.print( - Panel( - "This build will generate an .xcarchive (Xcode Archive). " - "To produce an .ipa (iOS App Package), please specify " - "a Provisioning Profile.", - style=warning_style, - ) + self.warn( + "This build will generate an .xcarchive (Xcode Archive). " + "To produce an .ipa (iOS App Package), please specify " + "a Provisioning Profile.", + title="iOS signing", + panel=True, ) assert self.flutter_dir @@ -1919,111 +1919,116 @@ def create_flutter_project(self, second_pass=False): if hash_changed: # create a new Flutter bootstrap project directory, if non-existent + status = None if not second_pass: self.flutter_dir.mkdir(parents=True, exist_ok=True) status = f"[bold blue]Creating app shell from {template_source}" if checkout: status += f' with ref "{template_ref}"' status += "..." - self.update_status(status) - try: - from cookiecutter.main import cookiecutter - - cookiecutter( - template=template_url, - checkout=checkout, - directory=template_dir, - output_dir=str(self.flutter_dir.parent), - no_input=True, - overwrite_if_exists=True, - extra_context={ - k: v for k, v in self.template_data.items() if v is not None - }, - ) - except Exception as e: - rmtree(self.flutter_dir) - self.cleanup(1, f"{e}") - - # For local development, override flet dependency with path - repo_root = None - pubspec = None - if is_local_dev: - repo_root = flet.version.find_repo_root(Path(__file__).resolve().parent) - if repo_root: - flet_pkg_path = str(repo_root / "packages" / "flet") - pubspec = self.load_yaml(self.pubspec_path) - pubspec["dependencies"]["flet"] = {"path": flet_pkg_path} - pubspec.setdefault("dependency_overrides", {})["flet"] = { - "path": flet_pkg_path - } - - # In test mode, inject the integration-test driver (and flutter_test) - # as dev dependencies. They are intentionally NOT in the template - # pubspec: that keeps it valid YAML for the release patch tooling and - # ensures a normal `flet build` never pulls them. flet_integration_test - # is publish_to:none, so for local dev it resolves to the in-repo - # package by path, and for an end user it is a git dependency pinned to - # this flet version's tag. - if getattr(self, "test_mode", False): - if pubspec is None: - pubspec = self.load_yaml(self.pubspec_path) - dev_deps = pubspec.setdefault("dev_dependencies", {}) - dev_deps["flutter_test"] = {"sdk": "flutter"} - if is_local_dev and repo_root: - fit_pkg_path = str(repo_root / "packages" / "flet_integration_test") - dev_deps["flet_integration_test"] = {"path": fit_pkg_path} - pubspec.setdefault("dependency_overrides", {})[ - "flet_integration_test" - ] = {"path": fit_pkg_path} - else: - dev_deps["flet_integration_test"] = { - "git": { - "url": "https://github.com/flet-dev/flet.git", - "ref": f"v{flet.version.flet_version}", - "path": "packages/flet_integration_test", + with self.step(status) if status else contextlib.nullcontext(): + try: + from cookiecutter.main import cookiecutter + + cookiecutter( + template=template_url, + checkout=checkout, + directory=template_dir, + output_dir=str(self.flutter_dir.parent), + no_input=True, + overwrite_if_exists=True, + extra_context={ + k: v for k, v in self.template_data.items() if v is not None + }, + ) + except Exception as e: + rmtree(self.flutter_dir) + self.cleanup(1, f"{e}") + + # For local development, override flet dependency with path + repo_root = None + pubspec = None + if is_local_dev: + repo_root = flet.version.find_repo_root( + Path(__file__).resolve().parent + ) + if repo_root: + flet_pkg_path = str(repo_root / "packages" / "flet") + pubspec = self.load_yaml(self.pubspec_path) + pubspec["dependencies"]["flet"] = {"path": flet_pkg_path} + pubspec.setdefault("dependency_overrides", {})["flet"] = { + "path": flet_pkg_path } - } - # Only the web (Pyodide) build loads the packaged app as a Flutter - # asset; on native platforms serious_python places it inside the - # bundle, and a missing app/app.zip asset would fail the build. - if self.config_platform == "web": - if pubspec is None: - pubspec = self.load_yaml(self.pubspec_path) - assets = pubspec.setdefault("flutter", {}).setdefault("assets", []) - for asset in ["app/app.zip", "app/app.zip.hash"]: - if asset not in assets: - assets.append(asset) - - if pubspec is not None: - self.save_yaml(self.pubspec_path, pubspec) - - pyproject_pubspec = self.get_pyproject("tool.flet.flutter.pubspec") - - if pyproject_pubspec: - pyproject_pubspec = copy.deepcopy(pyproject_pubspec) - pubspec = self.load_yaml(self.pubspec_path) - # Replace individual dependency entries from pyproject rather - # than deep-merging them — a Dart dependency can only have one - # source, so merging {"path":…} with {"git":…} is invalid. - for section in ( - "dependencies", - "dependency_overrides", - "dev_dependencies", - ): - if section in pyproject_pubspec: - pubspec.setdefault(section, {}).update( - pyproject_pubspec.pop(section) + # In test mode, inject the integration-test driver (and flutter_test) + # as dev dependencies. They are intentionally NOT in the template + # pubspec: that keeps it valid YAML for the release patch tooling and + # ensures a normal `flet build` never pulls them. flet_integration_test + # is publish_to:none, so for local dev it resolves to the in-repo + # package by path, and for an end user it is a git dependency pinned to + # this flet version's tag. + if getattr(self, "test_mode", False): + if pubspec is None: + pubspec = self.load_yaml(self.pubspec_path) + dev_deps = pubspec.setdefault("dev_dependencies", {}) + dev_deps["flutter_test"] = {"sdk": "flutter"} + if is_local_dev and repo_root: + fit_pkg_path = str( + repo_root / "packages" / "flet_integration_test" ) - pubspec = merge_dict(pubspec, pyproject_pubspec) - self.save_yaml(self.pubspec_path, pubspec) + dev_deps["flet_integration_test"] = {"path": fit_pkg_path} + pubspec.setdefault("dependency_overrides", {})[ + "flet_integration_test" + ] = {"path": fit_pkg_path} + else: + dev_deps["flet_integration_test"] = { + "git": { + "url": "https://github.com/flet-dev/flet.git", + "ref": f"v{flet.version.flet_version}", + "path": "packages/flet_integration_test", + } + } - # make backup of pubspec.yaml - shutil.copyfile(self.pubspec_path, f"{self.pubspec_path}.orig") + # Only the web (Pyodide) build loads the packaged app as a Flutter + # asset; on native platforms serious_python places it inside the + # bundle, and a missing app/app.zip asset would fail the build. + if self.config_platform == "web": + if pubspec is None: + pubspec = self.load_yaml(self.pubspec_path) + assets = pubspec.setdefault("flutter", {}).setdefault("assets", []) + for asset in ["app/app.zip", "app/app.zip.hash"]: + if asset not in assets: + assets.append(asset) - if not second_pass: - console.log(f"Created app shell {self.emojis['checkmark']}") + if pubspec is not None: + self.save_yaml(self.pubspec_path, pubspec) + + pyproject_pubspec = self.get_pyproject("tool.flet.flutter.pubspec") + + if pyproject_pubspec: + pyproject_pubspec = copy.deepcopy(pyproject_pubspec) + pubspec = self.load_yaml(self.pubspec_path) + # Replace individual dependency entries from pyproject rather + # than deep-merging them — a Dart dependency can only have one + # source, so merging {"path":…} with {"git":…} is invalid. + for section in ( + "dependencies", + "dependency_overrides", + "dev_dependencies", + ): + if section in pyproject_pubspec: + pubspec.setdefault(section, {}).update( + pyproject_pubspec.pop(section) + ) + pubspec = merge_dict(pubspec, pyproject_pubspec) + self.save_yaml(self.pubspec_path, pubspec) + + # make backup of pubspec.yaml + shutil.copyfile(self.pubspec_path, f"{self.pubspec_path}.orig") + + if not second_pass: + console.log(f"Created app shell {self.emojis['checkmark']}") hash.commit() @@ -2061,22 +2066,21 @@ def register_flutter_extensions(self): shutil.move(self.flutter_packages_temp_dir, self.flutter_packages_dir) if self.flutter_packages_dir.exists(): - self.update_status("[bold blue]Registering Flutter user extensions...") - - for fp in os.listdir(self.flutter_packages_dir): - if (self.flutter_packages_dir / fp / "pubspec.yaml").exists(): - ext_dir = str(self.flutter_packages_dir / fp) - if self.verbose > 0: - console.log(f"Found Flutter extension at {ext_dir}") - self.flutter_dependencies[fp] = {"path": ext_dir} - - self.template_data["flutter"]["dependencies"] = list( - self.flutter_dependencies.keys() - ) + with self.step("[bold blue]Registering Flutter user extensions..."): + for fp in os.listdir(self.flutter_packages_dir): + if (self.flutter_packages_dir / fp / "pubspec.yaml").exists(): + ext_dir = str(self.flutter_packages_dir / fp) + if self.verbose > 0: + console.log(f"Found Flutter extension at {ext_dir}") + self.flutter_dependencies[fp] = {"path": ext_dir} + + self.template_data["flutter"]["dependencies"] = list( + self.flutter_dependencies.keys() + ) - console.log( - f"Registered Flutter user extensions {self.emojis['checkmark']}" - ) + console.log( + f"Registered Flutter user extensions {self.emojis['checkmark']}" + ) def update_flutter_dependencies(self): """ @@ -2169,46 +2173,48 @@ def customize_icons(self): hash.commit() return - self.update_status("[bold blue]Generating app icons...") - - try: - source, pre_rendered = load_source(source_path) - except SourceError as e: - console.log(f"Warning: {e}", style=warning_style) - hash.commit() - return + with self.step("[bold blue]Generating app icons..."): + try: + source, pre_rendered = load_source(source_path) + except SourceError as e: + self.warn(e, title="App icon", file=annotation_path(source_path)) + hash.commit() + return + + # Deliberately not squared first. Every icon is composed by centring + # the artwork on a square canvas anyway, so padding here would be + # redundant - and worse, it replaces the empty space with transparency, + # which makes finished opaque artwork look like a glyph and gets it + # reframed on top of being letterboxed. + if source.width != source.height: + self.warn( + f"icon source is {source.width}x{source.height}, not " + "square. It is centred on each icon, with the remaining space " + "filled by icon_background where a platform needs an opaque " + "icon and left transparent elsewhere. Supply a square image to " + "frame it yourself.", + title="App icon", + file=annotation_path(source_path), + ) - # Deliberately not squared first. Every icon is composed by centring - # the artwork on a square canvas anyway, so padding here would be - # redundant - and worse, it replaces the empty space with transparency, - # which makes finished opaque artwork look like a glyph and gets it - # reframed on top of being letterboxed. - if source.width != source.height: - console.log( - f"Warning: icon source is {source.width}x{source.height}, not " - "square. It is centred on each icon, with the remaining space " - "filled by icon_background where a platform needs an opaque " - "icon and left transparent elsewhere. Supply a square image to " - "frame it yourself.", - style=warning_style, + result = render_icons( + source, + options, + self._icon_spec(platform), + platform=platform, + pre_rendered=pre_rendered, + derived=derived, ) + for message in result.warnings: + self.warn(message, title="App icon", file=annotation_path(source_path)) - result = render_icons( - source, - options, - self._icon_spec(platform), - platform=platform, - pre_rendered=pre_rendered, - derived=derived, - ) - for message in result.warnings: - console.log(f"Warning: {message}", style=warning_style) - - self._backup_generated_icons(result) - write( - result, self.flutter_dir, declared_only=platform not in ICONS_CREATED_FRESH - ) - console.log(f"Generated app icons {self.emojis['checkmark']}") + self._backup_generated_icons(result) + write( + result, + self.flutter_dir, + declared_only=platform not in ICONS_CREATED_FRESH, + ) + console.log(f"Generated app icons {self.emojis['checkmark']}") hash.commit() @@ -2261,10 +2267,10 @@ def _resolve_icon_background(self) -> tuple[int, int, int]: except ValueError: # A typo in a colour must not stop a build that would otherwise # succeed; say what was ignored and carry on with the default. - console.log( - f'Warning: icon_background "{value}" is not a valid colour ' + self.warn( + f'icon_background "{value}" is not a valid colour ' "(expected #rrggbb); using white.", - style=warning_style, + title="App icon", ) return (255, 255, 255) @@ -2460,30 +2466,33 @@ def resolve(*names): hash.commit() return - self.update_status("[bold blue]Generating splash screens...") - - try: - light, _ = load_source(light_path) - dark = load_source(dark_path)[0] if dark_path is not None else None - except SourceError as e: - console.log(f"Warning: {e}", style=warning_style) - hash.commit() - return - - light, warning = square(light) - if warning: - console.log(f"Warning: {warning}", style=warning_style) - if dark is not None: - dark = square(dark)[0] + with self.step("[bold blue]Generating splash screens..."): + try: + light, _ = load_source(light_path) + dark = load_source(dark_path)[0] if dark_path is not None else None + except SourceError as e: + self.warn(e, title="Splash screen") + hash.commit() + return + + light, warning = square(light) + if warning: + self.warn( + warning, title="Splash screen", file=annotation_path(light_path) + ) + if dark is not None: + dark = square(dark)[0] - result = render_splash(light, dark, options, platform=platform, derived=derived) - for message in result.warnings: - console.log(f"Warning: {message}", style=warning_style) + result = render_splash( + light, dark, options, platform=platform, derived=derived + ) + for message in result.warnings: + self.warn(message, title="Splash screen") - write(result, self.flutter_dir, declared_only=False) - if platform == "ios": - self.patch_launch_storyboard(light) - console.log(f"Generated splash screens {self.emojis['checkmark']}") + write(result, self.flutter_dir, declared_only=False) + if platform == "ios": + self.patch_launch_storyboard(light) + console.log(f"Generated splash screens {self.emojis['checkmark']}") hash.commit() @@ -2522,10 +2531,10 @@ def patch_launch_storyboard(self, image) -> None: text, ) if count != 1: - console.log( - "Warning: could not update the launch storyboard's image size " + self.warn( + "could not update the launch storyboard's image size " f"({count} matches). The splash still renders; Xcode may warn.", - style=warning_style, + title="Splash screen", ) return storyboard.write_text(patched, encoding="utf-8") @@ -2569,322 +2578,324 @@ def package_python_app(self): hash = HashStamp(self.build_dir / ".hash" / "package") - self.update_status("[bold blue]Packaging Python app...") - package_args = [ - self.dart_exe, - "run", - "--suppress-analytics", - "serious_python:main", - "package", - str(self.package_app_path), - "--platform", - self.package_platform, - "--python-version", - self.python_release.short, - ] - - if self.template_data["options"]["target_arch"]: - # serious_python's --arch is a Dart multi-option: values must be - # comma-separated or the flag repeated. Space-separated values - # after the first are silently treated as positional arguments. - package_args.extend( - ["--arch", ",".join(self.template_data["options"]["target_arch"])] - ) - - # Only the short version is passed; serious_python derives the full - # version, python-build date, and dart_bridge version from its own - # committed snapshot of the manifest. - package_env = { - "SERIOUS_PYTHON_VERSION": self.python_release.short, - } - - # requirements - requirements_txt = self.python_app_path.joinpath("requirements.txt") - - toml_dependencies = ( - get_poetry_dependencies(self.get_pyproject("tool.poetry.dependencies")) - or get_project_dependencies(self.get_pyproject("project.dependencies")) - or [] - ) + with self.step("[bold blue]Packaging Python app..."): + package_args = [ + self.dart_exe, + "run", + "--suppress-analytics", + "serious_python:main", + "package", + str(self.package_app_path), + "--platform", + self.package_platform, + "--python-version", + self.python_release.short, + ] - platform_dependencies = get_project_dependencies( - self.get_pyproject(f"tool.flet.{self.config_platform}.dependencies") - ) - if platform_dependencies: - toml_dependencies.extend(platform_dependencies) - - dev_packages_configured = False - if len(toml_dependencies) > 0: - dev_packages = self.get_platform_setting("dev_packages", {}) - if len(dev_packages) > 0: - for i in range(0, len(toml_dependencies)): - package_name = Requirement(toml_dependencies[i]).name - if package_name in dev_packages: - package_location = dev_packages[package_name] - dev_path = Path(package_location) - if not dev_path.is_absolute(): - dev_path = (self.python_app_path / dev_path).resolve() - if dev_path.exists(): - # Use Path.as_uri() so Windows drive paths render as - # `file:///D:/a/...` rather than `file://D:\a\...`, - # which pip otherwise treats as a UNC path and fails - # to resolve. - toml_dependencies[i] = ( - f"{package_name} @ {dev_path.as_uri()}" - ) - else: - toml_dependencies[i] = ( - f"{package_name} @ {package_location}" - ) - dev_packages_configured = True - if dev_packages_configured: - toml_dependencies.append("--no-cache-dir") + if self.template_data["options"]["target_arch"]: + # serious_python's --arch is a Dart multi-option: values must be + # comma-separated or the flag repeated. Space-separated values + # after the first are silently treated as positional arguments. + package_args.extend( + ["--arch", ",".join(self.template_data["options"]["target_arch"])] + ) - for toml_dep in toml_dependencies: - package_args.extend(["-r", toml_dep]) + # Only the short version is passed; serious_python derives the full + # version, python-build date, and dart_bridge version from its own + # committed snapshot of the manifest. + package_env = { + "SERIOUS_PYTHON_VERSION": self.python_release.short, + } - elif requirements_txt.exists(): - if self.verbose > 1: - with open(requirements_txt, encoding="utf-8") as f: - reqs_txt_contents = f.read() - console.log( - f"Contents of requirements.txt: {reqs_txt_contents}", - style=verbose2_style, - ) - hash.update(reqs_txt_contents) - package_args.extend(["-r", "-r", "-r", str(requirements_txt)]) - else: - package_args.extend(["-r", f"flet=={flet.version.flet_version}"]) + # requirements + requirements_txt = self.python_app_path.joinpath("requirements.txt") - # site-packages variable - if self.package_platform != "Emscripten": - package_env["SERIOUS_PYTHON_SITE_PACKAGES"] = str( - self.build_dir / "site-packages" + toml_dependencies = ( + get_poetry_dependencies(self.get_pyproject("tool.poetry.dependencies")) + or get_project_dependencies(self.get_pyproject("project.dependencies")) + or [] ) - # app staging dir: serious_python's `package` places the processed - # app here (no app.zip on native); the platform native build copies - # it into the bundle (Android zips it as a stored asset). - package_env["SERIOUS_PYTHON_APP"] = str(self.build_dir / "python-app") - # app bundle id: serious_python (>= 4.4.2) namespaces the generated - # iOS framework bundle identifiers under it. Without it they keep a - # shared `org.python.*` default that is byte-identical in every Flet - # app — see flet-dev/flet#6724. - bundle_id = (self.template_data or {}).get("bundle_id") - if bundle_id: - package_env["SERIOUS_PYTHON_BUNDLE_ID"] = bundle_id - - # Swift Package Manager (darwin): tell serious_python's package command to - # do the host-side SPM staging (the podspec prepare_command doesn't run - # under SPM) and write the SP_NATIVE_SET cache-bust key to this file. - # serious_python defaults to SPM staging, so be explicit either way — set - # it false for the CocoaPods cases (e.g. an app using flet-video). - if self.package_platform in ("iOS", "Darwin"): - spm = self._darwin_spm_active() - package_env["SERIOUS_PYTHON_DARWIN_SPM"] = "true" if spm else "false" - if spm: - package_env["SERIOUS_PYTHON_SPM_KEY_FILE"] = str( - self.build_dir / ".serious_python_spm_key" - ) - # flutter-packages variable - if self.flutter_packages_temp_dir.exists(): - rmtree(self.flutter_packages_temp_dir) + platform_dependencies = get_project_dependencies( + self.get_pyproject(f"tool.flet.{self.config_platform}.dependencies") + ) + if platform_dependencies: + toml_dependencies.extend(platform_dependencies) + + dev_packages_configured = False + if len(toml_dependencies) > 0: + dev_packages = self.get_platform_setting("dev_packages", {}) + if len(dev_packages) > 0: + for i in range(0, len(toml_dependencies)): + package_name = Requirement(toml_dependencies[i]).name + if package_name in dev_packages: + package_location = dev_packages[package_name] + dev_path = Path(package_location) + if not dev_path.is_absolute(): + dev_path = (self.python_app_path / dev_path).resolve() + if dev_path.exists(): + # Use Path.as_uri() so Windows drive paths render as + # `file:///D:/a/...` rather than `file://D:\a\...`, + # which pip otherwise treats as a UNC path and fails + # to resolve. + toml_dependencies[i] = ( + f"{package_name} @ {dev_path.as_uri()}" + ) + else: + toml_dependencies[i] = ( + f"{package_name} @ {package_location}" + ) + dev_packages_configured = True + if dev_packages_configured: + toml_dependencies.append("--no-cache-dir") + + for toml_dep in toml_dependencies: + package_args.extend(["-r", toml_dep]) + + elif requirements_txt.exists(): + if self.verbose > 1: + with open(requirements_txt, encoding="utf-8") as f: + reqs_txt_contents = f.read() + console.log( + f"Contents of requirements.txt: {reqs_txt_contents}", + style=verbose2_style, + ) + hash.update(reqs_txt_contents) + package_args.extend(["-r", "-r", "-r", str(requirements_txt)]) + else: + package_args.extend(["-r", f"flet=={flet.version.flet_version}"]) - package_env["SERIOUS_PYTHON_FLUTTER_PACKAGES"] = str( - self.flutter_packages_temp_dir - ) + # site-packages variable + if self.package_platform != "Emscripten": + package_env["SERIOUS_PYTHON_SITE_PACKAGES"] = str( + self.build_dir / "site-packages" + ) + # app staging dir: serious_python's `package` places the processed + # app here (no app.zip on native); the platform native build copies + # it into the bundle (Android zips it as a stored asset). + package_env["SERIOUS_PYTHON_APP"] = str(self.build_dir / "python-app") + # app bundle id: serious_python (>= 4.4.2) namespaces the generated + # iOS framework bundle identifiers under it. Without it they keep a + # shared `org.python.*` default that is byte-identical in every Flet + # app — see flet-dev/flet#6724. + bundle_id = (self.template_data or {}).get("bundle_id") + if bundle_id: + package_env["SERIOUS_PYTHON_BUNDLE_ID"] = bundle_id + + # Swift Package Manager (darwin): tell serious_python's package command to + # do the host-side SPM staging (the podspec prepare_command doesn't run + # under SPM) and write the SP_NATIVE_SET cache-bust key to this file. + # serious_python defaults to SPM staging, so be explicit either way — set + # it false for the CocoaPods cases (e.g. an app using flet-video). + if self.package_platform in ("iOS", "Darwin"): + spm = self._darwin_spm_active() + package_env["SERIOUS_PYTHON_DARWIN_SPM"] = "true" if spm else "false" + if spm: + package_env["SERIOUS_PYTHON_SPM_KEY_FILE"] = str( + self.build_dir / ".serious_python_spm_key" + ) - # exclude - app_exclude = self.options.exclude or self.get_platform_setting( - "app.exclude", [] - ) - explicit_excludes = [ - "build", - *app_exclude, - *(["assets"] if self.target_platform == "web" else []), - ] + # flutter-packages variable + if self.flutter_packages_temp_dir.exists(): + rmtree(self.flutter_packages_temp_dir) - default_excludes: list[str] = [] - if self.get_bool_setting( - self.options.default_excludes, "app.default_excludes", True - ): - app_include = self.options.include or self.get_platform_setting( - "app.include", [] + package_env["SERIOUS_PYTHON_FLUTTER_PACKAGES"] = str( + self.flutter_packages_temp_dir ) - default_excludes = find_default_excludes( - self.package_app_path, app_include, explicit_excludes - ) - - exclude_list = list(dict.fromkeys(explicit_excludes + default_excludes)) - # one flag per path: serious_python (>= 5.0.0) doesn't split values on - # commas, so paths containing `,` survive - for path in exclude_list: - package_args.extend(["--exclude", path]) - if default_excludes: - console.log( - "Excluded from app package by default: " - f"{', '.join(default_excludes)} " - "(use --include or --no-default-excludes to package them)", - markup=False, + # exclude + app_exclude = self.options.exclude or self.get_platform_setting( + "app.exclude", [] ) - env_files = [ - p for p in default_excludes if p == ".env" or p.startswith(".env.") + explicit_excludes = [ + "build", + *app_exclude, + *(["assets"] if self.target_platform == "web" else []), ] - if env_files: - it = "it" if len(env_files) == 1 else "them" + + default_excludes: list[str] = [] + if self.get_bool_setting( + self.options.default_excludes, "app.default_excludes", True + ): + app_include = self.options.include or self.get_platform_setting( + "app.include", [] + ) + default_excludes = find_default_excludes( + self.package_app_path, app_include, explicit_excludes + ) + + exclude_list = list(dict.fromkeys(explicit_excludes + default_excludes)) + # one flag per path: serious_python (>= 5.0.0) doesn't split values on + # commas, so paths containing `,` survive + for path in exclude_list: + package_args.extend(["--exclude", path]) + + if default_excludes: console.log( - f"Warning: {', '.join(env_files)} not packaged. If the app " - f"loads {it} at runtime (e.g. with python-dotenv), package " - f"{it} with `--include {' '.join(env_files)}` or " - f"`include = {json.dumps(env_files)}` under [tool.flet.app] " - "in pyproject.toml.", - style=warning_style, + "Excluded from app package by default: " + f"{', '.join(default_excludes)} " + "(use --include or --no-default-excludes to package them)", + markup=False, + ) + env_files = [ + p for p in default_excludes if p == ".env" or p.startswith(".env.") + ] + if env_files: + it = "it" if len(env_files) == 1 else "them" + self.warn( + f"{', '.join(env_files)} not packaged. If the app " + f"loads {it} at runtime (e.g. with python-dotenv), package " + f"{it} with `--include {' '.join(env_files)}` or " + f"`include = {json.dumps(env_files)}` under [tool.flet.app] " + "in pyproject.toml.", + title="App package", + markup=False, + ) + if self.verbose > 0: + console.log( + f"App package exclude list: {exclude_list}", + style=verbose1_style, markup=False, ) - if self.verbose > 0: - console.log( - f"App package exclude list: {exclude_list}", - style=verbose1_style, - markup=False, - ) - # source-packages - source_packages = self.options.source_packages or self.get_platform_setting( - "source_packages" - ) - if source_packages: - package_env["SERIOUS_PYTHON_ALLOW_SOURCE_DISTRIBUTIONS"] = ",".join( - source_packages - ) - - # android-extract-packages: path-hungry packages shipped extracted to disk - # instead of inside the zip (serious_python Android native-mmap packaging). - # A built-in default set covers commonly-broken packages; the user list - # (CLI / pyproject) is merged on top. Consumed by the serious_python_android - # Gradle split during `flutter build`, so the env var is set on build_env - # (see _run_flutter_command), not on the package step. - self.android_extract_packages: list[str] = [] - if self.package_platform == "Android": - user_extract_packages = ( - self.options.android_extract_packages - or self.get_platform_setting("extract_packages", []) - ) - self.android_extract_packages = list( - dict.fromkeys(ANDROID_DEFAULT_EXTRACT_PACKAGES + user_extract_packages) + # source-packages + source_packages = self.options.source_packages or self.get_platform_setting( + "source_packages" ) + if source_packages: + package_env["SERIOUS_PYTHON_ALLOW_SOURCE_DISTRIBUTIONS"] = ",".join( + source_packages + ) - if self.get_bool_setting(self.options.compile_app, "compile.app", True): - package_args.append("--compile-app") + # android-extract-packages: path-hungry packages shipped extracted to disk + # instead of inside the zip (serious_python Android native-mmap packaging). + # A built-in default set covers commonly-broken packages; the user list + # (CLI / pyproject) is merged on top. Consumed by the serious_python_android + # Gradle split during `flutter build`, so the env var is set on build_env + # (see _run_flutter_command), not on the package step. + self.android_extract_packages: list[str] = [] + if self.package_platform == "Android": + user_extract_packages = ( + self.options.android_extract_packages + or self.get_platform_setting("extract_packages", []) + ) + self.android_extract_packages = list( + dict.fromkeys( + ANDROID_DEFAULT_EXTRACT_PACKAGES + user_extract_packages + ) + ) - if self.get_bool_setting( - self.options.compile_packages, "compile.packages", True - ): - package_args.append("--compile-packages") + if self.get_bool_setting(self.options.compile_app, "compile.app", True): + package_args.append("--compile-app") - cleanup_app = self.get_bool_setting( - self.options.cleanup_app, "cleanup.app", False - ) - cleanup_packages = self.get_bool_setting( - self.options.cleanup_packages, "cleanup.packages", True - ) + if self.get_bool_setting( + self.options.compile_packages, "compile.packages", True + ): + package_args.append("--compile-packages") - if cleanup_app_files := ( - self.options.cleanup_app_files - or self.get_platform_setting("cleanup.app_files") - ): - if isinstance(cleanup_app_files, str): - cleanup_app_files = [ - value.strip() for value in cleanup_app_files.split(",") - ] - if isinstance(cleanup_app_files, list): - for glob_pattern in cleanup_app_files: - if glob_pattern.strip(): - package_args.extend( - ["--cleanup-app-files", glob_pattern.strip()] - ) - cleanup_app = True + cleanup_app = self.get_bool_setting( + self.options.cleanup_app, "cleanup.app", False + ) + cleanup_packages = self.get_bool_setting( + self.options.cleanup_packages, "cleanup.packages", True + ) - if cleanup_package_files := ( - self.options.cleanup_package_files - or self.get_platform_setting("cleanup.package_files") - ): - if isinstance(cleanup_package_files, str): - cleanup_package_files = [ - value for value in cleanup_package_files.split(",") - ] - if isinstance(cleanup_package_files, list): - for glob_pattern in cleanup_package_files: - if glob_pattern.strip(): - package_args.extend( - ["--cleanup-package-files", glob_pattern.strip()] - ) - cleanup_packages = True + if cleanup_app_files := ( + self.options.cleanup_app_files + or self.get_platform_setting("cleanup.app_files") + ): + if isinstance(cleanup_app_files, str): + cleanup_app_files = [ + value.strip() for value in cleanup_app_files.split(",") + ] + if isinstance(cleanup_app_files, list): + for glob_pattern in cleanup_app_files: + if glob_pattern.strip(): + package_args.extend( + ["--cleanup-app-files", glob_pattern.strip()] + ) + cleanup_app = True - if cleanup_app: - package_args.append("--cleanup-app") + if cleanup_package_files := ( + self.options.cleanup_package_files + or self.get_platform_setting("cleanup.package_files") + ): + if isinstance(cleanup_package_files, str): + cleanup_package_files = [ + value for value in cleanup_package_files.split(",") + ] + if isinstance(cleanup_package_files, list): + for glob_pattern in cleanup_package_files: + if glob_pattern.strip(): + package_args.extend( + ["--cleanup-package-files", glob_pattern.strip()] + ) + cleanup_packages = True - if cleanup_packages: - package_args.append("--cleanup-packages") + if cleanup_app: + package_args.append("--cleanup-app") - if self.verbose > 0: - console.log( - f"Compile app: {'--compile-app' in package_args}, " - f"cleanup app: {cleanup_app}, cleanup packages: {cleanup_packages} " - "(cleanup removes serious_python's default junk globs plus any " - "--cleanup-app-files/--cleanup-package-files)", - style=verbose1_style, - ) + if cleanup_packages: + package_args.append("--cleanup-packages") - if self.verbose > 1: - package_args.append("--verbose") - - # check if site-packages installation could be skipped - for arg in package_args: - hash.update(arg) - - if not dev_packages_configured: - if not hash.has_changed(): - package_args.append("--skip-site-packages") - # serious_python skips copying Flutter packages to the temp dir - # under --skip-site-packages, so register_flutter_extensions must - # keep (not wipe) the permanent flutter-packages copy from the - # previous build. - self.site_packages_skipped = True - else: - if self.flutter_packages_dir.exists(): - shutil.rmtree(self.flutter_packages_dir, ignore_errors=True) + if self.verbose > 0: + console.log( + f"Compile app: {'--compile-app' in package_args}, " + f"cleanup app: {cleanup_app}, cleanup packages: {cleanup_packages} " + "(cleanup removes serious_python's default junk globs plus any " + "--cleanup-app-files/--cleanup-package-files)", + style=verbose1_style, + ) - package_result = self.run( - package_args, - cwd=str(self.flutter_dir), - env=package_env, - capture_output=self.verbose < 1, - ) + if self.verbose > 1: + package_args.append("--verbose") + + # check if site-packages installation could be skipped + for arg in package_args: + hash.update(arg) + + if not dev_packages_configured: + if not hash.has_changed(): + package_args.append("--skip-site-packages") + # serious_python skips copying Flutter packages to the temp dir + # under --skip-site-packages, so register_flutter_extensions must + # keep (not wipe) the permanent flutter-packages copy from the + # previous build. + self.site_packages_skipped = True + else: + if self.flutter_packages_dir.exists(): + shutil.rmtree(self.flutter_packages_dir, ignore_errors=True) + + package_result = self.run( + package_args, + cwd=str(self.flutter_dir), + env=package_env, + capture_output=self.verbose < 1, + ) - if package_result.returncode != 0: - if isinstance(package_result.stdout, str): - console.log(package_result.stdout, style=verbose1_style) - if isinstance(package_result.stderr, str): - console.log(package_result.stderr, style=error_style) - self.cleanup(package_result.returncode) + if package_result.returncode != 0: + if isinstance(package_result.stdout, str): + console.log(package_result.stdout, style=verbose1_style) + if isinstance(package_result.stderr, str): + console.log(package_result.stderr, style=error_style) + self.cleanup(package_result.returncode) - hash.commit() + hash.commit() - # verify the package output: web ships app/app.zip; native platforms - # stage the unpacked app to build/app for the native build to bundle. - if self.package_platform == "Emscripten": - app_zip_path = self.flutter_dir.joinpath("app", "app.zip") - if not os.path.exists(app_zip_path): - self.cleanup(1, "Flet app package app/app.zip was not created.") - else: - app_staging_dir = self.build_dir / "python-app" - if not app_staging_dir.exists(): - self.cleanup( - 1, f"Flet app package was not staged to {app_staging_dir}." - ) + # verify the package output: web ships app/app.zip; native platforms + # stage the unpacked app to build/app for the native build to bundle. + if self.package_platform == "Emscripten": + app_zip_path = self.flutter_dir.joinpath("app", "app.zip") + if not os.path.exists(app_zip_path): + self.cleanup(1, "Flet app package app/app.zip was not created.") + else: + app_staging_dir = self.build_dir / "python-app" + if not app_staging_dir.exists(): + self.cleanup( + 1, f"Flet app package was not staged to {app_staging_dir}." + ) - console.log(f"Packaged Python app {self.emojis['checkmark']}") + console.log(f"Packaged Python app {self.emojis['checkmark']}") # Drop the matching Pyodide runtime into the Flutter project's web/ # directory so it ships in `flutter build web` output. Cached @@ -2899,12 +2910,12 @@ def package_python_app(self): if self.resolve_no_cdn(): from flet_cli.utils.pyodide import ensure_pyodide - self.update_status("[bold blue]Preparing Pyodide runtime...") - ensure_pyodide(self.python_release.pyodide, pyodide_dest) - console.log( - f"Pyodide {self.python_release.pyodide} ready " - f"{self.emojis['checkmark']}" - ) + with self.step("[bold blue]Preparing Pyodide runtime..."): + ensure_pyodide(self.python_release.pyodide, pyodide_dest) + console.log( + f"Pyodide {self.python_release.pyodide} ready " + f"{self.emojis['checkmark']}" + ) elif pyodide_dest.exists(): # The Flutter project is reused across builds, so a copy left # by an earlier `--no-cdn` build would otherwise still ship. @@ -3214,12 +3225,13 @@ def flutter_sdk_supported(self) -> bool: "Flutter SDK.", ) - console.log( + self.warn( f"The Flutter SDK at {escape(str(self.flutter_exe))} builds ARM64 " "Windows apps, but Flet's Python runtime for Windows is x64-only. " f"Flet will use its own Flutter {self.required_flutter_version} (x64) " "to build an x64 app, which runs on Windows on ARM.", - style=warning_style, + title="Flutter SDK", + prefix="", ) return False @@ -3265,76 +3277,77 @@ def copy_build_output(self): assert self.assets_path assert self.target_platform - self.update_status( + with self.step( f"[bold blue]Copying build to [cyan]{self.rel_out_dir}[/cyan] directory...", - ) - - def make_ignore_fn(out_dir, out_glob): - """ - Create a shutil ignore callback that keeps only one selected output glob. - """ + ): - def ignore(path, names): + def make_ignore_fn(out_dir, out_glob): """ - Filter sibling entries at `out_dir` so only `out_glob` is copied. + Create a shutil ignore callback that keeps only one selected + output glob. """ - if path == out_dir and out_glob != "*": - return [f for f in os.listdir(path) if f != out_glob] - return [] + def ignore(path, names): + """ + Filter sibling entries at `out_dir` so only `out_glob` is copied. + """ - return ignore + if path == out_dir and out_glob != "*": + return [f for f in os.listdir(path) if f != out_glob] + return [] - searched_outputs = [] - copied = False - for build_output in self.platforms[self.target_platform]["outputs"]: - build_output_dir = self.resolve_output_path(build_output) - searched_outputs.append(build_output_dir) + return ignore - if self.verbose > 0: - console.log( - "Copying build output from: " + build_output_dir, - style=verbose1_style, - ) + searched_outputs = [] + copied = False + for build_output in self.platforms[self.target_platform]["outputs"]: + build_output_dir = self.resolve_output_path(build_output) + searched_outputs.append(build_output_dir) - build_output_glob = os.path.basename(build_output_dir) - build_output_dir = os.path.dirname(build_output_dir) - if not os.path.isdir(build_output_dir) or not any( - build_output_glob in ("*", f) for f in os.listdir(build_output_dir) - ): - continue - copied = True + if self.verbose > 0: + console.log( + "Copying build output from: " + build_output_dir, + style=verbose1_style, + ) - if self.out_dir.exists(): - rmtree(str(self.out_dir)) - self.out_dir.mkdir(parents=True, exist_ok=True) + build_output_glob = os.path.basename(build_output_dir) + build_output_dir = os.path.dirname(build_output_dir) + if not os.path.isdir(build_output_dir) or not any( + build_output_glob in ("*", f) for f in os.listdir(build_output_dir) + ): + continue + copied = True - # copy build result to out_dir - copy_tree( - build_output_dir, - str(self.out_dir), - ignore=make_ignore_fn(build_output_dir, build_output_glob), - ) + if self.out_dir.exists(): + rmtree(str(self.out_dir)) + self.out_dir.mkdir(parents=True, exist_ok=True) - if not copied: - self.cleanup( - 1, - "Build output not found in " - + ", ".join(escape(path) for path in searched_outputs), - ) + # copy build result to out_dir + copy_tree( + build_output_dir, + str(self.out_dir), + ignore=make_ignore_fn(build_output_dir, build_output_glob), + ) - if self.target_platform == "web": - self.prune_cdn_assets() - if self.assets_path.exists(): - # copy `assets` directory contents to the output directory - copy_tree(str(self.assets_path), str(self.out_dir)) - elif self.target_platform in {"apk", "aab"}: - self.rename_android_build_outputs() + if not copied: + self.cleanup( + 1, + "Build output not found in " + + ", ".join(escape(path) for path in searched_outputs), + ) - console.log( - f"Copied build to [cyan]{self.rel_out_dir}[/cyan] " - f"directory {self.emojis['checkmark']}" - ) + if self.target_platform == "web": + self.prune_cdn_assets() + if self.assets_path.exists(): + # copy `assets` directory contents to the output directory + copy_tree(str(self.assets_path), str(self.out_dir)) + elif self.target_platform in {"apk", "aab"}: + self.rename_android_build_outputs() + + console.log( + f"Copied build to [cyan]{self.rel_out_dir}[/cyan] " + f"directory {self.emojis['checkmark']}" + ) def prune_cdn_assets(self): """ @@ -3407,10 +3420,11 @@ def rename_android_build_outputs(self): renamed_path = output_file.with_name(renamed) if renamed_path.exists(): - console.log( + self.warn( f"Skipping rename of [cyan]{name}[/cyan] because " f"[cyan]{renamed}[/cyan] already exists.", - style=warning_style, + title="Build output", + prefix="", ) continue @@ -3609,12 +3623,13 @@ def _incompatible(p: str) -> bool: (p for p in candidates if Path(p).suffix.lower() == ".svg"), None ) if svg: - console.log( - f'Warning: "{Path(svg).name}" is a vector (SVG) image and ' + output.warn( + f'"{Path(svg).name}" is a vector (SVG) image and ' f'cannot be used for "{image_name}". Provide a raster ' f'"{image_name}.png" to customize it — using the default ' f"for now.", - style=warning_style, + title="Image", + file=annotation_path(svg), ) return None diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py index 816d8ee723..dc84a15e1f 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/debug.py @@ -3,9 +3,6 @@ import os import platform -from rich.console import Group -from rich.live import Live - from flet_cli.commands.build_base import BaseBuildCommand, console, verbose2_style @@ -100,11 +97,9 @@ def handle(self, options: argparse.Namespace) -> None: if self.options.device_id: self.device_id = self.options.device_id - self.status = console.status( - f"[bold blue]Initializing {self.target_platform} debug session...", - spinner="bouncingBall", - ) - with Live(Group(self.status, self.progress), console=console) as self.live: + with self.live_status( + f"[bold blue]Initializing {self.target_platform} debug session..." + ): self.check_device_id() self.initialize_command() if self.options.show_devices: diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py index 14fc057c86..31d76c7c1d 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/devices.py @@ -2,7 +2,6 @@ import os from rich.console import Group -from rich.live import Live from rich.panel import Panel from rich.table import Column, Table @@ -70,11 +69,7 @@ def handle(self, options: argparse.Namespace) -> None: self.device_connection = self.options.device_connection self.platform_label = self.platform_labels[self.devices_platform] - self.status = console.status( - "[bold blue]Initializing environment...", - spinner="bouncingBall", - ) - with Live(Group(self.status, self.progress), console=console) as self.live: + with self.live_status("[bold blue]Initializing environment..."): self.initialize_command() self.run_flutter_devices() self.cleanup(0) diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py index 7ed1d48274..59d202c148 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/emulators.py @@ -4,7 +4,6 @@ from pathlib import Path from rich.console import Group -from rich.live import Live from rich.panel import Panel from rich.table import Column, Table @@ -73,11 +72,7 @@ def handle(self, options: argparse.Namespace) -> None: self.emulator_name = self.options.emulator self.cold_boot = bool(self.options.cold) - self.status = console.status( - "[bold blue]Initializing environment...", - spinner="bouncingBall", - ) - with Live(Group(self.status, self.progress), console=console) as self.live: + with self.live_status("[bold blue]Initializing environment..."): self.initialize_command() if self.action == "delete": if not self.emulator_target: diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py index db2c03c6d1..67befe3218 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/flutter_base.py @@ -1,13 +1,16 @@ import argparse +import contextlib import os import platform import re import shutil import sys +from collections.abc import Generator from typing import Any, Optional from packaging import version from rich.console import Console, Group +from rich.live import Live from rich.panel import Panel from rich.progress import Progress from rich.prompt import Confirm @@ -20,19 +23,27 @@ from flet.utils.platform_utils import get_bool_env_var from flet_cli.commands.base import BaseCommand from flet_cli.utils.flutter import get_flutter_dir, install_flutter +from flet_cli.utils.log_format import ( + LOG_FORMATS, + CliOutput, + PlainLogConsole, + detect_log_format, + resolve_log_format, + step_title, +) # Detect the plain-output request BEFORE building the shared console: the -# `--no-rich-output` argparse flag is parsed per-command, too late to -# affect this module-level console, so check sys.argv for it here too -# (alongside the env var). Without this, the flag only suppressed emojis -# while the color + Live spinner kept going. -no_rich_output = ( - get_bool_env_var("FLET_CLI_NO_RICH_OUTPUT") or "--no-rich-output" in sys.argv -) +# `--log-format`/`--no-rich-output` argparse options are parsed per-command, +# too late to affect this module-level console, so check sys.argv for them +# here too (alongside the env vars). Without this, the flag only suppressed +# emojis while the color + Live spinner kept going. +log_format = detect_log_format(sys.argv, os.environ) +no_rich_output = log_format != "rich" error_style = Style(color="red", bold=True) warning_style = Style(color="yellow", bold=True) -console = Console( +# `plain`/`github` log plain lines: no time column, no wrapping. +console = (PlainLogConsole if no_rich_output else Console)( log_path=False, theme=Theme({"log.message": "green bold"}), # no_rich_output forces fully plain output (no color, no animation). @@ -45,6 +56,11 @@ verbose1_style = Style(dim=True, bold=False) verbose2_style = Style(color="bright_black", bold=False) +# Renders steps, warnings and errors in the selected log format. +output = CliOutput( + console, log_format, warning_style=warning_style, error_style=error_style +) + class BaseFlutterCommand(BaseCommand): """ @@ -79,6 +95,21 @@ def __init__(self, parser: argparse.ArgumentParser) -> None: self.assume_yes = False self._android_install_confirmed = False + @property + def log_format(self) -> str: + """ + The output format, one of `rich`, `plain` or `github`. + + Stored on the process-wide `output`, which also renders module-level + warnings, so the format is the same everywhere. + """ + + return output.format + + @log_format.setter + def log_format(self, value: str) -> None: + output.format = value + def add_arguments(self, parser: argparse.ArgumentParser) -> None: """ Register shared CLI arguments for Flutter-based commands. @@ -87,11 +118,22 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None: parser: Argument parser configured by the command runner. """ + parser.add_argument( + "--log-format", + type=str.lower, + choices=LOG_FORMATS, + default=None, + help="Output format: `rich` (default) shows a live spinner, `plain` " + "prints plain text lines, `github` prints plain text plus GitHub " + "Actions workflow commands - a collapsible group per build step and " + "annotations for warnings and errors [env: FLET_CLI_LOG_FORMAT=]", + ) parser.add_argument( "--no-rich-output", action="store_true", default=False, - help="Disable rich output and prefer plain text. Useful on Windows builds " + help="Disable rich output and prefer plain text, same as " + "`--log-format plain`. Useful on Windows builds " "[env: FLET_CLI_NO_RICH_OUTPUT=]", ) parser.add_argument( @@ -119,7 +161,16 @@ def handle(self, options: argparse.Namespace) -> None: """ self.options = options - self.no_rich_output = self.no_rich_output or self.options.no_rich_output + try: + self.log_format = resolve_log_format( + getattr(options, "log_format", None), + getattr(options, "no_rich_output", False), + os.environ, + ) + except ValueError as e: + console.print(str(e), style=error_style, markup=False) + sys.exit(1) + self.no_rich_output = self.log_format != "rich" self.verbose = self.options.verbose self.assume_yes = getattr(self.options, "assume_yes", False) @@ -162,9 +213,10 @@ def initialize_command(self): if not sdk_found or not self.flutter_sdk_supported(): if not self.assume_yes: if not sdk_found: - console.log( + self.warn( "Flutter SDK not found or invalid version installed.", - style=warning_style, + title="Flutter SDK", + prefix="", ) prompt = ( f"Flutter SDK {self.required_flutter_version} is required. " @@ -275,30 +327,72 @@ def install_flutter(self): """ assert self.required_flutter_version - self.update_status( + with self.step( f"[bold blue]Installing Flutter {self.required_flutter_version}..." - ) + ): + flutter_dir = install_flutter( + str(self.required_flutter_version), + self.log_stdout, + progress=self.progress, + ) + ext = ".bat" if platform.system() == "Windows" else "" + self.flutter_exe = os.path.join(flutter_dir, "bin", f"flutter{ext}") + self.dart_exe = os.path.join(flutter_dir, "bin", f"dart{ext}") + path_env = os.environ.get("PATH", "") + flutter_bin = os.path.join(flutter_dir, "bin") + self.env["PATH"] = ( + os.pathsep.join([flutter_bin, path_env]) if path_env else flutter_bin + ) - flutter_dir = install_flutter( - str(self.required_flutter_version), self.log_stdout, progress=self.progress - ) - ext = ".bat" if platform.system() == "Windows" else "" - self.flutter_exe = os.path.join(flutter_dir, "bin", f"flutter{ext}") - self.dart_exe = os.path.join(flutter_dir, "bin", f"dart{ext}") - path_env = os.environ.get("PATH", "") - flutter_bin = os.path.join(flutter_dir, "bin") - self.env["PATH"] = ( - os.pathsep.join([flutter_bin, path_env]) if path_env else flutter_bin - ) + # desktop mode + desktop_platform = platform.system().lower() + if desktop_platform == "darwin": + desktop_platform = "macos" + if desktop_platform in ["macos", "windows", "linux"]: + if self.verbose > 0: + console.log( + "Ensure Flutter has desktop support enabled", + style=verbose1_style, + ) + config_result = self.run( + [ + self.flutter_exe, + "config", + "--no-version-check", + "--suppress-analytics", + f"--enable-{desktop_platform}-desktop", + ], + cwd=os.getcwd(), + capture_output=self.verbose < 1, + ) + if config_result.returncode != 0: + if isinstance(config_result.stdout, str): + console.log(config_result.stdout, style=verbose1_style) + if isinstance(config_result.stderr, str): + console.log(config_result.stderr, style=error_style) + self.cleanup(config_result.returncode) - # desktop mode - desktop_platform = platform.system().lower() - if desktop_platform == "darwin": - desktop_platform = "macos" - if desktop_platform in ["macos", "windows", "linux"]: if self.verbose > 0: console.log( - "Ensure Flutter has desktop support enabled", + f"Flutter {self.required_flutter_version} " + f"installed {self.emojis['checkmark']}" + ) + + def install_jdk(self): + """ + Install or resolve JDK and configure Flutter to use it. + """ + + from flet_cli.utils.jdk import install_jdk + + with self.step("[bold blue]Installing JDK..."): + jdk_dir = install_jdk(self.log_stdout, progress=self.progress) + self.env["JAVA_HOME"] = jdk_dir + + # config flutter's JDK dir + if self.verbose > 0: + console.log( + "Configuring Flutter's path to JDK", style=verbose1_style, ) config_result = self.run( @@ -307,7 +401,7 @@ def install_flutter(self): "config", "--no-version-check", "--suppress-analytics", - f"--enable-{desktop_platform}-desktop", + f"--jdk-dir={jdk_dir}", ], cwd=os.getcwd(), capture_output=self.verbose < 1, @@ -319,49 +413,8 @@ def install_flutter(self): console.log(config_result.stderr, style=error_style) self.cleanup(config_result.returncode) - if self.verbose > 0: - console.log( - f"Flutter {self.required_flutter_version} " - f"installed {self.emojis['checkmark']}" - ) - - def install_jdk(self): - """ - Install or resolve JDK and configure Flutter to use it. - """ - - from flet_cli.utils.jdk import install_jdk - - self.update_status("[bold blue]Installing JDK...") - jdk_dir = install_jdk(self.log_stdout, progress=self.progress) - self.env["JAVA_HOME"] = jdk_dir - - # config flutter's JDK dir - if self.verbose > 0: - console.log( - "Configuring Flutter's path to JDK", - style=verbose1_style, - ) - config_result = self.run( - [ - self.flutter_exe, - "config", - "--no-version-check", - "--suppress-analytics", - f"--jdk-dir={jdk_dir}", - ], - cwd=os.getcwd(), - capture_output=self.verbose < 1, - ) - if config_result.returncode != 0: - if isinstance(config_result.stdout, str): - console.log(config_result.stdout, style=verbose1_style) - if isinstance(config_result.stderr, str): - console.log(config_result.stderr, style=error_style) - self.cleanup(config_result.returncode) - - if self.verbose > 0: - console.log(f"JDK installed {self.emojis['checkmark']}") + if self.verbose > 0: + console.log(f"JDK installed {self.emojis['checkmark']}") def install_android_sdk(self): """ @@ -370,13 +423,13 @@ def install_android_sdk(self): from flet_cli.utils.android_sdk import AndroidSDK - self.update_status("[bold blue]Installing Android SDK...") - self.env["ANDROID_HOME"] = AndroidSDK( - self.env["JAVA_HOME"], self.log_stdout, progress=self.progress - ).install() + with self.step("[bold blue]Installing Android SDK..."): + self.env["ANDROID_HOME"] = AndroidSDK( + self.env["JAVA_HOME"], self.log_stdout, progress=self.progress + ).install() - if self.verbose > 0: - console.log(f"Android SDK installed {self.emojis['checkmark']}") + if self.verbose > 0: + console.log(f"Android SDK installed {self.emojis['checkmark']}") def _confirm_android_sdk_installation(self) -> bool: """ @@ -488,16 +541,28 @@ def cleanup(self, exit_code: int, message: Any = None, no_border: bool = False): """ if exit_code == 0: - self.live.update( - (message if no_border else Panel(message)) if message else "", - refresh=True, - ) + if self.no_rich_output: + # Non-interactive (plain/github): no box-drawn panel. Commands + # whose result is the output (device lists, emulator actions) + # pass a message; `flet build` passes none, as the exit code + # and its last step already say it. + self.live.update("", refresh=True) + if message: + output.console.print(message, soft_wrap=True) + else: + self.live.update( + (message if no_border else Panel(message)) if message else "", + refresh=True, + ) else: msg = ( message if message is not None else "Error building Flet app - see the log of failed command above." ) + if output.github: + # Annotate the failure, titled after the step it happened in. + output.error(msg, title=output.error_title) # windows has been reported to raise encoding errors # when running `flutter doctor` @@ -507,15 +572,29 @@ def cleanup(self, exit_code: int, message: Any = None, no_border: bool = False): (self.no_rich_output and self.current_platform == "Windows") or self.skip_flutter_doctor ): - status = console.status( - "[bold blue]Running Flutter doctor...", - spinner="bouncingBall", - ) - self.live.update( - Group(Panel(msg, style=error_style), status), refresh=True - ) - self.run_flutter_doctor() - self.live.update(Panel(msg, style=error_style), refresh=True) + if output.github: + with output.group("Running Flutter doctor"): + self.run_flutter_doctor() + else: + status = console.status( + "[bold blue]Running Flutter doctor...", + spinner="bouncingBall", + ) + self.live.update( + Group(Panel(msg, style=error_style), status), refresh=True + ) + self.run_flutter_doctor() + if output.github: + # The `::error::` line above already shows in the log (and + # as an annotation); a box-drawn panel would only repeat it. + self.live.update("", refresh=True) + elif self.no_rich_output: + # One plain line, not a box: some failures (bad arguments, + # a missing app) have no other output explaining them. + self.live.update("", refresh=True) + output.console.print(msg, style=error_style, soft_wrap=True) + else: + self.live.update(Panel(msg, style=error_style), refresh=True) sys.exit(exit_code) @@ -534,6 +613,28 @@ def run_flutter_doctor(self): if flutter_doctor.stderr: console.log(flutter_doctor.stderr, style=error_style) + @contextlib.contextmanager + def live_status(self, status: str) -> Generator[Live, None, None]: + """ + Show a spinner with `status`, and the SDK download progress bars, while + the block runs; available as `self.status` and `self.live`. + + In the non-interactive formats (plain/github) the display is transient: + rich renders nothing to a non-terminal console while it runs, and a + non-transient one would print its last frame (the spinner) on exit. + + Args: + status: Initial spinner text, such as `[bold blue]Initializing...`. + """ + + self.status = console.status(status, spinner="bouncingBall") + with Live( + Group(self.status, self.progress), + console=console, + transient=self.no_rich_output, + ) as self.live: + yield self.live + def update_status(self, status): """ Update current live status message or log it in plain-output mode. @@ -547,6 +648,73 @@ def update_status(self, status): else: self.status.update(status) + @contextlib.contextmanager + def step(self, status: str) -> Generator[None, None, None]: + """ + Run a block as a named build step. + + `rich`: shows `status` on the live spinner. `plain`: logs `status`. + `github`: wraps the block in `::group::` ... `::endgroup::`, + the title being `status` without markup and trailing ellipsis; the + group is closed even when the block raises or exits via `cleanup()`. + The block logs its own completion message, if any. + + Args: + status: Status text, such as `[bold blue]Generating app icons...`. + """ + + if not output.github: + self.update_status(status) + yield + return + + with output.group(step_title(status)): + yield + + def warn( + self, + message: Any, + title: Optional[str] = None, + file: Optional[str] = None, + line: Optional[int] = None, + **kwargs: Any, + ) -> None: + """ + Report a warning: a `Warning: <message>` log line, or a `::warning::` + annotation in the `github` log format. + + Args: + message: The warning text. + title: Annotation title (`github` only). + file: File the annotation points to (`github` only). + line: Line in `file` (`github` only). + **kwargs: Passed to `CliOutput.warn()`. + """ + + output.warn(message, title=title, file=file, line=line, **kwargs) + + def error( + self, + message: Any, + title: Optional[str] = None, + file: Optional[str] = None, + line: Optional[int] = None, + **kwargs: Any, + ) -> None: + """ + Report an error: a log line in the error style, or an `::error::` + annotation in the `github` log format. + + Args: + message: The error text. + title: Annotation title (`github` only). + file: File the annotation points to (`github` only). + line: Line in `file` (`github` only). + **kwargs: Passed to `CliOutput.error()`. + """ + + output.error(message, title=title, file=file, line=line, **kwargs) + def log_stdout(self, message): """ Log subprocess output lines when verbose mode is enabled. diff --git a/sdk/python/packages/flet-cli/src/flet_cli/commands/test.py b/sdk/python/packages/flet-cli/src/flet_cli/commands/test.py index 07daa224d9..633ce8737d 100644 --- a/sdk/python/packages/flet-cli/src/flet_cli/commands/test.py +++ b/sdk/python/packages/flet-cli/src/flet_cli/commands/test.py @@ -7,8 +7,6 @@ from pathlib import Path from typing import Optional -from rich.console import Group -from rich.live import Live from rich.markup import escape from flet_cli.commands.build_base import BaseBuildCommand, console @@ -230,18 +228,13 @@ def handle(self, options: argparse.Namespace) -> None: flutter_dir = Path(self.flutter_test_host).resolve() # Still need python_app_path/env for pytest; run a light init. self.test_mode = True - self.status = console.status( - "[bold blue]Preparing tests...", spinner="bouncingBall" - ) - with Live(Group(self.status, self.progress), console=console) as self.live: + with self.live_status("[bold blue]Preparing tests..."): self.initialize_command() self.validate_entry_point() else: - self.status = console.status( - f"[bold blue]Provisioning {self.target_platform} test host...", - spinner="bouncingBall", - ) - with Live(Group(self.status, self.progress), console=console) as self.live: + with self.live_status( + f"[bold blue]Provisioning {self.target_platform} test host..." + ): flutter_dir = _provision_steps(self) self.update_status("[bold blue]Test host ready. Starting tests...") @@ -310,11 +303,7 @@ def provision_test_host( cmd.test_platform_name = plat cmd.target_platform = TEST_PLATFORMS[plat]["target_platform"] - cmd.status = console.status( - f"[bold blue]Provisioning {cmd.target_platform} test host...", - spinner="bouncingBall", - ) - with Live(Group(cmd.status, cmd.progress), console=console) as cmd.live: + with cmd.live_status(f"[bold blue]Provisioning {cmd.target_platform} test host..."): flutter_dir = _provision_steps(cmd) # Make the SDK discoverable for the FletTestApp-spawned `flutter test` and diff --git a/sdk/python/packages/flet-cli/src/flet_cli/utils/log_format.py b/sdk/python/packages/flet-cli/src/flet_cli/utils/log_format.py new file mode 100644 index 0000000000..75941b8be3 --- /dev/null +++ b/sdk/python/packages/flet-cli/src/flet_cli/utils/log_format.py @@ -0,0 +1,466 @@ +""" +Output formats of the Flutter-based CLI commands (`flet build`, `flet debug`, +`flet test`, ...). + +* `rich` - interactive output: a live spinner shows the current step. +* `plain` - plain text lines, no color or animation (`--no-rich-output`). +* `github` - `plain` plus GitHub Actions workflow commands: every build step + is a collapsible `::group::` and warnings/errors are `::warning::`/`::error::` + annotations. See + https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands +""" + +import contextlib +import itertools +import os +from collections.abc import Generator, Mapping, Sequence +from typing import Any, Optional, Union + +from rich.console import Console +from rich.errors import MarkupError +from rich.panel import Panel +from rich.style import Style +from rich.text import Text + +LOG_FORMATS = ("rich", "plain", "github") +LOG_FORMAT_ENV_VAR = "FLET_CLI_LOG_FORMAT" +NO_RICH_OUTPUT_ENV_VAR = "FLET_CLI_NO_RICH_OUTPUT" + +StyleType = Union[str, Style, None] + + +def escape_data(value: Any) -> str: + """ + Escape the message part of a workflow command. + + Args: + value: The message; converted to `str`. + + Returns: + The message with `%`, `\\r` and `\\n` percent-encoded. + """ + + return str(value).replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + + +def escape_property(value: Any) -> str: + """ + Escape a property value (`title=`, `file=`, ...) of a workflow command. + + Args: + value: The property value; converted to `str`. + + Returns: + The value escaped like a message, with `:` and `,` also encoded. + """ + + return escape_data(value).replace(":", "%3A").replace(",", "%2C") + + +def workflow_command(command: str, message: Any = "", **properties: Any) -> str: + """ + Format a GitHub Actions workflow command line. + + Args: + command: The command name, such as `group` or `warning`. + message: The command message. + **properties: Command properties, in order; `None` values are omitted. + + Returns: + A line like `::warning title=Icon,line=3::message`. + """ + + props = ",".join( + f"{name}={escape_property(value)}" + for name, value in properties.items() + if value is not None + ) + return f"::{command}{' ' + props if props else ''}::{escape_data(message)}" + + +def _truthy(value: Optional[str]) -> bool: + # Same truthy values as `flet.utils.platform_utils.get_bool_env_var()`. + return (value or "").lower() in ("true", "1", "yes") + + +def resolve_log_format( + cli_value: Optional[str] = None, + no_rich_output: bool = False, + env: Optional[Mapping[str, str]] = None, +) -> str: + """ + Resolve the output format from the CLI option and the environment. + + `--log-format` wins over `FLET_CLI_LOG_FORMAT`. `--no-rich-output` and + `FLET_CLI_NO_RICH_OUTPUT` are aliases of `plain`: they turn the default + `rich` format into `plain` and leave an explicit `plain` or `github` + alone. + + Args: + cli_value: The `--log-format` value, if given. + no_rich_output: Whether `--no-rich-output` was given. + env: Environment variables; defaults to an empty mapping. + + Returns: + One of `LOG_FORMATS`. + + Raises: + ValueError: When the selected format is not one of `LOG_FORMATS`. + """ + + env = env or {} + log_format = (cli_value or env.get(LOG_FORMAT_ENV_VAR) or "rich").strip().lower() + if log_format not in LOG_FORMATS: + source = "--log-format" if cli_value else LOG_FORMAT_ENV_VAR + raise ValueError( + f"Invalid {source} value {log_format!r}; " + f"expected one of: {', '.join(LOG_FORMATS)}." + ) + if log_format == "rich" and ( + no_rich_output or _truthy(env.get(NO_RICH_OUTPUT_ENV_VAR)) + ): + return "plain" + return log_format + + +def detect_log_format(argv: Sequence[str], env: Mapping[str, str]) -> str: + """ + Resolve the output format straight from the raw command line. + + The shared console is created at import time, before argparse runs, so the + options that turn rich output off are looked up in `argv` here. Arguments + after `--` are passed down to Flutter and are ignored. Never raises: an + invalid value falls back to `rich` and is reported once argparse or + `resolve_log_format()` sees it. + + Args: + argv: The process arguments, usually `sys.argv`. + env: Environment variables, usually `os.environ`. + + Returns: + One of `LOG_FORMATS`. + """ + + cli_value = None + no_rich_output = False + for i, arg in enumerate(argv): + if arg == "--": + break + if arg == "--no-rich-output": + no_rich_output = True + elif arg == "--log-format" and i + 1 < len(argv): + cli_value = argv[i + 1] + elif arg.startswith("--log-format="): + cli_value = arg.split("=", 1)[1] + try: + return resolve_log_format(cli_value, no_rich_output, env) + except ValueError: + no_rich_output = no_rich_output or _truthy(env.get(NO_RICH_OUTPUT_ENV_VAR)) + return "plain" if no_rich_output else "rich" + + +def step_title(status: str) -> str: + """ + Turn a rich status message into a plain step title. + + Args: + status: A status message such as `[bold blue]Generating app icons...`. + + Returns: + The text without markup and trailing ellipsis: `Generating app icons`. + """ + + title = plain_text(status).strip() + for suffix in ("...", "…"): + if title.endswith(suffix): + title = title[: -len(suffix)].rstrip() + return title + + +def annotation_path(path: Union[str, os.PathLike]) -> str: + """ + Format a path for the `file=` property of an annotation. + + GitHub matches annotation files against `GITHUB_WORKSPACE`, even when + the build runs from a subdirectory. Outside GitHub Actions, use the + current working directory as the base. + + Args: + path: The file path. + + Returns: + The path relative to the workspace when it is inside it, + otherwise the absolute path; always with `/` separators. + """ + + absolute = os.path.abspath(path) + try: + relative = os.path.relpath( + absolute, os.environ.get("GITHUB_WORKSPACE") or os.getcwd() + ) + except ValueError: # another drive on Windows + relative = None + if relative is not None and not ( + relative == os.pardir or relative.startswith(os.pardir + os.sep) + ): + absolute = relative + return absolute.replace(os.sep, "/") + + +class PlainLogConsole(Console): + """ + Console for `plain` and `github` output: `log()` writes plain lines. + + Rich's `log()` lays lines out in a table with a time column — repeated + times are left blank, so continuation lines look indented — and wraps + them at the console width (80 columns without a terminal). In CI and + hosted-build logs both are noise — the viewer wraps long lines itself + and usually has its own timestamps — so `log()` prints like + `print(..., soft_wrap=True)`. + """ + + def log( # type: ignore[override] + self, + *objects: Any, + sep: str = " ", + end: str = "\n", + style: StyleType = None, + justify: Any = None, + emoji: Optional[bool] = None, + markup: Optional[bool] = None, + highlight: Optional[bool] = None, + log_locals: bool = False, + _stack_offset: int = 1, + ) -> None: + # One entry per call, like rich's log(): subprocess lines arrive + # with their newline stripped and `end=""`, and must not run together. + self.print( + *objects, + sep=sep, + end="\n", + style=style, + justify=justify, + emoji=emoji, + markup=markup, + highlight=highlight, + soft_wrap=True, + ) + + +def plain_text(message: Any, markup: bool = True) -> str: + """ + Render a console message as plain text. + + Args: + message: The message; rich markup is stripped when `markup` is true. + markup: Whether `message` contains rich markup. + + Returns: + The plain text of the message. + """ + + if isinstance(message, Text): + return message.plain + text = str(message) + if not markup: + return text + try: + return Text.from_markup(text).plain + except MarkupError: + return text + + +class CliOutput: + """ + Renders steps, warnings and errors in the selected log format. + + One instance is shared by the process, next to the shared rich console. + In the `github` format it tracks the open `::group::`: GitHub Actions does + not nest groups, so opening a group closes the previous one, and closing + a group that was already closed that way is a no-op. + """ + + def __init__( + self, + console: Console, + log_format: str = "rich", + warning_style: StyleType = None, + error_style: StyleType = None, + ): + self.console = console + self.format = log_format + self.warning_style = warning_style + self.error_style = error_style + self._group_ids = itertools.count(1) + self._group: Optional[tuple[int, str]] = None + self._failed_group: Optional[str] = None + + @property + def github(self) -> bool: + """Whether GitHub Actions workflow commands are emitted.""" + return self.format == "github" + + @property + def group_title(self) -> Optional[str]: + """Title of the open `github` group, or `None`.""" + return self._group[1] if self._group else None + + @property + def error_title(self) -> Optional[str]: + """ + Title for an error annotation: the open group, or else the group an + exception has just left - a failure caught outside the step that + raised it is still attributed to that step. + """ + return self.group_title or self._failed_group + + def command(self, command: str, message: Any = "", **properties: Any) -> None: + """ + Print a workflow command on a line of its own. + + Args: + command: The command name. + message: The command message. + **properties: Command properties; `None` values are omitted. + """ + + self.console.print( + workflow_command(command, message, **properties), + markup=False, + highlight=False, + emoji=False, + soft_wrap=True, + ) + + def start_group(self, title: str) -> int: + """ + Open a `::group::`, closing the one that is still open. + + Args: + title: The group title. + + Returns: + A token to pass to `end_group()`. + """ + + if self._group: + self.command("endgroup") + self._failed_group = None + group_id = next(self._group_ids) + self._group = (group_id, title) + self.command("group", title) + return group_id + + def end_group(self, group_id: int) -> None: + """ + Close the group opened with `group_id` if it is still the open one. + + Args: + group_id: The token returned by `start_group()`. + """ + + if self._group and self._group[0] == group_id: + self._group = None + self.command("endgroup") + + @contextlib.contextmanager + def group(self, title: str) -> Generator[None, None, None]: + """ + Wrap a block in a `::group::`; it is closed even when the block raises. + + Args: + title: The group title. + """ + + group_id = self.start_group(title) + try: + yield + except BaseException: + self._failed_group = title + raise + finally: + self.end_group(group_id) + + def warn( + self, + message: Any, + title: Optional[str] = None, + file: Optional[str] = None, + line: Optional[int] = None, + *, + prefix: str = "Warning: ", + markup: bool = True, + style: StyleType = None, + panel: bool = False, + ) -> None: + """ + Report a warning. + + `rich`/`plain`: logs `<prefix><message>` in the warning style (or + prints it in a panel). `github`: emits a `::warning::` annotation. + + Args: + message: The warning text. + title: Annotation title (`github` only). + file: File the annotation points to (`github` only). + line: Line in `file` (`github` only). + prefix: Text put before `message` in `rich`/`plain` output. + markup: Whether `message` contains rich markup. + style: Overrides the warning style in `rich` output. + panel: Print the warning in a panel in `rich`/`plain` output. + """ + + if self.github: + self.command( + "warning", + plain_text(message, markup), + title=title, + file=file, + line=line, + ) + elif panel: + self.console.print(Panel(message, style=style or self.warning_style)) + else: + self.console.log( + f"{prefix}{message}", + style=style or self.warning_style, + markup=markup, + ) + + def error( + self, + message: Any, + title: Optional[str] = None, + file: Optional[str] = None, + line: Optional[int] = None, + *, + prefix: str = "", + markup: bool = True, + ) -> None: + """ + Report an error. + + `rich`/`plain`: logs `<prefix><message>` in the error style. + `github`: emits an `::error::` annotation. + + Args: + message: The error text. + title: Annotation title (`github` only). + file: File the annotation points to (`github` only). + line: Line in `file` (`github` only). + prefix: Text put before `message` in `rich`/`plain` output. + markup: Whether `message` contains rich markup. + """ + + if self.github: + self.command( + "error", + plain_text(message, markup), + title=title, + file=file, + line=line, + ) + else: + self.console.log( + f"{prefix}{message}", style=self.error_style, markup=markup + ) diff --git a/sdk/python/packages/flet-cli/tests/test_flutter.py b/sdk/python/packages/flet-cli/tests/test_flutter.py index 527293137a..6e8fd710d1 100644 --- a/sdk/python/packages/flet-cli/tests/test_flutter.py +++ b/sdk/python/packages/flet-cli/tests/test_flutter.py @@ -1,5 +1,6 @@ """Tests concerning the Flutter SDK the Flet CLI provisions and drives.""" +import contextlib import os from types import SimpleNamespace from unittest.mock import patch @@ -33,6 +34,7 @@ def _install_flutter(monkeypatch, path_env, flutter_dir="/opt/flutter/3.32.0"): required_flutter_version="3.32.0", emojis={"checkmark": ""}, update_status=lambda *_, **__: None, + step=lambda *_, **__: contextlib.nullcontext(), log_stdout=lambda *_, **__: None, progress=None, run=lambda *_, **__: SimpleNamespace(returncode=0, stdout="", stderr=""), diff --git a/sdk/python/packages/flet-cli/tests/test_log_format.py b/sdk/python/packages/flet-cli/tests/test_log_format.py new file mode 100644 index 0000000000..3f8a2657e4 --- /dev/null +++ b/sdk/python/packages/flet-cli/tests/test_log_format.py @@ -0,0 +1,637 @@ +""" +`--log-format {rich,plain,github}` of the Flutter-based commands. + +`rich` and `plain` output must not change; `github` adds GitHub Actions +workflow commands: a `::group::` per build step and `::warning::`/`::error::` +annotations. +""" + +import argparse +import io +from types import SimpleNamespace + +import pytest +from rich.console import Console +from rich.panel import Panel +from rich.theme import Theme + +from flet_cli.commands import flutter_base +from flet_cli.commands.build import Command as BuildCommand +from flet_cli.commands.flutter_base import ( + BaseFlutterCommand, + error_style, + output, + warning_style, +) +from flet_cli.utils.log_format import ( + CliOutput, + annotation_path, + detect_log_format, + escape_data, + escape_property, + resolve_log_format, + step_title, + workflow_command, +) + + +def _console(terminal: bool) -> tuple[io.StringIO, Console]: + """A console configured like the shared CLI console, recording to a buffer.""" + buffer = io.StringIO() + return buffer, Console( + file=buffer, + log_path=False, + log_time=False, + theme=Theme({"log.message": "green bold"}), + force_terminal=terminal, + color_system="truecolor" if terminal else None, + width=100, + ) + + +@pytest.fixture +def github(monkeypatch): + """Switch the shared output to `github`, recording what it prints.""" + buffer, console = _console(terminal=False) + monkeypatch.setattr(output, "console", console) + monkeypatch.setattr(output, "format", "github") + monkeypatch.setattr(output, "_group", None) + monkeypatch.setattr(output, "_failed_group", None) + return buffer + + +class _Live: + """Stand-in for `rich.live.Live`: `cleanup()` only updates it.""" + + def __init__(self): + self.renderables = [] + + def update(self, renderable, refresh=False): + self.renderables.append(renderable) + + +def _command(**attrs) -> BaseFlutterCommand: + cmd = BaseFlutterCommand.__new__(BaseFlutterCommand) + cmd.live = _Live() + cmd.no_rich_output = True + cmd.skip_flutter_doctor = True + cmd.current_platform = "Linux" + cmd.statuses = [] + cmd.update_status = cmd.statuses.append + for name, value in attrs.items(): + setattr(cmd, name, value) + return cmd + + +class TestAnnotationPath: + @pytest.mark.parametrize("absolute", [False, True]) + def test_workspace_path_from_subdirectory(self, monkeypatch, tmp_path, absolute): + project = tmp_path / "apps" / "sample" + project.mkdir(parents=True) + monkeypatch.setenv("GITHUB_WORKSPACE", str(tmp_path)) + monkeypatch.chdir(project) + path = project / "assets" / "icon.png" if absolute else "assets/icon.png" + assert annotation_path(path) == "apps/sample/assets/icon.png" + + def test_without_workspace_uses_working_directory(self, monkeypatch, tmp_path): + monkeypatch.delenv("GITHUB_WORKSPACE", raising=False) + monkeypatch.chdir(tmp_path) + assert annotation_path(tmp_path / "assets" / "icon.png") == "assets/icon.png" + + def test_outside_workspace_stays_absolute(self, monkeypatch, tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setenv("GITHUB_WORKSPACE", str(workspace)) + monkeypatch.chdir(tmp_path) + path = tmp_path / "icon.png" + assert annotation_path(path) == path.as_posix() + + +class TestEscaping: + def test_data(self): + assert escape_data("100% done\r\nnext: a,b") == "100%25 done%0D%0Anext: a,b" + + def test_property(self): + assert ( + escape_property("100% done\r\nnext: a,b") + == "100%25 done%0D%0Anext%3A a%2Cb" + ) + + def test_percent_is_escaped_first(self): + # A literal "%0A" must not turn into a newline when GitHub decodes it. + assert escape_data("%0A") == "%250A" + assert escape_property("%3A") == "%253A" + + def test_command_with_properties(self): + assert ( + workflow_command( + "warning", + "line 1\nline 2", + title="Icon: 512x512, not 1024", + file="assets/icon.png", + line=3, + ) + == "::warning title=Icon%3A 512x512%2C not 1024,file=assets/icon.png," + "line=3::line 1%0Aline 2" + ) + + def test_command_omits_unset_properties(self): + assert workflow_command("error", "boom", title=None, file=None) == ( + "::error::boom" + ) + assert workflow_command("endgroup") == "::endgroup::" + + +class TestResolveLogFormat: + def test_default_is_rich(self): + assert resolve_log_format(None, False, {}) == "rich" + + @pytest.mark.parametrize("value", ["rich", "plain", "github", "GitHub"]) + def test_cli_value(self, value): + assert resolve_log_format(value, False, {}) == value.lower() + + def test_env_value(self): + assert resolve_log_format(None, False, {"FLET_CLI_LOG_FORMAT": "github"}) == ( + "github" + ) + + def test_cli_wins_over_env(self): + env = {"FLET_CLI_LOG_FORMAT": "github"} + assert resolve_log_format("plain", False, env) == "plain" + + @pytest.mark.parametrize( + "no_rich, env", + [(True, {}), (False, {"FLET_CLI_NO_RICH_OUTPUT": "1"})], + ) + def test_no_rich_output_is_plain(self, no_rich, env): + assert resolve_log_format(None, no_rich, env) == "plain" + + def test_no_rich_output_keeps_github(self): + assert resolve_log_format("github", True, {}) == "github" + + def test_falsy_no_rich_output_env(self): + assert resolve_log_format(None, False, {"FLET_CLI_NO_RICH_OUTPUT": "0"}) == ( + "rich" + ) + + def test_invalid_env_value(self): + with pytest.raises(ValueError, match="FLET_CLI_LOG_FORMAT"): + resolve_log_format(None, False, {"FLET_CLI_LOG_FORMAT": "json"}) + + +class TestDetectLogFormat: + @pytest.mark.parametrize( + "argv, expected", + [ + (["flet", "build", "apk"], "rich"), + (["flet", "build", "apk", "--log-format", "github"], "github"), + (["flet", "build", "apk", "--log-format=plain"], "plain"), + (["flet", "build", "apk", "--no-rich-output"], "plain"), + # Arguments after `--` belong to `flutter build`. + (["flet", "build", "apk", "--", "--log-format", "github"], "rich"), + # Invalid values are left for argparse to report. + (["flet", "build", "apk", "--log-format", "json"], "rich"), + ], + ) + def test_argv(self, argv, expected): + assert detect_log_format(argv, {}) == expected + + def test_env(self): + assert detect_log_format(["flet"], {"FLET_CLI_LOG_FORMAT": "github"}) == ( + "github" + ) + + +class TestOption: + def _parser(self) -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="flet build") + BuildCommand(parser) + return parser + + def test_listed_in_build_help(self): + # Build agents detect support for the option from `flet build --help`. + assert "--log-format {rich,plain,github}" in self._parser().format_help() + + @pytest.mark.parametrize( + "args, expected", + [ + ([], "rich"), + (["--log-format", "github"], "github"), + (["--log-format", "PLAIN"], "plain"), + (["--no-rich-output"], "plain"), + ], + ) + def test_handle(self, monkeypatch, args, expected): + monkeypatch.delenv("FLET_CLI_LOG_FORMAT", raising=False) + monkeypatch.delenv("FLET_CLI_NO_RICH_OUTPUT", raising=False) + monkeypatch.setattr(output, "format", output.format) + options = self._parser().parse_args(["apk", *args]) + cmd = BuildCommand(argparse.ArgumentParser()) + BaseFlutterCommand.handle(cmd, options) + assert cmd.log_format == expected + assert output.format == expected + assert cmd.no_rich_output == (expected != "rich") + + def test_invalid_choice(self, capsys): + with pytest.raises(SystemExit): + self._parser().parse_args(["apk", "--log-format", "json"]) + assert "invalid choice" in capsys.readouterr().err + + +class TestStepTitle: + @pytest.mark.parametrize( + "status, title", + [ + ("[bold blue]Generating app icons...", "Generating app icons"), + ( + "[bold blue]Building [cyan]Android App Bundle (.aab)[/cyan]...", + "Building Android App Bundle (.aab)", + ), + ("Packaging Python app…", "Packaging Python app"), + ( + "[bold blue]Notarizing [cyan]app.app[/cyan] (slow)...", + "Notarizing app.app (slow)", + ), + ], + ) + def test_strips_markup_and_ellipsis(self, status, title): + assert step_title(status) == title + + +class TestRichAndPlainUnchanged: + """`warn()` prints exactly what the `console.log` calls it replaced did.""" + + MESSAGES = [ + 'icon_background "#zz" is not a valid colour (expected #rrggbb); using white.', + "Skipping rename of [cyan]app-release.apk[/cyan] because it exists.", + "x" * 250, + ] + + @pytest.mark.parametrize("terminal", [True, False], ids=["rich", "plain"]) + @pytest.mark.parametrize("message", MESSAGES) + def test_warning(self, terminal, message): + before, console = _console(terminal) + console.log(f"Warning: {message}", style=warning_style) + after, console = _console(terminal) + CliOutput(console, "rich", warning_style=warning_style).warn( + message, title="ignored", file="ignored.png", line=1 + ) + assert after.getvalue() == before.getvalue() + + @pytest.mark.parametrize("terminal", [True, False], ids=["rich", "plain"]) + def test_warning_without_markup(self, terminal): + message = '.env not packaged; use `include = [".env"]` under [tool.flet.app]' + before, console = _console(terminal) + console.log(f"Warning: {message}", style=warning_style, markup=False) + after, console = _console(terminal) + CliOutput(console, "plain", warning_style=warning_style).warn( + message, markup=False + ) + assert after.getvalue() == before.getvalue() + + @pytest.mark.parametrize("terminal", [True, False], ids=["rich", "plain"]) + def test_warning_without_prefix(self, terminal): + message = "Flutter SDK not found or invalid version installed." + before, console = _console(terminal) + console.log(message, style=warning_style) + after, console = _console(terminal) + CliOutput(console, "rich", warning_style=warning_style).warn(message, prefix="") + assert after.getvalue() == before.getvalue() + + @pytest.mark.parametrize("terminal", [True, False], ids=["rich", "plain"]) + def test_warning_panel(self, terminal): + message = "This build will generate an .xcarchive (Xcode Archive)." + before, console = _console(terminal) + console.print(Panel(message, style=warning_style)) + after, console = _console(terminal) + CliOutput(console, "rich", warning_style=warning_style).warn( + message, panel=True + ) + assert after.getvalue() == before.getvalue() + + @pytest.mark.parametrize("terminal", [True, False], ids=["rich", "plain"]) + def test_error(self, terminal): + message = "Gradle task bundleRelease failed" + before, console = _console(terminal) + console.log(message, style=error_style) + after, console = _console(terminal) + CliOutput(console, "rich", error_style=error_style).error(message) + assert after.getvalue() == before.getvalue() + + @pytest.mark.parametrize("log_format", ["rich", "plain"]) + def test_step_updates_status(self, monkeypatch, log_format): + buffer, console = _console(terminal=False) + monkeypatch.setattr(output, "console", console) + monkeypatch.setattr(output, "format", log_format) + cmd = _command() + with cmd.step("[bold blue]Generating app icons..."): + pass + assert cmd.statuses == ["[bold blue]Generating app icons..."] + assert buffer.getvalue() == "" + + +class TestGithub: + def test_warning(self, github): + _command().warn( + "icon source is 512x256, not square.\nSupply a square image.", + title="App icon", + file="assets/icon.png", + line=1, + ) + assert github.getvalue() == ( + "::warning title=App icon,file=assets/icon.png,line=1::" + "icon source is 512x256, not square.%0ASupply a square image.\n" + ) + + def test_warning_strips_markup(self, github): + _command().warn("Skipping [cyan]app.apk[/cyan]", prefix="") + assert github.getvalue() == "::warning::Skipping app.apk\n" + + def test_warning_keeps_brackets_without_markup(self, github): + _command().warn("see [tool.flet.app]", markup=False) + assert github.getvalue() == "::warning::see [tool.flet.app]\n" + + def test_long_warning_is_one_line(self, github): + _command().warn("x" * 500) + assert github.getvalue() == f"::warning::{'x' * 500}\n" + + def test_panel_warning_is_an_annotation(self, github): + _command().warn("Unsigned build.", title="iOS signing", panel=True) + assert github.getvalue() == "::warning title=iOS signing::Unsigned build.\n" + + def test_error(self, github): + _command().error("Failed: a, b", title="flutter build", file="main.py") + assert github.getvalue() == ( + "::error title=flutter build,file=main.py::Failed: a, b\n" + ) + + def test_step_is_a_group(self, github): + cmd = _command() + with cmd.step("[bold blue]Generating app icons..."): + output.console.print("Generated app icons OK") + assert cmd.statuses == [] + assert github.getvalue() == ( + "::group::Generating app icons\nGenerated app icons OK\n::endgroup::\n" + ) + + @pytest.mark.parametrize("exception", [RuntimeError, SystemExit, KeyboardInterrupt]) + def test_group_closed_on_exception(self, github, exception): + cmd = _command() + with pytest.raises(exception), cmd.step("Packaging Python app..."): + raise exception("boom") + assert github.getvalue() == "::group::Packaging Python app\n::endgroup::\n" + assert output.group_title is None + assert output.error_title == "Packaging Python app" + + def test_nested_steps_do_not_nest_groups(self, github): + # GitHub Actions does not nest groups: the inner step closes the outer + # group, and the outer step's exit then closes nothing. + cmd = _command() + with cmd.step("Signing [cyan]app.app[/cyan]..."): + output.console.print("Signed") + with cmd.step("Notarizing [cyan]app.app[/cyan]..."): + output.console.print("Notarized") + assert github.getvalue() == ( + "::group::Signing app.app\nSigned\n::endgroup::\n" + "::group::Notarizing app.app\nNotarized\n::endgroup::\n" + ) + + def test_cleanup_failure_inside_step(self, github): + cmd = _command() + with ( + pytest.raises(SystemExit) as exit_info, + cmd.step("[bold blue]Building [cyan]Android App Bundle[/cyan]..."), + ): + cmd.cleanup(1, "Gradle task bundleRelease failed\nwith exit code 1") + assert exit_info.value.code == 1 + assert github.getvalue() == ( + "::group::Building Android App Bundle\n" + "::error title=Building Android App Bundle::" + "Gradle task bundleRelease failed%0Awith exit code 1\n" + "::endgroup::\n" + ) + # The annotation is the message; no box-drawn panel repeats it. + assert cmd.live.renderables[-1] == "" + + def test_cleanup_failure_after_step_raised(self, github): + # An exception caught outside the step that raised it is still + # attributed to that step. + cmd = _command() + with pytest.raises(SystemExit): + try: + with cmd.step("Notarizing app.app..."): + raise ValueError("notarization rejected") + except ValueError as e: + cmd.cleanup(1, str(e)) + assert github.getvalue() == ( + "::group::Notarizing app.app\n::endgroup::\n" + "::error title=Notarizing app.app::notarization rejected\n" + ) + + def test_cleanup_failure_outside_step(self, github): + with pytest.raises(SystemExit): + _command().cleanup(1, "Path to Flet app must be a directory") + assert github.getvalue() == "::error::Path to Flet app must be a directory\n" + + def test_cleanup_default_message(self, github): + with pytest.raises(SystemExit): + _command().cleanup(2) + assert github.getvalue() == ( + "::error::Error building Flet app - see the log of failed command above.\n" + ) + + def test_cleanup_strips_markup(self, github): + with pytest.raises(SystemExit): + _command().cleanup(1, "Build output not found in [cyan]build/x[/cyan]") + assert github.getvalue() == "::error::Build output not found in build/x\n" + + def test_cleanup_success_prints_nothing(self, github): + cmd = _command() + with pytest.raises(SystemExit) as exit_info: + cmd.cleanup(0) + assert exit_info.value.code == 0 + # Non-interactive: the exit code is the result, no banner. + assert github.getvalue() == "" + assert cmd.live.renderables[-1] == "" + + def test_cleanup_success_message_is_plain(self, github): + cmd = _command() + with pytest.raises(SystemExit): + cmd.cleanup(0, "Created emulator [cyan]pixel[/cyan].", no_border=True) + # A command's result (emulator actions, device lists) is still shown, + # without a panel. + assert github.getvalue() == "Created emulator pixel.\n" + assert cmd.live.renderables[-1] == "" + + def test_flutter_doctor_is_a_group(self, github): + cmd = _command(skip_flutter_doctor=False) + cmd.run_flutter_doctor = lambda: output.console.print("Doctor summary") + with pytest.raises(SystemExit), cmd.step("Building app..."): + cmd.cleanup(1, "Build failed") + assert github.getvalue() == ( + "::group::Building app\n" + "::error title=Building app::Build failed\n" + "::endgroup::\n" + "::group::Running Flutter doctor\nDoctor summary\n::endgroup::\n" + ) + + def test_build_snapshot(self, github): + """A typical build: one group per step, annotations where they occur.""" + cmd = _command(emojis={"checkmark": "OK"}) + log = output.console.print + with cmd.step("[bold blue]Creating app shell from template..."): + log("Created app shell OK") + with cmd.step("[bold blue]Packaging Python app..."): + cmd.warn( + ".env not packaged. Package it with `--include .env`.", + title="App package", + markup=False, + ) + log("Packaged Python app OK") + with cmd.step("[bold blue]Generating app icons..."): + cmd.warn( + "icon source is 512x256, not square.", + title="App icon", + file="assets/icon.png", + ) + log("Generated app icons OK") + with ( + pytest.raises(SystemExit), + cmd.step("[bold blue]Building [cyan]Android App Bundle[/cyan]..."), + ): + log("Running Gradle task 'bundleRelease'...") + cmd.cleanup(1) + assert github.getvalue().splitlines() == [ + "::group::Creating app shell from template", + "Created app shell OK", + "::endgroup::", + "::group::Packaging Python app", + "::warning title=App package::" + ".env not packaged. Package it with `--include .env`.", + "Packaged Python app OK", + "::endgroup::", + "::group::Generating app icons", + "::warning title=App icon,file=assets/icon.png::" + "icon source is 512x256, not square.", + "Generated app icons OK", + "::endgroup::", + "::group::Building Android App Bundle", + "Running Gradle task 'bundleRelease'...", + "::error title=Building Android App Bundle::" + "Error building Flet app - see the log of failed command above.", + "::endgroup::", + ] + + +def test_find_platform_image_warning_in_github(github, tmp_path): + """`find_platform_image` warns through the shared output, no command needed.""" + from flet_cli.commands.build_base import BaseBuildCommand + + assets = tmp_path / "assets" + assets.mkdir() + (assets / "icon.svg").write_bytes(b"<svg/>") + fake_self = SimpleNamespace(target_platform="web", verbose=0) + hash = SimpleNamespace(update=lambda *_: None) + assert BaseBuildCommand.find_platform_image(fake_self, assets, "icon", hash) is None + line = github.getvalue() + assert line.startswith("::warning title=Image,file=") + assert line.rstrip("\n").endswith( + '::"icon.svg" is a vector (SVG) image and cannot be used for "icon". ' + 'Provide a raster "icon.png" to customize it — using the default for now.' + ) + + +def test_module_console_matches_detected_format(): + # The shared console is plain whenever the detected format is not `rich`. + assert flutter_base.no_rich_output == (flutter_base.log_format != "rich") + + +def test_github_console_logs_plain_unwrapped_lines(): + import io + + from flet_cli.utils.log_format import PlainLogConsole + + buf = io.StringIO() + console = PlainLogConsole(file=buf, width=80, force_terminal=False, log_path=False) + long = ( + "Flutter executable: /opt/hostedtoolcache/flutter/stable-3.47.7-x64" + "/flutter/bin/cache/dart-sdk/bin/dart" + ) + console.log(long) + console.log("Run subprocess:", ["flutter", "--version"]) + lines = buf.getvalue().splitlines() + # No time column and no wrapping at the console width. + assert lines[0] == long + # Rich renders the list on its own line; it isn't indented under a + # (blank) time column. + assert lines[1:] == ["Run subprocess:", "['flutter', '--version']"] + assert not lines[0].startswith("[") + + +@pytest.fixture +def plain(monkeypatch): + """Switch the shared output to `plain`, recording what it prints.""" + buffer, console = _console(terminal=False) + monkeypatch.setattr(output, "console", console) + monkeypatch.setattr(output, "format", "plain") + monkeypatch.setattr(output, "_group", None) + monkeypatch.setattr(output, "_failed_group", None) + return buffer + + +def test_plain_cleanup_success_prints_nothing(plain): + cmd = _command() + with pytest.raises(SystemExit) as exit_info: + cmd.cleanup(0) + assert exit_info.value.code == 0 + assert plain.getvalue() == "" + assert cmd.live.renderables[-1] == "" + + +def test_plain_cleanup_success_message_has_no_panel(plain): + cmd = _command() + with pytest.raises(SystemExit): + cmd.cleanup(0, "Deleted emulator [cyan]pixel[/cyan].") + assert plain.getvalue() == "Deleted emulator pixel.\n" + + +def test_non_interactive_live_status_prints_no_spinner(monkeypatch): + from rich.progress import Progress + + buffer, console = _console(terminal=False) + monkeypatch.setattr(flutter_base, "console", console) + cmd = _command(progress=Progress(transient=True, console=console)) + with cmd.live_status("[bold blue]Provisioning windows test host..."): + cmd.status.update("[bold blue]Test host ready. Starting tests...") + # A non-transient Live prints its last frame (the spinner) on exit to a + # non-terminal console, running into whatever is logged next. + assert buffer.getvalue() == "" + + +def test_plain_cleanup_failure_is_one_plain_line(plain): + cmd = _command() + with pytest.raises(SystemExit) as exit_info: + cmd.cleanup(1, "Path to Flet app must be a directory") + assert exit_info.value.code == 1 + # No box-drawn panel, no workflow command. + assert plain.getvalue() == "Path to Flet app must be a directory\n" + assert cmd.live.renderables[-1] == "" + + +def test_plain_console_logs_each_call_on_its_own_line(): + from flet_cli.utils.log_format import PlainLogConsole + + buf = io.StringIO() + console = PlainLogConsole(file=buf, width=80, force_terminal=False, log_path=False) + # How `log_stdout` streams subprocess output: newline stripped, end="". + for line in ( + "Resolving dependencies...", + "Downloading packages...", + "+ audioplayers 6.8.1", + ): + console.log(line, end="", markup=False) + assert buf.getvalue().splitlines() == [ + "Resolving dependencies...", + "Downloading packages...", + "+ audioplayers 6.8.1", + ] diff --git a/sdk/python/packages/flet/src/flet/pytest_plugin.py b/sdk/python/packages/flet/src/flet/pytest_plugin.py index 9ef8afa2b5..7fbabc97ae 100644 --- a/sdk/python/packages/flet/src/flet/pytest_plugin.py +++ b/sdk/python/packages/flet/src/flet/pytest_plugin.py @@ -24,6 +24,28 @@ except ImportError: # pragma: no cover - plugin degrades to a no-op _HAS_ASYNCIO = False +_config = None + + +def pytest_configure(config): + global _config + _config = config + + +def ensure_newline() -> None: + """ + End pytest's progress line (`tests/test_app.py ..`) so that output written + next starts on its own line. + + Does nothing outside pytest, or when pytest captures output: captured + output is reported separately and must not break the progress line. + """ + if _config is None or _config.getoption("capture", None) != "no": + return + reporter = _config.pluginmanager.get_plugin("terminalreporter") + if reporter is not None: + reporter.ensure_newline() + def _is_device_mode() -> bool: # Device mode (app under test runs on-device with embedded Python) is the 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 e8a33a4541..71914ebb6b 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 @@ -16,6 +16,7 @@ import flet as ft from flet.controls.control import Control +from flet.pytest_plugin import ensure_newline from flet.testing.remote_tester import RemoteTester from flet.testing.tester import Tester from flet.utils.environment import without_host_python_config @@ -28,6 +29,16 @@ __all__ = ["FletTestApp"] +def _print(*args, **kwargs) -> None: + """ + `print()` that starts on its own line when pytest streams output (`-s`) + rather than after its progress dots, so workflow commands such as Flutter's + `::group::` lines are still recognized by CI. + """ + ensure_newline() + print(*args, **kwargs) + + class DisposalMode(Enum): """ Indicates the way in which a frame is treated after being displayed. @@ -183,6 +194,7 @@ def __init__( self.__tcp_port = tcp_port self.__flutter_process: Optional[asyncio.subprocess.Process] = None self.__flutter_output = bytearray() + self.__echo_flutter_output = False self.__flutter_output_task: Optional[asyncio.Task] = None self.__page = None self.__tester: Union[Tester, RemoteTester, None] = None @@ -249,7 +261,7 @@ async def main(page: ft.Page): remote = RemoteTester() self.__tcp_port = await remote.start(host="127.0.0.1", port=self.__tcp_port) self.__tester = remote - print(f"Started remote tester on 127.0.0.1:{self.__tcp_port}") + _print(f"Started remote tester on 127.0.0.1:{self.__tcp_port}") else: if self.__use_http: os.environ["FLET_FORCE_WEB_SERVER"] = "true" @@ -262,17 +274,19 @@ async def main(page: ft.Page): view=None, ) ) - print("Started Flet app") - - # Stream the Flutter test process output to the console when verbose - # (set by `flet test -v`) or when debug logging is on; otherwise - # capture it into a buffer so it can be dumped if the process fails. - verbose = ( + _print("Started Flet app") + + # Echo the Flutter test process output line by line when verbose (set + # by `flet test -v`) or when debug logging is on; otherwise capture it + # into a buffer so it can be dumped if the process fails. Echoed, not + # inherited: each line must start on its own line, not after pytest's + # progress dots. + self.__echo_flutter_output = ( get_bool_env_var("FLET_TEST_VERBOSE") or logging.getLogger().getEffectiveLevel() == logging.DEBUG ) - stdout = None if verbose else asyncio.subprocess.PIPE - stderr = None if verbose else asyncio.subprocess.STDOUT + stdout = asyncio.subprocess.PIPE + stderr = asyncio.subprocess.STDOUT # The resolved Flutter executable (full path, `flutter.bat` on Windows) # is passed by `flet test`; fall back to a bare "flutter" on PATH. @@ -338,8 +352,8 @@ async def main(page: ft.Page): self.__read_flutter_output(self.__flutter_process.stdout) ) - print("Started Flutter test process.") - print("Waiting for the Flutter app to connect...") + _print("Started Flutter test process.") + _print("Waiting for the Flutter app to connect...") def connected() -> bool: if self.__device_mode: @@ -382,9 +396,13 @@ def __append_flutter_output_line(self, line: bytearray, truncated: bool): if len(line) > self.__flutter_output_line_limit: line = line[: self.__flutter_output_line_limit] truncated = True - self.__flutter_output.extend(line.rstrip(b"\r\n")) + text = bytes(line.rstrip(b"\r\n")) if truncated: - self.__flutter_output.extend(b" ...<truncated>") + text += b" ...<truncated>" + if self.__echo_flutter_output: + _print(text.decode(errors="replace"), flush=True) + return + self.__flutter_output.extend(text) self.__flutter_output.extend(b"\n") if len(self.__flutter_output) > self.__flutter_output_limit: del self.__flutter_output[: -self.__flutter_output_limit] @@ -393,9 +411,9 @@ def __dump_flutter_output(self): if not self.__flutter_output: return output = self.__flutter_output.decode(errors="replace") - print("---------- Flutter test process output (tail) ----------") - print(output) - print("---------- End of Flutter test process output ----------") + _print("---------- Flutter test process output (tail) ----------") + _print(output) + _print("---------- End of Flutter test process output ----------") def __flutter_test_target(self) -> str: # In device mode the driver (`integration_test/app_test.dart`) is @@ -425,23 +443,23 @@ async def teardown(self): try: await self.tester.teardown(timeout=10) except (RuntimeError, TimeoutError) as e: - print(f"Tester teardown failed: {e}") + _print(f"Tester teardown failed: {e}") flutter_returncode: Optional[int] = None if self.__flutter_process: - print("\nWaiting for Flutter test process to exit...") + _print("Waiting for Flutter test process to exit...") try: await asyncio.wait_for(self.__flutter_process.wait(), timeout=10) flutter_returncode = self.__flutter_process.returncode - print(f"Flutter test process has exited (code {flutter_returncode}).") + _print(f"Flutter test process has exited (code {flutter_returncode}).") except asyncio.TimeoutError: - print("Flutter test process did not exit in time, terminating it...") + _print("Flutter test process did not exit in time, terminating it...") self.__flutter_process.terminate() # Optionally ensure it terminates try: await asyncio.wait_for(self.__flutter_process.wait(), timeout=5) except asyncio.TimeoutError: - print("Force killing Flutter test process...") + _print("Force killing Flutter test process...") self.__flutter_process.kill() if self.__flutter_output_task: @@ -645,7 +663,7 @@ def assert_screenshot( else: if not golden_image_path.exists(): if self.__golden_failed: - print(f"Creating missing golden for {name}") + _print(f"Creating missing golden for {name}") golden_image_path.parent.mkdir(parents=True, exist_ok=True) golden_image_path.write_bytes(screenshot) return @@ -655,14 +673,14 @@ def assert_screenshot( golden_img = self._load_image_from_file(golden_image_path) img = self._load_image_from_bytes(screenshot) similarity = self._compare_images_rgb(golden_img, img) - print(f"Similarity for {name}: {similarity}%") + _print(f"Similarity for {name}: {similarity}%") if similarity_threshold == 0: similarity_threshold = self.screenshots_similarity_threshold if self.__golden_failed and ( similarity <= similarity_threshold or self._pixels_differ(golden_img, img) ): - print(f"Updating failed golden for {name}") + _print(f"Updating failed golden for {name}") golden_image_path.write_bytes(screenshot) return if similarity <= similarity_threshold: @@ -904,7 +922,7 @@ def _assert_gif_bytes( zip(golden_frames, actual_frames, strict=True) ): similarity = self._compare_images_rgb(golden_frame, actual_frame) - print(f"GIF similarity for {name} frame {index}: {similarity}%") + _print(f"GIF similarity for {name} frame {index}: {similarity}%") assert similarity > self.screenshots_similarity_threshold, ( f"{name} GIF frame {index} differs " f"(similarity: {similarity}% <= " @@ -969,7 +987,7 @@ def assert_gif( if not golden_gif_path.exists(): if self.__golden_failed: - print(f"Creating missing golden GIF for {name}") + _print(f"Creating missing golden GIF for {name}") golden_gif_path.parent.mkdir(parents=True, exist_ok=True) golden_gif_path.write_bytes(gif_bytes) return @@ -978,7 +996,7 @@ def assert_gif( similarity, frame_count_mismatch = self._compare_gifs( golden_gif_path, gif_bytes ) - print(f"Similarity for {name}: {similarity}%") + _print(f"Similarity for {name}: {similarity}%") if similarity_threshold == 0: similarity_threshold = self.screenshots_similarity_threshold @@ -987,7 +1005,7 @@ def assert_gif( or similarity <= similarity_threshold or self._gif_pixels_differ(golden_gif_path, gif_bytes) ): - print(f"Updating failed golden GIF for {name}") + _print(f"Updating failed golden GIF for {name}") golden_gif_path.write_bytes(gif_bytes) return diff --git a/sdk/python/packages/flet/tests/test_pytest_plugin.py b/sdk/python/packages/flet/tests/test_pytest_plugin.py new file mode 100644 index 0000000000..5e0820af29 --- /dev/null +++ b/sdk/python/packages/flet/tests/test_pytest_plugin.py @@ -0,0 +1,47 @@ +from types import SimpleNamespace + +import pytest + +from flet import pytest_plugin + + +class _Reporter: + def __init__(self): + self.newlines = 0 + + def ensure_newline(self): + self.newlines += 1 + + +def _config(capture: str, reporter): + return SimpleNamespace( + getoption=lambda name, default=None: capture if name == "capture" else default, + pluginmanager=SimpleNamespace( + get_plugin=lambda name: reporter if name == "terminalreporter" else None + ), + ) + + +@pytest.fixture +def reporter(monkeypatch): + reporter = _Reporter() + monkeypatch.setattr(pytest_plugin, "_config", _config("no", reporter)) + return reporter + + +def test_ensure_newline_ends_progress_line_when_not_capturing(reporter): + pytest_plugin.ensure_newline() + assert reporter.newlines == 1 + + +@pytest.mark.parametrize("capture", ["fd", "sys", "tee-sys"]) +def test_ensure_newline_keeps_progress_line_when_capturing(monkeypatch, capture): + reporter = _Reporter() + monkeypatch.setattr(pytest_plugin, "_config", _config(capture, reporter)) + pytest_plugin.ensure_newline() + assert reporter.newlines == 0 + + +def test_ensure_newline_outside_pytest(monkeypatch): + monkeypatch.setattr(pytest_plugin, "_config", None) + pytest_plugin.ensure_newline() # no error diff --git a/website/docs/cli/flet-build.md b/website/docs/cli/flet-build.md index 312e00280f..e6345c3488 100644 --- a/website/docs/cli/flet-build.md +++ b/website/docs/cli/flet-build.md @@ -5,3 +5,31 @@ title: "flet build" import CliBuild from '@site/.crocodocs/cli-build.mdx'; <CliBuild /> + +## Log formats + +`--log-format` (or the `FLET_CLI_LOG_FORMAT` environment variable) selects how `flet build` +reports its progress: + +* `rich` (default) - a live spinner shows the current step. +* `plain` - plain text lines without colors, animation, timestamps or wrapping (log viewers wrap long lines themselves), same as `--no-rich-output`. +* `github` - `plain` output plus [GitHub Actions workflow commands](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands): + each build step (Flutter SDK, app shell, Python app packaging, icons, splash screens, + `flutter build`, macOS signing and notarization, ...) is a collapsible group, and + warnings and errors become annotations in the job summary. + +``` +::group::Generating app icons +::warning title=App icon,file=assets/icon.png::icon source is 512x256, not square. ... +[13:01:29] Generated app icons OK +::endgroup:: +::group::Building web app + Could not find an option named "--bogus-flag". +::error title=Building web app::Error building Flet app - see the log of failed command above. +::endgroup:: +::group::Running Flutter doctor +... +::endgroup:: +``` + +The lines are easy to parse for other CI systems and build services, too. diff --git a/website/docs/publish/index.md b/website/docs/publish/index.md index a9fe995585..dbf5e79b2a 100644 --- a/website/docs/publish/index.md +++ b/website/docs/publish/index.md @@ -2091,7 +2091,7 @@ env: # (6)! PYTHONUTF8: 1 # (8)! # https://flet.dev/docs/reference/environment-variables - FLET_CLI_NO_RICH_OUTPUT: 1 # (9)! + FLET_CLI_LOG_FORMAT: github # (9)! jobs: build: @@ -2191,7 +2191,7 @@ jobs: 6. Environment variables shared by all jobs and steps. 7. Python version used by `uv`. 8. Forces UTF-8 mode for Python output/IO. Especially useful on Windows builds. -9. Disables rich output from `flet build` for better readability in CI logs. +9. Prints plain `flet build` output with a collapsible group per build step and annotations for warnings and errors. See [Log formats](../cli/flet-build.md#log-formats). 10. Matrix strategy: each `include` item becomes a parallel build job. 11. Checks out your repository so this workflow can access project files. View its docs [here](https://github.com/actions/checkout). 12. Installs `uv` on the runner. View its docs [here](https://github.com/astral-sh/setup-uv). diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index b302572948..e399ae118b 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -195,9 +195,18 @@ ft.run(main, assets_dir="assets") For control properties like [`Image.src`](../controls/image.md#flet.Image.src), continue using paths relative to the `ft.run(assets_dir=...)`, as described in the [assets cookbook](../cookbook/assets.md). +### `FLET_CLI_LOG_FORMAT` + +Output format of `flet build`, `flet debug`, `flet test`, `flet devices` and +`flet emulators`: `rich`, `plain` or `github`. +Same as the `--log-format` option, which takes precedence. See +[`flet build`](../cli/flet-build.md#log-formats). + +Defaults to `"rich"`. + ### `FLET_CLI_NO_RICH_OUTPUT` -Whether to disable rich output in the console. +Whether to disable rich output in the console. Same as `FLET_CLI_LOG_FORMAT=plain`. Defaults to `"false"`.