flet-cli 0.85.3.dev0__py3-none-any.whl → 0.86.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
1
  import argparse
2
+ import base64
2
3
  import copy
3
4
  import glob
5
+ import json
4
6
  import os
5
7
  import platform
6
8
  import shutil
@@ -15,6 +17,7 @@ from rich.table import Column, Table
15
17
  import flet.version
16
18
  import flet_cli.utils.processes as processes
17
19
  from flet.utils import copy_tree, slugify
20
+ from flet.utils.deprecated import deprecated_warning
18
21
  from flet_cli.commands.flutter_base import (
19
22
  BaseFlutterCommand,
20
23
  console,
@@ -23,6 +26,10 @@ from flet_cli.commands.flutter_base import (
23
26
  verbose2_style,
24
27
  warning_style,
25
28
  )
29
+ from flet_cli.utils.android import (
30
+ ANDROID_ARCH_TO_FLUTTER_TARGET_PLATFORM,
31
+ excluded_android_abis,
32
+ )
26
33
  from flet_cli.utils.cli import parse_cli_bool_value
27
34
  from flet_cli.utils.hash_stamp import HashStamp
28
35
  from flet_cli.utils.merge import merge_dict
@@ -32,12 +39,28 @@ from flet_cli.utils.project_dependencies import (
32
39
  get_project_dependencies,
33
40
  )
34
41
  from flet_cli.utils.pyproject_toml import load_pyproject_toml
42
+ from flet_cli.utils.python_versions import (
43
+ UnsupportedPythonVersionError,
44
+ resolve_python_version,
45
+ )
35
46
 
36
47
  DEFAULT_TEMPLATE_URL = (
37
48
  "https://github.com/flet-dev/flet/releases/download/"
38
49
  "v{version}/flet-build-template.zip"
39
50
  )
40
51
 
52
+ # Android (serious_python native-mmap packaging): pure Python ships in stored zips
53
+ # read via zipimport, which breaks packages that read bundled data through a real
54
+ # filesystem path (__file__ / pkg_resources) instead of importlib.resources. Such
55
+ # packages are shipped extracted to disk via --android-extract-packages or
56
+ # [tool.flet.android].extract_packages.
57
+ #
58
+ # The default set is empty: the common offenders read their data via
59
+ # importlib.resources, which is zip-safe (e.g. certifi.where() works from the zip —
60
+ # importlib.resources.as_file() extracts cacert.pem to a temp file on demand). Add
61
+ # real offenders here as they are found.
62
+ ANDROID_DEFAULT_EXTRACT_PACKAGES: list[str] = []
63
+
41
64
 
42
65
  class BaseBuildCommand(BaseFlutterCommand):
43
66
  """
@@ -244,10 +267,13 @@ class BaseBuildCommand(BaseFlutterCommand):
244
267
  parser.add_argument(
245
268
  "--arch",
246
269
  dest="target_arch",
270
+ action="extend",
247
271
  nargs="+",
248
272
  default=[],
249
273
  help="Build for specific CPU architectures "
250
- "(used in macOS and Android builds only). Example: `--arch arm64 x64`",
274
+ "(used in macOS and Android builds only). "
275
+ "Android: arm64-v8a, armeabi-v7a, x86_64; macOS: arm64, x64. "
276
+ "Example: `--arch arm64-v8a`",
251
277
  )
252
278
  parser.add_argument(
253
279
  "--exclude",
@@ -263,7 +289,8 @@ class BaseBuildCommand(BaseFlutterCommand):
263
289
  dest="clear_cache",
264
290
  action="store_true",
265
291
  default=None,
266
- help="Remove any existing build cache before starting the build process",
292
+ help="Remove any existing build cache before starting the build process. "
293
+ "Deprecated: use the `flet clean` command instead",
267
294
  )
268
295
  parser.add_argument(
269
296
  "--project",
@@ -448,16 +475,30 @@ class BaseBuildCommand(BaseFlutterCommand):
448
475
  parser.add_argument(
449
476
  "--compile-app",
450
477
  dest="compile_app",
451
- action="store_true",
478
+ action=argparse.BooleanOptionalAction,
452
479
  default=None,
453
- help="Pre-compile app's `.py` files to `.pyc`",
480
+ help="Pre-compile app's `.py` files to `.pyc` (on by default; "
481
+ "use --no-compile-app to disable)",
454
482
  )
455
483
  parser.add_argument(
456
484
  "--compile-packages",
457
485
  dest="compile_packages",
458
- action="store_true",
486
+ action=argparse.BooleanOptionalAction,
487
+ default=None,
488
+ help="Pre-compile site packages' `.py` files to `.pyc` (on by default; "
489
+ "use --no-compile-packages to disable)",
490
+ )
491
+ parser.add_argument(
492
+ "--swift-package-manager",
493
+ dest="swift_package_manager",
494
+ action=argparse.BooleanOptionalAction,
459
495
  default=None,
460
- help="Pre-compile site packages' `.py` files to `.pyc`",
496
+ help="Integrate the embedded Python runtime via Swift Package Manager "
497
+ "(default) or CocoaPods for iOS/macOS builds. On by default, matching "
498
+ "Flutter 3.44+ which uses SPM by default (other non-SPM plugins still "
499
+ "build with CocoaPods alongside it). Use --no-swift-package-manager (or "
500
+ "`swift_package_manager = false` under [tool.flet]) only if you've "
501
+ "disabled Swift Package Manager in Flutter.",
461
502
  )
462
503
  parser.add_argument(
463
504
  "--cleanup-app",
@@ -497,10 +538,28 @@ class BaseBuildCommand(BaseFlutterCommand):
497
538
  parser.add_argument(
498
539
  "--source-packages",
499
540
  dest="source_packages",
541
+ action="extend",
500
542
  nargs="+",
501
543
  default=[],
502
544
  help="The list of Python packages to install from source distributions",
503
545
  )
546
+ parser.add_argument(
547
+ "--android-extract-packages",
548
+ dest="android_extract_packages",
549
+ nargs="+",
550
+ default=[],
551
+ help="Android only: Python packages (relative paths) to ship extracted "
552
+ "to disk instead of inside the app zip — for packages that read bundled "
553
+ "data via __file__ / pkg_resources rather than importlib.resources",
554
+ )
555
+ parser.add_argument(
556
+ "--python-version",
557
+ dest="python_version",
558
+ type=str,
559
+ default=None,
560
+ help="Python version to bundle (e.g. 3.13). Defaults to the latest "
561
+ "supported version, or is parsed from project.requires-python.",
562
+ )
504
563
  parser.add_argument(
505
564
  "--info-plist",
506
565
  dest="info_plist",
@@ -552,6 +611,7 @@ class BaseBuildCommand(BaseFlutterCommand):
552
611
  "--permissions",
553
612
  dest="permissions",
554
613
  type=str.lower,
614
+ action="extend",
555
615
  nargs="+",
556
616
  default=[],
557
617
  choices=["location", "camera", "microphone", "photo_library"],
@@ -647,6 +707,22 @@ class BaseBuildCommand(BaseFlutterCommand):
647
707
  """
648
708
 
649
709
  super().handle(options)
710
+
711
+ if getattr(self.options, "clear_cache", None):
712
+ deprecated_warning(
713
+ name="--clear-cache",
714
+ reason="Use the `flet clean` command instead.",
715
+ version="0.86.0",
716
+ delete_version="0.89.0",
717
+ type="flag",
718
+ )
719
+ console.print(
720
+ "Warning: the `--clear-cache` flag is deprecated since version "
721
+ "0.86.0 and will be removed in version 0.89.0. "
722
+ "Use the `flet clean` command instead.",
723
+ style=warning_style,
724
+ )
725
+
650
726
  if "target_platform" in self.options:
651
727
  self.target_platform = self.options.target_platform
652
728
 
@@ -693,6 +769,29 @@ class BaseBuildCommand(BaseFlutterCommand):
693
769
  self.pubspec_path = str(self.flutter_dir.joinpath("pubspec.yaml"))
694
770
  self.get_pyproject = load_pyproject_toml(self.python_app_path)
695
771
 
772
+ try:
773
+ self.python_release = resolve_python_version(
774
+ self.options.python_version, self.get_pyproject
775
+ )
776
+ except UnsupportedPythonVersionError as e:
777
+ self.cleanup(1, str(e))
778
+
779
+ # Changing the bundled Python version invalidates the compiled bytecode
780
+ # baked into the previous build's native bundles (stdlib/site-packages
781
+ # .pyc). Reusing the build directory would mix versions and crash at
782
+ # runtime with "bad magic number". Force a clean rebuild on a switch.
783
+ version_marker = self.build_dir / ".python-version"
784
+ if self.build_dir.exists() and version_marker.exists():
785
+ previous = version_marker.read_text(encoding="utf-8").strip()
786
+ if previous and previous != self.python_release.short:
787
+ console.log(
788
+ f"Bundled Python version changed ({previous} -> "
789
+ f"{self.python_release.short}); cleaning the build directory."
790
+ )
791
+ shutil.rmtree(self.build_dir, ignore_errors=True)
792
+ self.build_dir.mkdir(parents=True, exist_ok=True)
793
+ version_marker.write_text(self.python_release.short, encoding="utf-8")
794
+
696
795
  def validate_target_platform(self):
697
796
  """
698
797
  Validate whether current host OS can build the selected target platform.
@@ -794,6 +893,13 @@ class BaseBuildCommand(BaseFlutterCommand):
794
893
  or self.get_pyproject("project.name")
795
894
  or self.python_app_path.name
796
895
  )
896
+ # Under integration test, `flutter test -d <desktop>` launches the built
897
+ # binary by the project name (the Flutter pubspec `name`), but the
898
+ # Windows/Linux runner sets the executable's OUTPUT_NAME to artifact_name.
899
+ # When they differ (e.g. `artifact = "my-app"` vs project `my_app`) the
900
+ # test host can't find the binary. Pin them equal in test mode.
901
+ if getattr(self, "test_mode", False):
902
+ artifact_name = project_name
797
903
  product_name = (
798
904
  self.options.product_name
799
905
  or self.get_pyproject("tool.flet.product")
@@ -826,6 +932,7 @@ class BaseBuildCommand(BaseFlutterCommand):
826
932
  "android.hardware.touchscreen": False,
827
933
  }
828
934
  android_meta_data = {}
935
+ android_providers = {}
829
936
 
830
937
  # merge values from "--permissions" arg:
831
938
  for p in (
@@ -974,6 +1081,88 @@ class BaseBuildCommand(BaseFlutterCommand):
974
1081
  else:
975
1082
  self.cleanup(1, f"Invalid Android meta-data option: {p}")
976
1083
 
1084
+ android_providers = merge_dict(
1085
+ android_providers,
1086
+ self.get_pyproject("tool.flet.android.provider") or {},
1087
+ )
1088
+
1089
+ def _xml_attr_value(v):
1090
+ # Android XML expects lowercase booleans.
1091
+ if isinstance(v, bool):
1092
+ return "true" if v else "false"
1093
+ return v
1094
+
1095
+ normalized_providers = {}
1096
+ for key, value in android_providers.items():
1097
+ if value is False or value == {}:
1098
+ continue
1099
+ if value is True:
1100
+ self.cleanup(
1101
+ 1,
1102
+ f"Invalid Android provider value for {key}: 'true' is not "
1103
+ "supported. Use an inline table of attributes, or 'false' "
1104
+ "to skip.",
1105
+ )
1106
+ if not isinstance(value, dict):
1107
+ self.cleanup(
1108
+ 1,
1109
+ f"Invalid Android provider value for {key}: "
1110
+ f"{type(value).__name__}. Expected boolean or inline table.",
1111
+ )
1112
+ normalized = {}
1113
+ for ak, av in value.items():
1114
+ if ak == "name":
1115
+ self.cleanup(
1116
+ 1,
1117
+ f"Invalid Android provider attribute for {key}: "
1118
+ "'name' is reserved and is taken from the table key.",
1119
+ )
1120
+ if ak == "meta_data":
1121
+ if not isinstance(av, dict):
1122
+ self.cleanup(
1123
+ 1,
1124
+ f"Invalid Android provider meta_data for {key}: "
1125
+ f"{type(av).__name__}. Expected inline table.",
1126
+ )
1127
+ normalized_meta = {}
1128
+ for mk, mv in av.items():
1129
+ if isinstance(mv, (str, bool, int, float)):
1130
+ normalized_meta[mk] = _xml_attr_value(mv)
1131
+ continue
1132
+ if isinstance(mv, dict):
1133
+ normalized_attrs = {}
1134
+ for attr_key, attr_value in mv.items():
1135
+ if not isinstance(attr_value, (str, bool, int, float)):
1136
+ self.cleanup(
1137
+ 1,
1138
+ f"Invalid Android provider meta-data "
1139
+ f"attribute value for "
1140
+ f"{key}.meta_data.{mk}.{attr_key}: "
1141
+ f"{type(attr_value).__name__}. "
1142
+ "Expected string, boolean, or number.",
1143
+ )
1144
+ normalized_attrs[attr_key] = _xml_attr_value(attr_value)
1145
+ normalized_meta[mk] = normalized_attrs
1146
+ continue
1147
+ self.cleanup(
1148
+ 1,
1149
+ f"Invalid Android provider meta-data value for "
1150
+ f"{key}.meta_data.{mk}: {type(mv).__name__}. "
1151
+ "Expected string, boolean, number, or inline table.",
1152
+ )
1153
+ normalized["meta_data"] = normalized_meta
1154
+ continue
1155
+ if not isinstance(av, (str, bool, int, float)):
1156
+ self.cleanup(
1157
+ 1,
1158
+ f"Invalid Android provider attribute value for "
1159
+ f"{key}.{ak}: {type(av).__name__}. "
1160
+ "Expected string, boolean, or number.",
1161
+ )
1162
+ normalized[ak] = _xml_attr_value(av)
1163
+ normalized_providers[key] = normalized
1164
+ android_providers = normalized_providers
1165
+
977
1166
  deep_linking_scheme = (
978
1167
  self.get_pyproject("tool.flet.ios.deep_linking.scheme")
979
1168
  if self.package_platform == "iOS"
@@ -1003,6 +1192,40 @@ class BaseBuildCommand(BaseFlutterCommand):
1003
1192
  or self.get_pyproject(f"tool.flet.{self.config_platform}.target_arch")
1004
1193
  or self.get_pyproject("tool.flet.target_arch")
1005
1194
  )
1195
+ target_arch = (
1196
+ target_arch
1197
+ if isinstance(target_arch, list)
1198
+ else [target_arch]
1199
+ if isinstance(target_arch, str)
1200
+ else []
1201
+ )
1202
+ if self.package_platform == "Android":
1203
+ invalid_archs = [
1204
+ arch
1205
+ for arch in target_arch
1206
+ if arch not in ANDROID_ARCH_TO_FLUTTER_TARGET_PLATFORM
1207
+ ]
1208
+ if invalid_archs:
1209
+ self.cleanup(
1210
+ 1,
1211
+ f"Invalid Android architecture(s): {', '.join(invalid_archs)}.\n"
1212
+ f"Supported: "
1213
+ f"{', '.join(ANDROID_ARCH_TO_FLUTTER_TARGET_PLATFORM)}.\n"
1214
+ f"Docs: https://flet.dev/docs/publish/android#supported-target-architectures",
1215
+ )
1216
+ python_abis = list(self.python_release.android_abis)
1217
+ unsupported_archs = [a for a in target_arch if a not in python_abis]
1218
+ if unsupported_archs:
1219
+ self.cleanup(
1220
+ 1,
1221
+ f"Architecture(s) not supported by Python "
1222
+ f"{self.python_release.short}: {', '.join(unsupported_archs)}.\n"
1223
+ f"Supported: {', '.join(python_abis)}.\n"
1224
+ f"Docs: https://flet.dev/docs/publish/android#supported-target-architectures",
1225
+ )
1226
+ if not target_arch:
1227
+ # Build only for the ABIs the bundled Python supports.
1228
+ target_arch = python_abis
1006
1229
 
1007
1230
  ios_export_method = (
1008
1231
  self.options.ios_export_method
@@ -1044,6 +1267,7 @@ class BaseBuildCommand(BaseFlutterCommand):
1044
1267
  self.target_platform in ["ipa"]
1045
1268
  and not ios_provisioning_profile
1046
1269
  and not self.debug_platform
1270
+ and not getattr(self, "test_mode", False)
1047
1271
  ):
1048
1272
  console.print(
1049
1273
  Panel(
@@ -1084,8 +1308,16 @@ class BaseBuildCommand(BaseFlutterCommand):
1084
1308
  "no_cdn": (
1085
1309
  self.options.no_cdn or self.get_pyproject("tool.flet.web.cdn") == False # noqa: E712
1086
1310
  ),
1311
+ # Surface the resolved Pyodide release to the cookiecutter
1312
+ # context so the web template's index.html can wire the
1313
+ # correct jsdelivr URL when CDN mode is on.
1314
+ "pyodide_version": self.python_release.pyodide,
1087
1315
  "base_url": f"/{base_url}/" if base_url else "/",
1088
1316
  "split_per_abi": split_per_abi,
1317
+ # Enabled by `flet test` to scaffold integration-test wiring
1318
+ # (integration_test/ + flutter_test dev deps). Default False so
1319
+ # normal `flet build`/`flet debug` output is unaffected.
1320
+ "test_mode": getattr(self, "test_mode", False),
1089
1321
  "project_name": project_name,
1090
1322
  "project_name_slug": project_name_slug,
1091
1323
  "artifact_name": artifact_name,
@@ -1114,11 +1346,11 @@ class BaseBuildCommand(BaseFlutterCommand):
1114
1346
  "options": {
1115
1347
  "package_platform": self.package_platform,
1116
1348
  "config_platform": self.config_platform,
1117
- "target_arch": (
1118
- target_arch
1119
- if isinstance(target_arch, list)
1120
- else [target_arch]
1121
- if isinstance(target_arch, str)
1349
+ "python_version": self.python_release.short,
1350
+ "target_arch": target_arch,
1351
+ "android_excluded_abis": (
1352
+ excluded_android_abis(target_arch)
1353
+ if self.package_platform == "Android"
1122
1354
  else []
1123
1355
  ),
1124
1356
  "info_plist": info_plist,
@@ -1126,6 +1358,7 @@ class BaseBuildCommand(BaseFlutterCommand):
1126
1358
  "android_permissions": android_permissions,
1127
1359
  "android_features": android_features,
1128
1360
  "android_meta_data": android_meta_data,
1361
+ "android_providers": android_providers,
1129
1362
  "deep_linking": {
1130
1363
  "scheme": deep_linking_scheme,
1131
1364
  "host": deep_linking_host,
@@ -1137,9 +1370,77 @@ class BaseBuildCommand(BaseFlutterCommand):
1137
1370
  ),
1138
1371
  },
1139
1372
  "flutter": {"dependencies": list(self.flutter_dependencies.keys())},
1373
+ "boot_screen": self._resolve_boot_screen(),
1140
1374
  "pyproject": self.get_pyproject(),
1141
1375
  }
1142
1376
 
1377
+ def _resolve_boot_screen(self):
1378
+ """
1379
+ Resolve the boot screen configuration from pyproject.toml.
1380
+
1381
+ Merges the global `[tool.flet.boot_screen]` with the platform-specific
1382
+ `[tool.flet.<platform>.boot_screen]` (platform wins per key), resolves
1383
+ the selected screen `name` (default "flet") and its options table.
1384
+
1385
+ Falls back to the legacy `[tool.flet[.<platform>].app.boot_screen]` /
1386
+ `app.startup_screen` (`show`/`message`) settings, mapping them onto the
1387
+ built-in "flet" screen with a deprecation warning.
1388
+
1389
+ Returns a dict with `name` and `options_b64` (base64-encoded JSON of the
1390
+ options table) for the cookiecutter template.
1391
+ """
1392
+ config_platform = self.config_platform
1393
+
1394
+ def merged(key):
1395
+ result = {}
1396
+ merge_dict(
1397
+ result, copy.deepcopy(self.get_pyproject(f"tool.flet.{key}") or {})
1398
+ )
1399
+ merge_dict(
1400
+ result,
1401
+ copy.deepcopy(
1402
+ self.get_pyproject(f"tool.flet.{config_platform}.{key}") or {}
1403
+ ),
1404
+ )
1405
+ return result
1406
+
1407
+ boot_screen = merged("boot_screen")
1408
+
1409
+ if boot_screen:
1410
+ name = boot_screen.get("name", "flet")
1411
+ options = boot_screen.get(name) or {}
1412
+ else:
1413
+ # backward compatibility with the legacy app.boot_screen /
1414
+ # app.startup_screen settings
1415
+ name = "flet"
1416
+ options = {}
1417
+ legacy_boot = merged("app.boot_screen")
1418
+ legacy_startup = merged("app.startup_screen")
1419
+ if legacy_boot or legacy_startup:
1420
+ console.log(
1421
+ "[tool.flet.app.boot_screen] and "
1422
+ "[tool.flet.app.startup_screen] are deprecated; use "
1423
+ "[tool.flet.boot_screen] with a named screen instead.",
1424
+ style=warning_style,
1425
+ )
1426
+ if legacy_boot.get("show"):
1427
+ options["spinner_size"] = 30
1428
+ message = legacy_boot.get("message")
1429
+ if message:
1430
+ options["prepare_message"] = message
1431
+ if legacy_startup.get("show"):
1432
+ options["spinner_size"] = 30
1433
+ message = legacy_startup.get("message")
1434
+ if message:
1435
+ options["startup_message"] = message
1436
+
1437
+ return {
1438
+ "name": name,
1439
+ "options_b64": base64.b64encode(json.dumps(options).encode("utf-8")).decode(
1440
+ "ascii"
1441
+ ),
1442
+ }
1443
+
1143
1444
  def create_flutter_project(self, second_pass=False):
1144
1445
  """
1145
1446
  Render Flutter bootstrap project from template if template inputs changed.
@@ -1174,6 +1475,9 @@ class BaseBuildCommand(BaseFlutterCommand):
1174
1475
  template_ref = flet.version.flet_version
1175
1476
 
1176
1477
  is_local_dev = False
1478
+ # Identity printed in status / hashed for invalidation; may differ from
1479
+ # the path cookiecutter actually reads when caching kicks in below.
1480
+ template_source = template_url
1177
1481
  if template_url:
1178
1482
  # User-provided template (git repo or local path) — use checkout
1179
1483
  checkout = template_ref
@@ -1182,13 +1486,19 @@ class BaseBuildCommand(BaseFlutterCommand):
1182
1486
  local_tpl = Path(__file__).resolve().parents[5] / "templates" / "build"
1183
1487
  if local_tpl.is_dir():
1184
1488
  template_url = str(local_tpl)
1489
+ template_source = template_url
1185
1490
  checkout = None
1186
1491
  is_local_dev = True
1187
1492
  else:
1188
- template_url = DEFAULT_TEMPLATE_URL.format(version=template_ref)
1493
+ from flet_cli.utils.template_cache import get_cached_template_zip
1494
+
1495
+ template_source = DEFAULT_TEMPLATE_URL.format(version=template_ref)
1496
+ template_url = str(
1497
+ get_cached_template_zip(template_source, template_ref)
1498
+ )
1189
1499
  checkout = None
1190
1500
 
1191
- hash.update(template_url)
1501
+ hash.update(template_source)
1192
1502
  hash.update(template_ref)
1193
1503
 
1194
1504
  template_dir = self.options.template_dir or self.get_pyproject(
@@ -1214,7 +1524,7 @@ class BaseBuildCommand(BaseFlutterCommand):
1214
1524
  # create a new Flutter bootstrap project directory, if non-existent
1215
1525
  if not second_pass:
1216
1526
  self.flutter_dir.mkdir(parents=True, exist_ok=True)
1217
- status = f"[bold blue]Creating app shell from {template_url}"
1527
+ status = f"[bold blue]Creating app shell from {template_source}"
1218
1528
  if checkout:
1219
1529
  status += f' with ref "{template_ref}"'
1220
1530
  status += "..."
@@ -1239,6 +1549,8 @@ class BaseBuildCommand(BaseFlutterCommand):
1239
1549
  self.cleanup(1, f"{e}")
1240
1550
 
1241
1551
  # For local development, override flet dependency with path
1552
+ repo_root = None
1553
+ pubspec = None
1242
1554
  if is_local_dev:
1243
1555
  repo_root = flet.version.find_repo_root(Path(__file__).resolve().parent)
1244
1556
  if repo_root:
@@ -1248,7 +1560,36 @@ class BaseBuildCommand(BaseFlutterCommand):
1248
1560
  pubspec.setdefault("dependency_overrides", {})["flet"] = {
1249
1561
  "path": flet_pkg_path
1250
1562
  }
1251
- self.save_yaml(self.pubspec_path, pubspec)
1563
+
1564
+ # In test mode, inject the integration-test driver (and flutter_test)
1565
+ # as dev dependencies. They are intentionally NOT in the template
1566
+ # pubspec: that keeps it valid YAML for the release patch tooling and
1567
+ # ensures a normal `flet build` never pulls them. flet_integration_test
1568
+ # is publish_to:none, so for local dev it resolves to the in-repo
1569
+ # package by path, and for an end user it is a git dependency pinned to
1570
+ # this flet version's tag.
1571
+ if getattr(self, "test_mode", False):
1572
+ if pubspec is None:
1573
+ pubspec = self.load_yaml(self.pubspec_path)
1574
+ dev_deps = pubspec.setdefault("dev_dependencies", {})
1575
+ dev_deps["flutter_test"] = {"sdk": "flutter"}
1576
+ if is_local_dev and repo_root:
1577
+ fit_pkg_path = str(repo_root / "packages" / "flet_integration_test")
1578
+ dev_deps["flet_integration_test"] = {"path": fit_pkg_path}
1579
+ pubspec.setdefault("dependency_overrides", {})[
1580
+ "flet_integration_test"
1581
+ ] = {"path": fit_pkg_path}
1582
+ else:
1583
+ dev_deps["flet_integration_test"] = {
1584
+ "git": {
1585
+ "url": "https://github.com/flet-dev/flet.git",
1586
+ "ref": f"v{flet.version.flet_version}",
1587
+ "path": "packages/flet_integration_test",
1588
+ }
1589
+ }
1590
+
1591
+ if pubspec is not None:
1592
+ self.save_yaml(self.pubspec_path, pubspec)
1252
1593
 
1253
1594
  pyproject_pubspec = self.get_pyproject("tool.flet.flutter.pubspec")
1254
1595
 
@@ -1291,10 +1632,15 @@ class BaseBuildCommand(BaseFlutterCommand):
1291
1632
  assert self.template_data
1292
1633
  assert self.build_dir
1293
1634
 
1635
+ # Replace the permanent flutter-packages copy with this build's set. The
1636
+ # temp dir is populated by serious_python's package step and is ABSENT
1637
+ # when the app has no Flutter extensions — so always clear the old copy
1638
+ # first, otherwise an extension removed since the previous build (e.g.
1639
+ # dropping flet-video) would linger here and stay in the built app.
1640
+ if self.flutter_packages_dir.exists():
1641
+ shutil.rmtree(self.flutter_packages_dir, ignore_errors=True)
1294
1642
  if self.flutter_packages_temp_dir.exists():
1295
1643
  # copy packages from temp to permanent location
1296
- if self.flutter_packages_dir.exists():
1297
- shutil.rmtree(self.flutter_packages_dir, ignore_errors=True)
1298
1644
  shutil.move(self.flutter_packages_temp_dir, self.flutter_packages_dir)
1299
1645
 
1300
1646
  if self.flutter_packages_dir.exists():
@@ -1756,6 +2102,25 @@ class BaseBuildCommand(BaseFlutterCommand):
1756
2102
  d[pp[-1]] = f"{images_dir}/{image}"
1757
2103
  return
1758
2104
 
2105
+ def _darwin_spm_active(self) -> bool:
2106
+ """Whether to stage serious_python for Swift Package Manager (vs CocoaPods).
2107
+
2108
+ On by default, matching Flutter 3.44+ (SPM enabled by default). Because
2109
+ `serious_python_darwin` ships a `Package.swift`, Flutter always builds it
2110
+ as an SPM plugin when SPM is enabled — even in a hybrid app where other,
2111
+ non-SPM plugins (e.g. `flet-video`/media_kit) build with CocoaPods at the
2112
+ same time. So serious_python must stage for SPM to match; it is NOT tied
2113
+ to whether the app also pulls in non-SPM plugins. Users force CocoaPods
2114
+ with `--no-swift-package-manager` (or `swift_package_manager = false` under
2115
+ `[tool.flet]`) only when they've disabled SPM in Flutter itself. Flet does
2116
+ not change Flutter's global SPM configuration.
2117
+ """
2118
+ if self.package_platform not in ("iOS", "Darwin"):
2119
+ return False
2120
+ return self.get_bool_setting(
2121
+ self.options.swift_package_manager, "swift_package_manager", True
2122
+ )
2123
+
1759
2124
  def package_python_app(self):
1760
2125
  """
1761
2126
  Package Python app and dependencies into Flutter-consumable app archive.
@@ -1786,14 +2151,24 @@ class BaseBuildCommand(BaseFlutterCommand):
1786
2151
  str(self.package_app_path),
1787
2152
  "--platform",
1788
2153
  self.package_platform,
2154
+ "--python-version",
2155
+ self.python_release.short,
1789
2156
  ]
1790
2157
 
1791
2158
  if self.template_data["options"]["target_arch"]:
2159
+ # serious_python's --arch is a Dart multi-option: values must be
2160
+ # comma-separated or the flag repeated. Space-separated values
2161
+ # after the first are silently treated as positional arguments.
1792
2162
  package_args.extend(
1793
- ["--arch"] + self.template_data["options"]["target_arch"]
2163
+ ["--arch", ",".join(self.template_data["options"]["target_arch"])]
1794
2164
  )
1795
2165
 
1796
- package_env = {}
2166
+ # Only the short version is passed; serious_python derives the full
2167
+ # version, python-build date, and dart_bridge version from its own
2168
+ # committed snapshot of the manifest.
2169
+ package_env = {
2170
+ "SERIOUS_PYTHON_VERSION": self.python_release.short,
2171
+ }
1797
2172
 
1798
2173
  # requirements
1799
2174
  requirements_txt = self.python_app_path.joinpath("requirements.txt")
@@ -1826,7 +2201,13 @@ class BaseBuildCommand(BaseFlutterCommand):
1826
2201
  if not dev_path.is_absolute():
1827
2202
  dev_path = (self.python_app_path / dev_path).resolve()
1828
2203
  if dev_path.exists():
1829
- toml_dependencies[i] = f"{package_name} @ file://{dev_path}"
2204
+ # Use Path.as_uri() so Windows drive paths render as
2205
+ # `file:///D:/a/...` rather than `file://D:\a\...`,
2206
+ # which pip otherwise treats as a UNC path and fails
2207
+ # to resolve.
2208
+ toml_dependencies[i] = (
2209
+ f"{package_name} @ {dev_path.as_uri()}"
2210
+ )
1830
2211
  else:
1831
2212
  toml_dependencies[i] = (
1832
2213
  f"{package_name} @ {package_location}"
@@ -1856,6 +2237,23 @@ class BaseBuildCommand(BaseFlutterCommand):
1856
2237
  package_env["SERIOUS_PYTHON_SITE_PACKAGES"] = str(
1857
2238
  self.build_dir / "site-packages"
1858
2239
  )
2240
+ # app staging dir: serious_python's `package` places the processed
2241
+ # app here (no app.zip on native); the platform native build copies
2242
+ # it into the bundle (Android zips it as a stored asset).
2243
+ package_env["SERIOUS_PYTHON_APP"] = str(self.build_dir / "python-app")
2244
+
2245
+ # Swift Package Manager (darwin): tell serious_python's package command to
2246
+ # do the host-side SPM staging (the podspec prepare_command doesn't run
2247
+ # under SPM) and write the SP_NATIVE_SET cache-bust key to this file.
2248
+ # serious_python defaults to SPM staging, so be explicit either way — set
2249
+ # it false for the CocoaPods cases (e.g. an app using flet-video).
2250
+ if self.package_platform in ("iOS", "Darwin"):
2251
+ spm = self._darwin_spm_active()
2252
+ package_env["SERIOUS_PYTHON_DARWIN_SPM"] = "true" if spm else "false"
2253
+ if spm:
2254
+ package_env["SERIOUS_PYTHON_SPM_KEY_FILE"] = str(
2255
+ self.build_dir / ".serious_python_spm_key"
2256
+ )
1859
2257
 
1860
2258
  # flutter-packages variable
1861
2259
  if self.flutter_packages_temp_dir.exists():
@@ -1891,11 +2289,31 @@ class BaseBuildCommand(BaseFlutterCommand):
1891
2289
  source_packages
1892
2290
  )
1893
2291
 
1894
- if self.get_bool_setting(self.options.compile_app, "compile.app", False):
2292
+ # android-extract-packages: path-hungry packages shipped extracted to disk
2293
+ # instead of inside the zip (serious_python Android native-mmap packaging).
2294
+ # A built-in default set covers commonly-broken packages; the user list
2295
+ # (CLI / pyproject) is merged on top. Consumed by the serious_python_android
2296
+ # Gradle split during `flutter build`, so the env var is set on build_env
2297
+ # (see _run_flutter_command), not on the package step.
2298
+ self.android_extract_packages: list[str] = []
2299
+ if self.package_platform == "Android":
2300
+ user_extract_packages = (
2301
+ self.options.android_extract_packages
2302
+ or self.get_pyproject(
2303
+ f"tool.flet.{self.config_platform}.extract_packages"
2304
+ )
2305
+ or self.get_pyproject("tool.flet.extract_packages")
2306
+ or []
2307
+ )
2308
+ self.android_extract_packages = list(
2309
+ dict.fromkeys(ANDROID_DEFAULT_EXTRACT_PACKAGES + user_extract_packages)
2310
+ )
2311
+
2312
+ if self.get_bool_setting(self.options.compile_app, "compile.app", True):
1895
2313
  package_args.append("--compile-app")
1896
2314
 
1897
2315
  if self.get_bool_setting(
1898
- self.options.compile_packages, "compile.packages", False
2316
+ self.options.compile_packages, "compile.packages", True
1899
2317
  ):
1900
2318
  package_args.append("--compile-packages")
1901
2319
 
@@ -1982,13 +2400,36 @@ class BaseBuildCommand(BaseFlutterCommand):
1982
2400
 
1983
2401
  hash.commit()
1984
2402
 
1985
- # make sure app/app.zip exists
1986
- app_zip_path = self.flutter_dir.joinpath("app", "app.zip")
1987
- if not os.path.exists(app_zip_path):
1988
- self.cleanup(1, "Flet app package app/app.zip was not created.")
2403
+ # verify the package output: web ships app/app.zip; native platforms
2404
+ # stage the unpacked app to build/app for the native build to bundle.
2405
+ if self.package_platform == "Emscripten":
2406
+ app_zip_path = self.flutter_dir.joinpath("app", "app.zip")
2407
+ if not os.path.exists(app_zip_path):
2408
+ self.cleanup(1, "Flet app package app/app.zip was not created.")
2409
+ else:
2410
+ app_staging_dir = self.build_dir / "python-app"
2411
+ if not app_staging_dir.exists():
2412
+ self.cleanup(
2413
+ 1, f"Flet app package was not staged to {app_staging_dir}."
2414
+ )
1989
2415
 
1990
2416
  console.log(f"Packaged Python app {self.emojis['checkmark']}")
1991
2417
 
2418
+ # Drop the matching Pyodide runtime into the Flutter project's web/
2419
+ # directory so it ships in `flutter build web` output. Cached
2420
+ # per-version under ~/.flet/pyodide/<version>/ so subsequent builds
2421
+ # are no-ops.
2422
+ if self.package_platform == "Emscripten":
2423
+ from flet_cli.utils.pyodide import ensure_pyodide
2424
+
2425
+ self.update_status("[bold blue]Preparing Pyodide runtime...")
2426
+ pyodide_dest = self.flutter_dir / "web" / "pyodide"
2427
+ ensure_pyodide(self.python_release.pyodide, pyodide_dest)
2428
+ console.log(
2429
+ f"Pyodide {self.python_release.pyodide} ready "
2430
+ f"{self.emojis['checkmark']}"
2431
+ )
2432
+
1992
2433
  def get_bool_setting(self, cli_option, pyproj_setting, default_value):
1993
2434
  """
1994
2435
  Resolve a boolean setting with precedence: CLI option, pyproject, default.
@@ -2038,6 +2479,60 @@ class BaseBuildCommand(BaseFlutterCommand):
2038
2479
 
2039
2480
  self._run_flutter_command()
2040
2481
 
2482
+ def _serious_python_build_env(self) -> dict:
2483
+ """
2484
+ serious_python environment for the platform NATIVE build (the Gradle /
2485
+ CMake / podspec steps run by `flutter build`).
2486
+
2487
+ These tell the native build where the `package` step staged the app and
2488
+ site-packages and which embedded Python runtime to bundle. `flet build`
2489
+ applies them via `_run_flutter_command`; `flet test` applies the SAME set
2490
+ to the `flutter test` it spawns (see test.py `_flutter_path_env`) so both
2491
+ bundle an identical app. In particular, without `SERIOUS_PYTHON_APP` the
2492
+ Android `packageApp` Gradle task early-returns and a stale `app.zip` (e.g.
2493
+ an old-Python `main.pyc`) survives in the APK — `ImportError: bad magic
2494
+ number`. Built defensively so it is safe to call before the full build
2495
+ pipeline has populated every attribute.
2496
+ """
2497
+
2498
+ env: dict = {}
2499
+ python_release = getattr(self, "python_release", None)
2500
+ if python_release is not None:
2501
+ # Only the short version is passed; serious_python derives the rest
2502
+ # from its committed manifest snapshot.
2503
+ env["SERIOUS_PYTHON_VERSION"] = python_release.short
2504
+
2505
+ build_dir = getattr(self, "build_dir", None)
2506
+ package_platform = getattr(self, "package_platform", None)
2507
+ if build_dir is not None and package_platform != "Emscripten":
2508
+ env["SERIOUS_PYTHON_SITE_PACKAGES"] = str(build_dir / "site-packages")
2509
+ # app staging dir: read by the platform native build (CMake / podspec
2510
+ # / Android Gradle) at `flutter build` time to place the unpacked app
2511
+ # into the bundle.
2512
+ env["SERIOUS_PYTHON_APP"] = str(build_dir / "python-app")
2513
+
2514
+ # Swift Package Manager (darwin): export the cache-bust key the package
2515
+ # step computed so the plugin's Package.swift re-resolves when the staged
2516
+ # native set changes (SwiftPM caches its graph on manifest text + env).
2517
+ if (
2518
+ build_dir is not None
2519
+ and package_platform in ("iOS", "Darwin")
2520
+ and self._darwin_spm_active()
2521
+ ):
2522
+ spm_key_file = build_dir / ".serious_python_spm_key"
2523
+ if spm_key_file.exists():
2524
+ env["SP_NATIVE_SET"] = spm_key_file.read_text().strip()
2525
+
2526
+ # Path-hungry packages to ship extracted to disk: consumed by the
2527
+ # serious_python_android Gradle split during `flutter build`.
2528
+ if package_platform == "Android" and getattr(
2529
+ self, "android_extract_packages", None
2530
+ ):
2531
+ env["SERIOUS_PYTHON_ANDROID_EXTRACT_PACKAGES"] = ",".join(
2532
+ self.android_extract_packages
2533
+ )
2534
+ return env
2535
+
2041
2536
  def _run_flutter_command(self):
2042
2537
  """
2043
2538
  Build final Flutter CLI command, configure environment, and run it.
@@ -2059,13 +2554,10 @@ class BaseBuildCommand(BaseFlutterCommand):
2059
2554
  ]
2060
2555
  )
2061
2556
 
2062
- build_env = {}
2063
-
2064
- # site-packages variable
2065
- if self.package_platform != "Emscripten":
2066
- build_env["SERIOUS_PYTHON_SITE_PACKAGES"] = str(
2067
- self.build_dir / "site-packages"
2068
- )
2557
+ # serious_python env for the native build, shared verbatim with `flet
2558
+ # test` (which spawns its own `flutter test`) so both bundle an identical
2559
+ # app — see `_serious_python_build_env`.
2560
+ build_env = self._serious_python_build_env()
2069
2561
 
2070
2562
  if self.package_platform == "Emscripten" and not self.template_data["no_wasm"]:
2071
2563
  build_args.append("--wasm")
@@ -2139,6 +2631,32 @@ class BaseBuildCommand(BaseFlutterCommand):
2139
2631
  console.log(build_result.stderr, style=error_style)
2140
2632
  self.cleanup(build_result.returncode if build_result.returncode else 1)
2141
2633
 
2634
+ def resolve_output_path(self, build_output: str) -> str:
2635
+ """
2636
+ Resolve a platform `outputs` glob to an absolute path inside the
2637
+ Flutter project, substituting the `{arch}` and name placeholders.
2638
+
2639
+ Args:
2640
+ build_output: An entry of `self.platforms[...]["outputs"]`.
2641
+ """
2642
+
2643
+ assert self.flutter_dir
2644
+ assert self.template_data
2645
+
2646
+ arch = platform.machine().lower()
2647
+ if arch in {"x86_64", "amd64"}:
2648
+ arch = "x64"
2649
+ elif arch in {"arm64", "aarch64"}:
2650
+ arch = "arm64"
2651
+
2652
+ return (
2653
+ str(self.flutter_dir.joinpath(build_output))
2654
+ .replace("{arch}", arch)
2655
+ .replace("{artifact_name}", self.template_data["artifact_name"])
2656
+ .replace("{project_name}", self.template_data["project_name"])
2657
+ .replace("{product_name}", self.template_data["product_name"])
2658
+ )
2659
+
2142
2660
  def copy_build_output(self):
2143
2661
  """
2144
2662
  Copy generated platform artifacts into the requested output directory.
@@ -2154,11 +2672,6 @@ class BaseBuildCommand(BaseFlutterCommand):
2154
2672
  self.update_status(
2155
2673
  f"[bold blue]Copying build to [cyan]{self.rel_out_dir}[/cyan] directory...",
2156
2674
  )
2157
- arch = platform.machine().lower()
2158
- if arch in {"x86_64", "amd64"}:
2159
- arch = "x64"
2160
- elif arch in {"arm64", "aarch64"}:
2161
- arch = "arm64"
2162
2675
 
2163
2676
  def make_ignore_fn(out_dir, out_glob):
2164
2677
  """
@@ -2177,13 +2690,7 @@ class BaseBuildCommand(BaseFlutterCommand):
2177
2690
  return ignore
2178
2691
 
2179
2692
  for build_output in self.platforms[self.target_platform]["outputs"]:
2180
- build_output_dir = (
2181
- str(self.flutter_dir.joinpath(build_output))
2182
- .replace("{arch}", arch)
2183
- .replace("{artifact_name}", self.template_data["artifact_name"])
2184
- .replace("{project_name}", self.template_data["project_name"])
2185
- .replace("{product_name}", self.template_data["product_name"])
2186
- )
2693
+ build_output_dir = self.resolve_output_path(build_output)
2187
2694
 
2188
2695
  if self.verbose > 0:
2189
2696
  console.log(