flutter-setup 2.1.0__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.
@@ -0,0 +1,579 @@
1
+ """Flutter SDK management for Flutter setup."""
2
+
3
+ import re
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+
10
+ from .config import Config
11
+ from .exceptions import FlutterInstallationError
12
+ from .platform import detect_runtime_platform
13
+
14
+ console = Console()
15
+
16
+
17
+ class FlutterManager:
18
+ """Manages Flutter SDK installation and updates."""
19
+
20
+ def __init__(self, config: Config):
21
+ """Initialize FlutterManager."""
22
+ self.config = config
23
+ self.platform = detect_runtime_platform()
24
+ self.home = Path.home()
25
+ self.flutter_root = config.flutter_location
26
+ self.path_profiles = self._path_profiles_for_platform()
27
+
28
+ def _path_profiles_for_platform(self) -> list[Path]:
29
+ """Return shell profile files that should contain Flutter PATH."""
30
+ if self.platform == "darwin":
31
+ return [self.home / ".zprofile", self.home / ".zshrc"]
32
+ if self.platform == "linux":
33
+ return [self.home / ".bashrc", self.home / ".zshrc"]
34
+ return [self.home / ".profile"]
35
+
36
+ def _normalize_flutter_bin_path(self, path: str) -> str:
37
+ """Normalize common shell home forms before comparing Flutter bin paths."""
38
+ expanded = path.replace("$HOME", str(self.home))
39
+ if expanded == "~":
40
+ expanded = str(self.home)
41
+ elif expanded.startswith("~/"):
42
+ expanded = str(self.home / expanded[2:])
43
+ return str(Path(expanded))
44
+
45
+ def _expected_flutter_bin_path(self) -> str:
46
+ """Return the normalized expected Flutter bin path."""
47
+ return self._normalize_flutter_bin_path(str(self.flutter_root / "bin"))
48
+
49
+ def ensure_flutter(self) -> None:
50
+ """Ensure Flutter SDK is installed and up to date."""
51
+ if self.config.dry_run:
52
+ console.print("[yellow]DRY RUN: Would manage Flutter SDK[/yellow]")
53
+ return
54
+
55
+ # Handle reclone mode
56
+ if self.config.flutter_update_mode == "reclone":
57
+ self._reclone_flutter()
58
+ # Check if Flutter is already installed
59
+ elif (
60
+ not self.flutter_root.exists() or not (self.flutter_root / ".git").exists()
61
+ ):
62
+ self._install_flutter()
63
+ elif self.config.flutter_update_mode == "skip":
64
+ console.print(" â­ī¸ Skipping Flutter SDK update (update mode: skip)")
65
+ else:
66
+ self._update_flutter()
67
+
68
+ # Verify version constraint before proceeding
69
+ if self.config.flutter_version:
70
+ self._check_flutter_version()
71
+
72
+ # Ensure Flutter is in PATH
73
+ self._ensure_flutter_path()
74
+
75
+ # Ensure Android SDK environment variables are set when targeting Android
76
+ if "android" in self.config.platforms:
77
+ self._ensure_android_env()
78
+
79
+ # Run flutter doctor
80
+ self._run_flutter_doctor()
81
+
82
+ def _get_current_flutter_version(self) -> str | None:
83
+ """Return the installed Flutter version string, or None if not detectable."""
84
+ flutter_bin = self.flutter_root / "bin" / "flutter"
85
+ try:
86
+ result = subprocess.run(
87
+ [str(flutter_bin), "--version"],
88
+ capture_output=True,
89
+ text=True,
90
+ check=False,
91
+ )
92
+ output = result.stdout or result.stderr or ""
93
+ match = re.search(r"Flutter\s+([\d.]+)", output)
94
+ if match:
95
+ return match.group(1)
96
+ except Exception:
97
+ pass
98
+ return None
99
+
100
+ def _check_flutter_version(self) -> None:
101
+ """Raise FlutterInstallationError if the installed version is older than config.flutter_version."""
102
+ required = self.config.flutter_version
103
+ if not required:
104
+ return
105
+
106
+ current = self._get_current_flutter_version()
107
+ if current is None:
108
+ raise FlutterInstallationError(
109
+ f"Could not determine Flutter version. "
110
+ f"Ensure Flutter is installed at {self.flutter_root}."
111
+ )
112
+
113
+ def parse(v: str) -> tuple[int, ...]:
114
+ return tuple(int(x) for x in v.split("."))
115
+
116
+ if parse(current) < parse(required):
117
+ raise FlutterInstallationError(
118
+ f"Flutter version too old: found {current}, requires >={required}.\n"
119
+ f" To upgrade: git -C {self.flutter_root} fetch && "
120
+ f"git -C {self.flutter_root} checkout {required}"
121
+ )
122
+ console.print(f" ✅ Flutter {current} satisfies >={required}")
123
+
124
+ def _reclone_flutter(self) -> None:
125
+ """Reclone Flutter repository."""
126
+ console.print(f" 🔄 Recloning Flutter ({self.config.channel})...")
127
+
128
+ try:
129
+ if self.flutter_root.exists():
130
+ import shutil
131
+
132
+ shutil.rmtree(self.flutter_root)
133
+
134
+ self._install_flutter()
135
+ except Exception as e:
136
+ raise FlutterInstallationError(f"Failed to reclone Flutter: {e}")
137
+
138
+ def _install_flutter(self) -> None:
139
+ """Install Flutter SDK."""
140
+ console.print(f" đŸ“Ĩ Installing Flutter ({self.config.channel})...")
141
+
142
+ try:
143
+ # Create parent directory
144
+ self.flutter_root.parent.mkdir(parents=True, exist_ok=True)
145
+
146
+ # Clone Flutter repository
147
+ subprocess.run(
148
+ [
149
+ "git",
150
+ "clone",
151
+ "--depth",
152
+ "1",
153
+ "-b",
154
+ self.config.channel,
155
+ "https://github.com/flutter/flutter.git",
156
+ str(self.flutter_root),
157
+ ],
158
+ check=True,
159
+ capture_output=True,
160
+ )
161
+
162
+ console.print(" ✅ Flutter installed")
163
+
164
+ except subprocess.CalledProcessError as e:
165
+ raise FlutterInstallationError(f"Failed to install Flutter: {e}")
166
+
167
+ def _update_flutter(self) -> None:
168
+ """Update existing Flutter installation."""
169
+ console.print(f" 🔄 Updating Flutter ({self.config.channel})...")
170
+
171
+ try:
172
+ # Set remote URL
173
+ subprocess.run(
174
+ [
175
+ "git",
176
+ "remote",
177
+ "set-url",
178
+ "origin",
179
+ "https://github.com/flutter/flutter.git",
180
+ ],
181
+ cwd=self.flutter_root,
182
+ check=False,
183
+ capture_output=True,
184
+ )
185
+
186
+ # Fetch latest changes
187
+ subprocess.run(
188
+ ["git", "fetch", "origin", "--prune"],
189
+ cwd=self.flutter_root,
190
+ check=True,
191
+ capture_output=True,
192
+ )
193
+
194
+ # Try to checkout the target channel
195
+ try:
196
+ subprocess.run(
197
+ ["git", "checkout", self.config.channel],
198
+ cwd=self.flutter_root,
199
+ check=True,
200
+ capture_output=True,
201
+ )
202
+ except subprocess.CalledProcessError:
203
+ # Create new branch if it doesn't exist
204
+ subprocess.run(
205
+ [
206
+ "git",
207
+ "checkout",
208
+ "-b",
209
+ self.config.channel,
210
+ f"origin/{self.config.channel}",
211
+ ],
212
+ cwd=self.flutter_root,
213
+ check=True,
214
+ capture_output=True,
215
+ )
216
+
217
+ # Try fast-forward merge
218
+ try:
219
+ subprocess.run(
220
+ ["git", "merge", "--ff-only", f"origin/{self.config.channel}"],
221
+ cwd=self.flutter_root,
222
+ check=True,
223
+ capture_output=True,
224
+ )
225
+ console.print(" ✅ Flutter updated (fast-forward)")
226
+ return
227
+ except subprocess.CalledProcessError:
228
+ pass
229
+
230
+ # Handle diverged branches
231
+ self._handle_diverged_branches()
232
+
233
+ except subprocess.CalledProcessError as e:
234
+ raise FlutterInstallationError(f"Failed to update Flutter: {e}")
235
+
236
+ def _handle_diverged_branches(self) -> None:
237
+ """Handle diverged Git branches."""
238
+ if self.config.flutter_update_mode == "skip":
239
+ console.print(
240
+ " âš ī¸ Flutter repo has diverged; skipping update (per --flutter-update skip)"
241
+ )
242
+ return
243
+
244
+ console.print(" âš ī¸ Flutter repo has diverged from origin")
245
+
246
+ # Get commit counts
247
+ try:
248
+ result = subprocess.run(
249
+ [
250
+ "git",
251
+ "rev-list",
252
+ "--left-right",
253
+ "--count",
254
+ f"origin/{self.config.channel}...{self.config.channel}",
255
+ ],
256
+ cwd=self.flutter_root,
257
+ capture_output=True,
258
+ text=True,
259
+ check=True,
260
+ )
261
+
262
+ counts = result.stdout.strip().split()
263
+ if len(counts) == 2:
264
+ left_ahead = counts[0]
265
+ right_ahead = counts[1]
266
+ console.print(
267
+ f" 📊 Local ahead by: {right_ahead}, origin ahead by: {left_ahead}"
268
+ )
269
+
270
+ except subprocess.CalledProcessError:
271
+ pass
272
+
273
+ # For now, we'll hard reset in reset mode
274
+ if self.config.flutter_update_mode == "reset":
275
+ console.print(" 🔄 Resetting Flutter to origin (discarding local changes)")
276
+ try:
277
+ subprocess.run(
278
+ ["git", "reset", "--hard", f"origin/{self.config.channel}"],
279
+ cwd=self.flutter_root,
280
+ check=True,
281
+ capture_output=True,
282
+ )
283
+ console.print(" ✅ Flutter reset to origin")
284
+ except subprocess.CalledProcessError as e:
285
+ raise FlutterInstallationError(f"Failed to reset Flutter: {e}")
286
+
287
+ def _ensure_flutter_path(self) -> None:
288
+ """Ensure Flutter is in PATH."""
289
+ console.print(" 🔧 Configuring Flutter PATH...")
290
+
291
+ flutter_path = f'export PATH="{self.flutter_root}/bin:$PATH"'
292
+ flutter_path_pattern = (
293
+ r'export\s+PATH=["\']([^"\']*flutter[^"\']*/bin):\$PATH["\']'
294
+ )
295
+
296
+ for profile in self.path_profiles:
297
+ if profile.exists():
298
+ with open(profile, "r") as f:
299
+ content = f.read()
300
+
301
+ existing_match = re.search(flutter_path_pattern, content)
302
+ if existing_match:
303
+ existing_flutter_path = existing_match.group(1)
304
+ normalized_existing_path = self._normalize_flutter_bin_path(
305
+ existing_flutter_path
306
+ )
307
+ if normalized_existing_path == self._expected_flutter_bin_path():
308
+ console.print(f" ✅ Flutter PATH already in {profile.name}")
309
+ else:
310
+ console.print(
311
+ f" âš ī¸ Different Flutter PATH in {profile.name}: {existing_flutter_path}"
312
+ )
313
+ continue
314
+
315
+ with open(profile, "a") as f:
316
+ f.write(f"\n{flutter_path}\n")
317
+ console.print(f" ✅ Flutter PATH added to {profile.name}")
318
+ else:
319
+ with open(profile, "w") as f:
320
+ f.write(f"{flutter_path}\n")
321
+ console.print(f" ✅ Flutter PATH added to {profile.name}")
322
+
323
+ # Add to current environment
324
+ flutter_bin = self.flutter_root / "bin"
325
+ if str(flutter_bin) not in sys.path:
326
+ sys.path.insert(0, str(flutter_bin))
327
+
328
+ def _detect_android_sdk_root(self) -> Path | None:
329
+ """Return the Android SDK root, checking env vars then common locations."""
330
+ import os
331
+
332
+ for var in ("ANDROID_HOME", "ANDROID_SDK_ROOT"):
333
+ val = os.getenv(var)
334
+ if val:
335
+ path = Path(val)
336
+ if path.exists():
337
+ return path
338
+
339
+ common = [
340
+ Path("/opt/android-sdk"),
341
+ self.home / "Android" / "Sdk",
342
+ self.home / "Library" / "Android" / "sdk",
343
+ ]
344
+ for path in common:
345
+ if path.exists():
346
+ return path
347
+
348
+ return None
349
+
350
+ def _ensure_android_env(self) -> None:
351
+ """Write ANDROID_HOME and Android SDK PATH entries to shell profiles."""
352
+ console.print(" 🤖 Configuring Android SDK environment...")
353
+
354
+ sdk_root = self._detect_android_sdk_root()
355
+ if sdk_root is None:
356
+ console.print(
357
+ " âš ī¸ Android SDK not found; skipping ANDROID_HOME configuration"
358
+ )
359
+ return
360
+
361
+ android_home_export = f'export ANDROID_HOME="{sdk_root}"'
362
+ android_path_export = (
363
+ 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin'
364
+ ":$ANDROID_HOME/platform-tools"
365
+ ':$ANDROID_HOME/emulator"'
366
+ )
367
+ marker = "# Android SDK"
368
+
369
+ for profile in self.path_profiles:
370
+ if profile.exists():
371
+ content = profile.read_text()
372
+ else:
373
+ content = ""
374
+
375
+ if "ANDROID_HOME" in content:
376
+ console.print(f" ✅ Android SDK env already in {profile.name}")
377
+ continue
378
+
379
+ with open(profile, "a") as f:
380
+ f.write(f"\n{marker}\n{android_home_export}\n{android_path_export}\n")
381
+ console.print(f" ✅ Android SDK env added to {profile.name}")
382
+
383
+ def _run_flutter_doctor(self) -> None:
384
+ """Run flutter doctor to check setup."""
385
+ console.print(" đŸĨ Running Flutter doctor...")
386
+
387
+ try:
388
+ # Run flutter doctor
389
+ result = subprocess.run(
390
+ [str(self.flutter_root / "bin" / "flutter"), "doctor", "-v"],
391
+ capture_output=True,
392
+ text=True,
393
+ check=False,
394
+ )
395
+
396
+ # Flutter doctor outputs to stdout, but may also use stderr
397
+ output = result.stdout or result.stderr or ""
398
+
399
+ if result.returncode == 0:
400
+ console.print(" ✅ Flutter doctor passed")
401
+ else:
402
+ console.print(" âš ī¸ Flutter doctor found issues:")
403
+ # Print output (stdout takes precedence, fallback to stderr)
404
+ if result.stdout:
405
+ console.print(result.stdout)
406
+ if result.stderr:
407
+ console.print(result.stderr)
408
+
409
+ # Check for Android licenses in either stream
410
+ combined_output = output
411
+ if "Some Android licenses not accepted" in combined_output:
412
+ console.print(" 📱 Android licenses need acceptance")
413
+ self._handle_android_licenses()
414
+ if self.platform == "linux":
415
+ self._print_linux_doctor_guidance(combined_output)
416
+
417
+ except Exception as e:
418
+ console.print(f" âš ī¸ Flutter doctor warning: {e}")
419
+
420
+ def _print_linux_doctor_guidance(self, output: str) -> None:
421
+ """Print Linux-specific remediation hints for doctor issues."""
422
+ if "Linux toolchain" in output or "Unable to locate" in output:
423
+ console.print(" 🐧 Linux toolchain issues detected")
424
+ console.print(
425
+ " â„šī¸ Ensure required packages are installed via apt (git curl unzip xz-utils zip libglu1-mesa clang cmake ninja-build pkg-config)"
426
+ )
427
+
428
+ def _handle_android_licenses(self) -> None:
429
+ """Handle Android license acceptance."""
430
+ if "android" not in self.config.platforms:
431
+ return
432
+
433
+ console.print(" 📱 Android licenses not accepted")
434
+ console.print(
435
+ " â„šī¸ You can run 'flutter doctor --android-licenses' later to accept licenses"
436
+ )
437
+
438
+ def check_only(self) -> bool:
439
+ """Check Flutter SDK installation without installing/updating. Returns True if all checks pass."""
440
+ all_ok = True
441
+
442
+ # Check if Flutter is installed
443
+ console.print(" 🔍 Checking Flutter SDK installation...")
444
+ if not self.flutter_root.exists() or not (self.flutter_root / ".git").exists():
445
+ console.print(f" ❌ Flutter SDK not found at {self.flutter_root}")
446
+ all_ok = False
447
+ else:
448
+ console.print(f" ✅ Flutter SDK found at {self.flutter_root}")
449
+
450
+ # Check Flutter binary
451
+ flutter_bin = self.flutter_root / "bin" / "flutter"
452
+ if not flutter_bin.exists():
453
+ console.print(" ❌ Flutter binary not found")
454
+ all_ok = False
455
+ else:
456
+ console.print(" ✅ Flutter binary found")
457
+
458
+ # Check Flutter PATH configuration
459
+ console.print(" 🔧 Checking Flutter PATH configuration...")
460
+ flutter_path_pattern = (
461
+ r'export\s+PATH=["\']([^"\']*flutter[^"\']*/bin):\$PATH["\']'
462
+ )
463
+ expected_path = self._expected_flutter_bin_path()
464
+ found_expected = False
465
+ found_any = False
466
+ for profile in self.path_profiles:
467
+ if not profile.exists():
468
+ continue
469
+
470
+ with open(profile, "r") as f:
471
+ content = f.read()
472
+
473
+ existing_match = re.search(flutter_path_pattern, content)
474
+ if existing_match:
475
+ found_any = True
476
+ existing_flutter_path = existing_match.group(1)
477
+ normalized_existing_path = self._normalize_flutter_bin_path(
478
+ existing_flutter_path
479
+ )
480
+ if normalized_existing_path == expected_path:
481
+ console.print(
482
+ f" ✅ Flutter PATH correctly configured in {profile.name}"
483
+ )
484
+ found_expected = True
485
+ else:
486
+ console.print(
487
+ f" âš ī¸ Different Flutter PATH in {profile.name}: {existing_flutter_path}"
488
+ )
489
+
490
+ if not found_any:
491
+ console.print(" âš ī¸ Flutter PATH not found in shell profiles")
492
+ all_ok = False
493
+ elif not found_expected:
494
+ all_ok = False
495
+
496
+ # Check if Flutter is up to date (if installed)
497
+ if self.flutter_root.exists() and (self.flutter_root / ".git").exists():
498
+ console.print(
499
+ f" 🔄 Checking Flutter update status ({self.config.channel})..."
500
+ )
501
+ try:
502
+ # Fetch latest changes
503
+ subprocess.run(
504
+ ["git", "fetch", "origin", "--prune"],
505
+ cwd=self.flutter_root,
506
+ check=True,
507
+ capture_output=True,
508
+ )
509
+
510
+ # Check if local branch is up to date
511
+ result = subprocess.run(
512
+ [
513
+ "git",
514
+ "rev-list",
515
+ "--left-right",
516
+ "--count",
517
+ f"origin/{self.config.channel}...{self.config.channel}",
518
+ ],
519
+ cwd=self.flutter_root,
520
+ capture_output=True,
521
+ text=True,
522
+ check=True,
523
+ )
524
+
525
+ counts = result.stdout.strip().split()
526
+ if len(counts) == 2:
527
+ left_ahead = int(counts[0])
528
+ right_ahead = int(counts[1])
529
+ if left_ahead == 0 and right_ahead == 0:
530
+ console.print(" ✅ Flutter is up to date")
531
+ elif left_ahead > 0:
532
+ console.print(
533
+ f" âš ī¸ Flutter is {left_ahead} commit(s) behind origin"
534
+ )
535
+ elif right_ahead > 0:
536
+ console.print(
537
+ f" â„šī¸ Flutter has {right_ahead} local commit(s) ahead of origin"
538
+ )
539
+ except subprocess.CalledProcessError:
540
+ console.print(" âš ī¸ Could not check Flutter update status")
541
+
542
+ # Run flutter doctor
543
+ if (
544
+ self.flutter_root.exists()
545
+ and (self.flutter_root / "bin" / "flutter").exists()
546
+ ):
547
+ console.print(" đŸĨ Running Flutter doctor...")
548
+ try:
549
+ result = subprocess.run(
550
+ [str(self.flutter_root / "bin" / "flutter"), "doctor", "-v"],
551
+ capture_output=True,
552
+ text=True,
553
+ check=False,
554
+ )
555
+
556
+ # Flutter doctor outputs to stdout, but may also use stderr
557
+ output = result.stdout or result.stderr or ""
558
+
559
+ if result.returncode == 0:
560
+ console.print(" ✅ Flutter doctor passed")
561
+ else:
562
+ console.print(" âš ī¸ Flutter doctor found issues:")
563
+ # Print only the summary, not the full verbose output
564
+ # Check both stdout (primary) and stderr (secondary)
565
+ lines = output.split("\n")
566
+ summary_lines = [
567
+ line
568
+ for line in lines
569
+ if "â€ĸ" in line or "!" in line or "✗" in line or "✓" in line
570
+ ]
571
+ if summary_lines:
572
+ for line in summary_lines[:10]: # Limit output
573
+ console.print(f" {line}")
574
+ all_ok = False
575
+ except Exception as e:
576
+ console.print(f" âš ī¸ Flutter doctor warning: {e}")
577
+ all_ok = False
578
+
579
+ return all_ok
@@ -0,0 +1,15 @@
1
+ """Platform detection helpers."""
2
+
3
+ import sys
4
+ from typing import Literal
5
+
6
+ SetupPlatform = Literal["darwin", "linux", "unsupported"]
7
+
8
+
9
+ def detect_runtime_platform() -> SetupPlatform:
10
+ """Return the normalized runtime platform for setup flows."""
11
+ if sys.platform == "darwin":
12
+ return "darwin"
13
+ if sys.platform.startswith("linux"):
14
+ return "linux"
15
+ return "unsupported"
@@ -0,0 +1,39 @@
1
+ """Platform-aware prerequisites facade for Flutter setup."""
2
+
3
+ from typing import Protocol
4
+
5
+ from .config import Config
6
+ from .exceptions import PrerequisitesError
7
+ from .platform import detect_runtime_platform
8
+ from .prerequisites_linux import LinuxPrerequisites
9
+ from .prerequisites_macos import MacOSPrerequisites
10
+
11
+
12
+ class _PrerequisitesBackend(Protocol):
13
+ def check_and_install(self) -> None: ...
14
+ def check_only(self) -> bool: ...
15
+
16
+
17
+ class PrerequisitesManager:
18
+ """Manages system prerequisites via platform-specific backends."""
19
+
20
+ def __init__(self, config: Config):
21
+ """Initialize PrerequisitesManager."""
22
+ self.config = config
23
+ self.platform = detect_runtime_platform()
24
+ self.backend = self._select_backend()
25
+
26
+ def _select_backend(self) -> _PrerequisitesBackend:
27
+ if self.platform == "darwin":
28
+ return MacOSPrerequisites(self.config)
29
+ if self.platform == "linux":
30
+ return LinuxPrerequisites(self.config)
31
+ raise PrerequisitesError(f"Unsupported host platform: {self.platform}")
32
+
33
+ def check_and_install(self) -> None:
34
+ """Check and install prerequisites for the active platform."""
35
+ self.backend.check_and_install()
36
+
37
+ def check_only(self) -> bool:
38
+ """Check prerequisites for the active platform."""
39
+ return self.backend.check_only()