flet-cli 1.1.0.dev0__py3-none-any.whl → 1.1.0.dev2__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.
@@ -9,6 +9,7 @@ import os
9
9
  import platform
10
10
  import re
11
11
  import shutil
12
+ import textwrap
12
13
  from pathlib import Path
13
14
  from typing import Any, Optional, cast
14
15
 
@@ -47,10 +48,23 @@ from flet_cli.commands.flutter_base import (
47
48
  from flet_cli.commands.options import PassThroughArgsAction
48
49
  from flet_cli.utils.android import (
49
50
  ANDROID_ARCH_TO_FLUTTER_TARGET_PLATFORM,
51
+ ANDROID_RESOURCES_KEEP_FILE,
52
+ android_resource_clashes,
53
+ android_resource_files,
54
+ android_resources_keep_xml,
50
55
  excluded_android_abis,
56
+ normalize_android_receivers,
57
+ normalize_android_services,
51
58
  )
52
59
  from flet_cli.utils.app_excludes import find_default_excludes
53
60
  from flet_cli.utils.cli import parse_cli_bool_value
61
+ from flet_cli.utils.extension_settings import (
62
+ ExtensionSettings,
63
+ contributed_settings,
64
+ find_extension_settings,
65
+ merge_compile_options,
66
+ merge_gradle_dependencies,
67
+ )
54
68
  from flet_cli.utils.flutter import get_flutter_dir
55
69
  from flet_cli.utils.hash_stamp import HashStamp
56
70
  from flet_cli.utils.log_format import annotation_path
@@ -59,6 +73,7 @@ from flet_cli.utils.plist import is_supported_plist_value, parse_cli_plist_value
59
73
  from flet_cli.utils.project_dependencies import (
60
74
  get_poetry_dependencies,
61
75
  get_project_dependencies,
76
+ read_requirements_file_text,
62
77
  )
63
78
  from flet_cli.utils.pyproject_toml import load_pyproject_toml
64
79
  from flet_cli.utils.python_versions import (
@@ -71,6 +86,11 @@ DEFAULT_TEMPLATE_URL = (
71
86
  "v{version}/flet-build-template.zip"
72
87
  )
73
88
 
89
+ DEFAULT_ANDROID_COMPILE_OPTIONS = {
90
+ "sourceCompatibility": "JavaVersion.VERSION_17",
91
+ "targetCompatibility": "JavaVersion.VERSION_17",
92
+ }
93
+
74
94
  # Android (serious_python native-mmap packaging): pure Python ships in stored zips
75
95
  # read via zipimport, which breaks packages that read bundled data through a real
76
96
  # filesystem path (__file__ / pkg_resources) instead of importlib.resources. Such
@@ -150,6 +170,8 @@ class BaseBuildCommand(BaseFlutterCommand):
150
170
  self.out_dir = None
151
171
  self.python_module_name = None
152
172
  self.get_pyproject = None
173
+ self.extensions: list[ExtensionSettings] = []
174
+ """Build settings of installed extensions, read after packaging."""
153
175
  self.python_app_path = None
154
176
  self.build_dir = None
155
177
  self.flutter_dir: Optional[Path] = None
@@ -713,6 +735,16 @@ class BaseBuildCommand(BaseFlutterCommand):
713
735
  "strings, numbers, TOML arrays, or TOML inline tables "
714
736
  "(macos only); can be used multiple times",
715
737
  )
738
+ parser.add_argument(
739
+ "--ios-entitlements",
740
+ dest="ios_entitlements",
741
+ action="extend",
742
+ nargs="+",
743
+ default=[],
744
+ help="The list of `<key>=<value>` entitlements. Values can be booleans, "
745
+ "strings, numbers, TOML arrays, or TOML inline tables "
746
+ "(ipa and ios-simulator only); can be used multiple times",
747
+ )
716
748
  parser.add_argument(
717
749
  "--android-features",
718
750
  dest="android_features",
@@ -1532,6 +1564,12 @@ class BaseBuildCommand(BaseFlutterCommand):
1532
1564
  # Resolved before the context, because the PWA background falls
1533
1565
  # through to it.
1534
1566
  splash = self._resolve_splash()
1567
+ org_name = (
1568
+ self.options.org_name
1569
+ or self.get_pyproject(f"tool.flet.{self.config_platform}.org")
1570
+ or self.get_pyproject("tool.flet.org")
1571
+ or "com.flet"
1572
+ )
1535
1573
 
1536
1574
  self.template_data = {
1537
1575
  "out_dir": self.flutter_dir.name,
@@ -1607,12 +1645,13 @@ class BaseBuildCommand(BaseFlutterCommand):
1607
1645
  # Pre-escaped Linux desktop entry values.
1608
1646
  "linux_desktop_exec": self.escape_linux_desktop_exec(str(artifact_name)),
1609
1647
  "linux_categories": linux_categories,
1610
- "org_name": self.options.org_name
1611
- or self.get_pyproject(f"tool.flet.{self.config_platform}.org")
1612
- or self.get_pyproject("tool.flet.org"),
1648
+ "org_name": org_name,
1649
+ # The template derives nothing from these: the build names files
1650
+ # after the bundle ID too, such as the app's Linux icons.
1613
1651
  "bundle_id": self.options.bundle_id
1614
1652
  or self.get_pyproject(f"tool.flet.{self.config_platform}.bundle_id")
1615
- or self.get_pyproject("tool.flet.bundle_id"),
1653
+ or self.get_pyproject("tool.flet.bundle_id")
1654
+ or f"{org_name}.{project_name_slug}",
1616
1655
  "company_name": (
1617
1656
  self.options.company_name or self.get_pyproject("tool.flet.company")
1618
1657
  ),
@@ -1623,7 +1662,10 @@ class BaseBuildCommand(BaseFlutterCommand):
1623
1662
  "ios_signing_certificate": ios_signing_certificate,
1624
1663
  "ios_export_options": ios_export_options,
1625
1664
  "ios_team_id": ios_team_id,
1626
- "options": {
1665
+ # `_` makes `_options` and `_pyproject` private cookiecutter
1666
+ # variables, which reach the template unchanged. Cookiecutter
1667
+ # renders other values as Jinja and turns them into strings.
1668
+ "_options": {
1627
1669
  "package_platform": self.package_platform,
1628
1670
  "config_platform": self.config_platform,
1629
1671
  "python_version": self.python_release.short,
@@ -1635,12 +1677,17 @@ class BaseBuildCommand(BaseFlutterCommand):
1635
1677
  ),
1636
1678
  "info_plist": info_plist,
1637
1679
  "macos_entitlements": macos_entitlements,
1680
+ "ios_entitlements": self.get_ios_entitlements(),
1638
1681
  "android_permissions": android_permissions,
1639
1682
  "android_features": android_features,
1640
1683
  "android_meta_data": android_meta_data,
1641
1684
  "android_gradle_properties": android_gradle_properties,
1685
+ "android_compile_options": self.get_android_compile_options(),
1686
+ "android_gradle_dependencies": self.get_android_gradle_dependencies(),
1642
1687
  "android_proguard_rules": android_proguard_rules,
1643
1688
  "android_providers": android_providers,
1689
+ "android_receivers": self.get_android_receivers(),
1690
+ "android_services": self.get_android_services(),
1644
1691
  "deep_linking": {
1645
1692
  "scheme": deep_linking_scheme,
1646
1693
  "host": deep_linking_host,
@@ -1667,7 +1714,7 @@ class BaseBuildCommand(BaseFlutterCommand):
1667
1714
  or self.icon_background_value("android")
1668
1715
  or "#ffffff"
1669
1716
  ),
1670
- "pyproject": self.get_pyproject(),
1717
+ "_pyproject": self.get_pyproject(),
1671
1718
  }
1672
1719
 
1673
1720
  @staticmethod
@@ -2062,6 +2109,56 @@ class BaseBuildCommand(BaseFlutterCommand):
2062
2109
  f"Registered app user extensions {self.emojis['checkmark']}"
2063
2110
  )
2064
2111
 
2112
+ self.apply_extension_settings()
2113
+
2114
+ def apply_extension_settings(self):
2115
+ """
2116
+ Apply the build settings installed extensions declare in their
2117
+ `flet-extension.toml`, under the app's own settings.
2118
+
2119
+ Settings an extension changes are recomputed for the second template pass.
2120
+ """
2121
+
2122
+ assert self.build_dir
2123
+ assert self.flutter_dir
2124
+ assert self.get_pyproject
2125
+ assert self.template_data
2126
+
2127
+ # serious_python installs web packages in its own default location, the
2128
+ # `build/site-packages` directory of the Flutter project it runs in.
2129
+ site_packages_dir = (
2130
+ self.flutter_dir / "build" / "site-packages"
2131
+ if self.package_platform == "Emscripten"
2132
+ else self.build_dir / "site-packages"
2133
+ )
2134
+ try:
2135
+ self.extensions, warnings = find_extension_settings(
2136
+ site_packages_dir, self.flutter_packages_dir
2137
+ )
2138
+ except ValueError as e:
2139
+ self.cleanup(1, str(e))
2140
+ for warning in warnings:
2141
+ self.warn(warning)
2142
+ for extension in self.extensions:
2143
+ contributed = contributed_settings(extension)
2144
+ if not contributed:
2145
+ continue
2146
+ console.log(
2147
+ f"Applied build settings from {extension.label}: "
2148
+ f"{', '.join(contributed)}",
2149
+ style=verbose1_style,
2150
+ markup=False,
2151
+ )
2152
+
2153
+ # Android settings only apply to Android builds, so other builds don't
2154
+ # fail on extensions' conflicting compile options.
2155
+ if self.package_platform == "Android":
2156
+ options = self.template_data["_options"]
2157
+ options["android_compile_options"] = self.get_android_compile_options()
2158
+ options["android_gradle_dependencies"] = (
2159
+ self.get_android_gradle_dependencies()
2160
+ )
2161
+
2065
2162
  def update_flutter_dependencies(self):
2066
2163
  """
2067
2164
  Merge resolved Flutter extension dependencies into `pubspec.yaml`.
@@ -2302,6 +2399,50 @@ class BaseBuildCommand(BaseFlutterCommand):
2302
2399
  return AssetSpec(ico_sizes=sizes)
2303
2400
  return None
2304
2401
 
2402
+ def _site_packages_missing(self) -> bool:
2403
+ """
2404
+ Whether any required platform package directory has no package files.
2405
+
2406
+ Check every requested mobile architecture. Empty directories and hidden
2407
+ staging metadata such as Darwin's `.pod` symlink do not count as packages.
2408
+ Darwin's architecture directories are merged into the root by packaging.
2409
+
2410
+ Returns:
2411
+ `True` when any required package directory is missing or empty.
2412
+ """
2413
+
2414
+ assert self.flutter_dir
2415
+ assert self.build_dir
2416
+ assert self.template_data
2417
+
2418
+ if self.package_platform == "Emscripten":
2419
+ site_packages = self.flutter_dir / "build" / "site-packages"
2420
+ else:
2421
+ site_packages = self.build_dir / "site-packages"
2422
+
2423
+ architectures = self.template_data["_options"]["target_arch"]
2424
+ if self.package_platform == "Android":
2425
+ architectures = architectures or self.python_release.android_abis
2426
+ elif self.package_platform == "iOS":
2427
+ architectures = architectures or (
2428
+ "iphoneos.arm64",
2429
+ "iphonesimulator.arm64",
2430
+ "iphonesimulator.x86_64",
2431
+ )
2432
+ else:
2433
+ architectures = ("",)
2434
+
2435
+ def has_package_files(directory: Path) -> bool:
2436
+ for _, directories, files in os.walk(directory):
2437
+ directories[:] = [d for d in directories if not d.startswith(".")]
2438
+ if any(not name.startswith(".") for name in files):
2439
+ return True
2440
+ return False
2441
+
2442
+ return any(
2443
+ not has_package_files(site_packages / arch) for arch in architectures
2444
+ )
2445
+
2305
2446
  def _icons_missing(self, platform: str) -> bool:
2306
2447
  """
2307
2448
  Whether any icon this build should have produced is absent.
@@ -2539,12 +2680,12 @@ class BaseBuildCommand(BaseFlutterCommand):
2539
2680
  self.python_release.short,
2540
2681
  ]
2541
2682
 
2542
- if self.template_data["options"]["target_arch"]:
2683
+ if self.template_data["_options"]["target_arch"]:
2543
2684
  # serious_python's --arch is a Dart multi-option: values must be
2544
2685
  # comma-separated or the flag repeated. Space-separated values
2545
2686
  # after the first are silently treated as positional arguments.
2546
2687
  package_args.extend(
2547
- ["--arch", ",".join(self.template_data["options"]["target_arch"])]
2688
+ ["--arch", ",".join(self.template_data["_options"]["target_arch"])]
2548
2689
  )
2549
2690
 
2550
2691
  # Only the short version is passed; serious_python derives the full
@@ -2600,14 +2741,16 @@ class BaseBuildCommand(BaseFlutterCommand):
2600
2741
  package_args.extend(["-r", toml_dep])
2601
2742
 
2602
2743
  elif requirements_txt.exists():
2744
+ # pip reads the file itself, so only its path is in
2745
+ # package_args: hash its text, and that of the files it
2746
+ # includes, so editing them reinstalls the packages.
2747
+ reqs_txt_contents = read_requirements_file_text(requirements_txt)
2603
2748
  if self.verbose > 1:
2604
- with open(requirements_txt, encoding="utf-8") as f:
2605
- reqs_txt_contents = f.read()
2606
- console.log(
2607
- f"Contents of requirements.txt: {reqs_txt_contents}",
2608
- style=verbose2_style,
2609
- )
2610
- hash.update(reqs_txt_contents)
2749
+ console.log(
2750
+ f"Contents of requirements.txt: {escape(reqs_txt_contents)}",
2751
+ style=verbose2_style,
2752
+ )
2753
+ hash.update(reqs_txt_contents)
2611
2754
  package_args.extend(["-r", "-r", "-r", str(requirements_txt)])
2612
2755
  else:
2613
2756
  package_args.extend(["-r", f"flet=={flet.version.flet_version}"])
@@ -2805,7 +2948,7 @@ class BaseBuildCommand(BaseFlutterCommand):
2805
2948
  hash.update(arg)
2806
2949
 
2807
2950
  if not dev_packages_configured:
2808
- if not hash.has_changed():
2951
+ if not hash.has_changed() and not self._site_packages_missing():
2809
2952
  package_args.append("--skip-site-packages")
2810
2953
  # serious_python skips copying Flutter packages to the temp dir
2811
2954
  # under --skip-site-packages, so register_flutter_extensions must
@@ -2945,6 +3088,188 @@ class BaseBuildCommand(BaseFlutterCommand):
2945
3088
  return value
2946
3089
  return self.get_pyproject(f"tool.flet.deep_linking.{key}")
2947
3090
 
3091
+ def get_ios_entitlements(self) -> dict:
3092
+ """
3093
+ Resolve the iOS app's entitlements.
3094
+
3095
+ Entries of `--ios-entitlements` override those of `[tool.flet.ios.entitlement]`.
3096
+ Values are written to `Runner.entitlements` with their plist types.
3097
+ """
3098
+ assert self.get_pyproject
3099
+ assert self.options
3100
+ ios_entitlements = dict(self.get_pyproject("tool.flet.ios.entitlement") or {})
3101
+
3102
+ # parse --ios-entitlements
3103
+ for p in self.options.ios_entitlements:
3104
+ i = p.find("=")
3105
+ if i > -1:
3106
+ ios_entitlements[p[:i]] = parse_cli_plist_value(p[i + 1 :])
3107
+ else:
3108
+ self.cleanup(1, f"Invalid iOS entitlement option: {p}")
3109
+
3110
+ for key, value in ios_entitlements.items():
3111
+ if not is_supported_plist_value(value):
3112
+ self.cleanup(
3113
+ 1,
3114
+ "Unsupported iOS entitlement value type for "
3115
+ f"{key}: {type(value).__name__}. Supported types are "
3116
+ "string, boolean, integer, float, dictionary, and arrays "
3117
+ "containing those values.",
3118
+ )
3119
+ return ios_entitlements
3120
+
3121
+ def get_android_compile_options(self) -> dict[str, str]:
3122
+ """Merge Android compiler expressions over installed extensions' and
3123
+ Flet's Java 17 defaults.
3124
+
3125
+ Keys are Gradle property identifiers; values are Kotlin expressions
3126
+ rendered verbatim inside `android.compileOptions`.
3127
+ """
3128
+ assert self.get_pyproject
3129
+ setting = "tool.flet.android.compile_options"
3130
+ try:
3131
+ configured = merge_compile_options(
3132
+ self.get_pyproject(setting), self.extensions
3133
+ )
3134
+ except ValueError as e:
3135
+ self.cleanup(1, str(e))
3136
+ if configured is None:
3137
+ configured = {}
3138
+ if not isinstance(configured, dict):
3139
+ self.cleanup(
3140
+ 1, f"Invalid {setting}: expected a table of Kotlin expressions."
3141
+ )
3142
+
3143
+ resolved = DEFAULT_ANDROID_COMPILE_OPTIONS.copy()
3144
+ for name, expression in configured.items():
3145
+ if not isinstance(name, str) or not re.fullmatch(
3146
+ r"[A-Za-z_][A-Za-z0-9_]*", name
3147
+ ):
3148
+ self.cleanup(
3149
+ 1,
3150
+ f"Invalid {setting} key {name!r}: expected a property identifier.",
3151
+ )
3152
+ if not isinstance(expression, str) or not expression.strip():
3153
+ self.cleanup(
3154
+ 1,
3155
+ f"Invalid {setting}.{name}: expected a nonempty Kotlin expression "
3156
+ "string.",
3157
+ )
3158
+ resolved[name] = expression.strip()
3159
+ return resolved
3160
+
3161
+ def get_android_gradle_dependencies(self) -> str:
3162
+ """Read Kotlin declarations for the body of Gradle's dependencies block,
3163
+ after those of installed extensions."""
3164
+ assert self.get_pyproject
3165
+ setting = "tool.flet.android.gradle_dependencies"
3166
+ configured = merge_gradle_dependencies(
3167
+ self.get_pyproject(setting), self.extensions
3168
+ )
3169
+ if configured is None:
3170
+ return ""
3171
+ if not isinstance(configured, str):
3172
+ self.cleanup(
3173
+ 1, f"Invalid {setting}: expected a string of Kotlin declarations."
3174
+ )
3175
+ return textwrap.dedent(configured).strip()
3176
+
3177
+ def get_android_receivers(self) -> dict:
3178
+ """Resolve Android manifest receiver declarations from project settings."""
3179
+ assert self.get_pyproject
3180
+ try:
3181
+ return normalize_android_receivers(
3182
+ self.get_pyproject("tool.flet.android.receiver")
3183
+ )
3184
+ except ValueError as exc:
3185
+ self.cleanup(1, str(exc))
3186
+
3187
+ def get_android_services(self) -> dict:
3188
+ """Resolve Android manifest service declarations from project settings."""
3189
+ assert self.get_pyproject
3190
+ try:
3191
+ return normalize_android_services(
3192
+ self.get_pyproject("tool.flet.android.service")
3193
+ )
3194
+ except ValueError as exc:
3195
+ self.cleanup(1, str(exc))
3196
+
3197
+ def copy_android_resources(self):
3198
+ """
3199
+ Copy the app's own Android resources into the generated project.
3200
+
3201
+ `tool.flet.android.res_dir` names a directory, relative to the project,
3202
+ laid out like Android's `res` directory, such as `drawable/ic_stat.png`
3203
+ or `raw/alert.mp3`. Files copied by the previous build are removed first,
3204
+ so deleting a resource from the directory also removes it from the app.
3205
+ A keep rule protects the copied resources from release-build resource
3206
+ shrinking, because Python code can refer to them only by name.
3207
+ """
3208
+
3209
+ assert self.get_pyproject
3210
+ assert self.python_app_path
3211
+ assert self.build_dir
3212
+ assert self.flutter_dir
3213
+
3214
+ if self.package_platform != "Android":
3215
+ return
3216
+
3217
+ res_path = self.flutter_dir / "android" / "app" / "src" / "main" / "res"
3218
+ manifest_path = self.build_dir / ".hash" / "android-resources"
3219
+ previous = (
3220
+ manifest_path.read_text().splitlines() if manifest_path.exists() else []
3221
+ )
3222
+ for relative in previous:
3223
+ (res_path / relative).unlink(missing_ok=True)
3224
+
3225
+ setting = self.get_pyproject("tool.flet.android.res_dir")
3226
+ if setting is None:
3227
+ manifest_path.unlink(missing_ok=True)
3228
+ return
3229
+ if not isinstance(setting, str):
3230
+ self.cleanup(
3231
+ 1, "Invalid tool.flet.android.res_dir: expected a directory path."
3232
+ )
3233
+ res_dir = self.python_app_path / setting
3234
+ if not res_dir.is_dir():
3235
+ self.cleanup(
3236
+ 1, f"Invalid tool.flet.android.res_dir: {res_dir} is not a directory."
3237
+ )
3238
+ try:
3239
+ files = android_resource_files(res_dir)
3240
+ except ValueError as exc:
3241
+ self.cleanup(1, f"Invalid tool.flet.android.res_dir entry {exc}")
3242
+
3243
+ clashes = android_resource_clashes(files, res_path)
3244
+ if clashes:
3245
+ self.cleanup(
3246
+ 1,
3247
+ "Android resources in tool.flet.android.res_dir clash with "
3248
+ "resources Flet generates; rename them:\n"
3249
+ + "\n".join(
3250
+ f" {file.as_posix()} (Flet's {generated.as_posix()})"
3251
+ for file, generated in clashes
3252
+ ),
3253
+ )
3254
+
3255
+ with self.step("[bold blue]Copying Android resources..."):
3256
+ copied = []
3257
+ for relative in files:
3258
+ target = res_path / relative
3259
+ target.parent.mkdir(parents=True, exist_ok=True)
3260
+ shutil.copy2(res_dir / relative, target)
3261
+ copied.append(relative.as_posix())
3262
+
3263
+ keep_xml = android_resources_keep_xml(files)
3264
+ if keep_xml is not None:
3265
+ keep_path = res_path / ANDROID_RESOURCES_KEEP_FILE
3266
+ keep_path.parent.mkdir(parents=True, exist_ok=True)
3267
+ keep_path.write_text(keep_xml)
3268
+ copied.append(ANDROID_RESOURCES_KEEP_FILE.as_posix())
3269
+
3270
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
3271
+ manifest_path.write_text("\n".join(copied))
3272
+
2948
3273
  def resolve_permissions(self) -> list[str]:
2949
3274
  """
2950
3275
  The cross-platform permission bundles to apply: `--permissions`, else