qyro-cli 1.0.0a1__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.
Files changed (55) hide show
  1. qyro_cli/__main__.py +29 -0
  2. qyro_cli/adapters/cli/console_ui.py +224 -0
  3. qyro_cli/adapters/cli/progress.py +46 -0
  4. qyro_cli/adapters/freeze/__init__.py +6 -0
  5. qyro_cli/adapters/freeze/framework_hooks.py +352 -0
  6. qyro_cli/adapters/freeze/hooks/__init__.py +0 -0
  7. qyro_cli/adapters/freeze/hooks/kivy_hook.py +75 -0
  8. qyro_cli/adapters/freeze/mac.py +48 -0
  9. qyro_cli/adapters/freeze/optimizer.py +141 -0
  10. qyro_cli/adapters/freeze/pyinstaller_adapter.py +836 -0
  11. qyro_cli/adapters/freeze/windows.py +30 -0
  12. qyro_cli/adapters/package/compiled_app_signer.py +513 -0
  13. qyro_cli/adapters/package/metadata.py +10 -0
  14. qyro_cli/adapters/package/release_bundler.py +822 -0
  15. qyro_cli/adapters/package/templates/nsis_installer.nsi +143 -0
  16. qyro_cli/adapters/persistence/storage.py +272 -0
  17. qyro_cli/adapters/process/dependencies.py +89 -0
  18. qyro_cli/adapters/process/runners.py +92 -0
  19. qyro_cli/adapters/templates/bundled.py +47 -0
  20. qyro_cli/adapters/templates/fallback.py +41 -0
  21. qyro_cli/adapters/templates/github_cached.py +432 -0
  22. qyro_cli/application/guards.py +35 -0
  23. qyro_cli/application/ports.py +357 -0
  24. qyro_cli/application/use_cases/bundle.py +130 -0
  25. qyro_cli/application/use_cases/clean.py +51 -0
  26. qyro_cli/application/use_cases/freeze.py +538 -0
  27. qyro_cli/application/use_cases/init.py +278 -0
  28. qyro_cli/application/use_cases/sign.py +176 -0
  29. qyro_cli/application/use_cases/start.py +63 -0
  30. qyro_cli/application/use_cases/version.py +23 -0
  31. qyro_cli/cmdline/__init__.py +4 -0
  32. qyro_cli/cmdline/app.py +43 -0
  33. qyro_cli/cmdline/commands/__init__.py +24 -0
  34. qyro_cli/cmdline/commands/build.py +159 -0
  35. qyro_cli/cmdline/commands/bundle.py +93 -0
  36. qyro_cli/cmdline/commands/clean.py +32 -0
  37. qyro_cli/cmdline/commands/init.py +130 -0
  38. qyro_cli/cmdline/commands/sign.py +107 -0
  39. qyro_cli/cmdline/commands/start.py +34 -0
  40. qyro_cli/cmdline/commands/version.py +25 -0
  41. qyro_cli/cmdline/models.py +33 -0
  42. qyro_cli/cmdline/parser.py +46 -0
  43. qyro_cli/cmdline/registry.py +68 -0
  44. qyro_cli/container.py +161 -0
  45. qyro_cli/domain/build.py +194 -0
  46. qyro_cli/domain/errors.py +257 -0
  47. qyro_cli/domain/mobile.py +31 -0
  48. qyro_cli/domain/project.py +223 -0
  49. qyro_cli/domain/version.py +76 -0
  50. qyro_cli-1.0.0a1.dist-info/LICENSE +21 -0
  51. qyro_cli-1.0.0a1.dist-info/METADATA +818 -0
  52. qyro_cli-1.0.0a1.dist-info/NOTICE +159 -0
  53. qyro_cli-1.0.0a1.dist-info/RECORD +55 -0
  54. qyro_cli-1.0.0a1.dist-info/WHEEL +4 -0
  55. qyro_cli-1.0.0a1.dist-info/entry_points.txt +3 -0
qyro_cli/__main__.py ADDED
@@ -0,0 +1,29 @@
1
+ """
2
+ Module entry point.
3
+
4
+ Allows invoking the CLI directly via:
5
+
6
+ python -m qyro <command> [options]
7
+ """
8
+
9
+ import sys
10
+
11
+ from qyro_cli.cmdline import main
12
+
13
+
14
+ def _configure_stdio() -> None:
15
+ """Use UTF-8 output when possible to avoid Windows codepage crashes."""
16
+ for stream_name in ("stdout", "stderr"):
17
+ stream = getattr(sys, stream_name, None)
18
+ reconfigure = getattr(stream, "reconfigure", None)
19
+ if callable(reconfigure):
20
+ try:
21
+ reconfigure(encoding="utf-8", errors="replace")
22
+ except Exception:
23
+ # Keep default stream settings if the runtime disallows reconfigure.
24
+ pass
25
+
26
+
27
+ if __name__ == "__main__":
28
+ _configure_stdio()
29
+ sys.exit(main())
@@ -0,0 +1,224 @@
1
+ """
2
+ Concrete presentation adapter implementing UserInteractionPort
3
+ using Rich and Questionary.
4
+ """
5
+
6
+ import random
7
+ from pathlib import Path
8
+ from typing import Sequence
9
+
10
+ import questionary
11
+ from prompt_toolkit.styles import Style
12
+ from rich.theme import Theme
13
+ from rich.align import Align
14
+ from rich.console import Console, Group
15
+ from rich.panel import Panel
16
+ from rich.prompt import Confirm, Prompt
17
+ from rich.table import Table
18
+ from rich.text import Text
19
+
20
+ QUESTIONARY_STYLE = Style([
21
+ ("qmark", "fg:#c08457 bold"),
22
+ ("question", "fg:#f1f5f9 bold"),
23
+ ("answer", "fg:#fdba74 bold"),
24
+ ("pointer", "fg:#fb923c bold"),
25
+ ("highlighted", "fg:#fb923c bg:default bold"),
26
+ ("selected", "fg:#fdba74 bg:default"),
27
+ ("instruction", "fg:#9ca3af"),
28
+ ])
29
+
30
+
31
+ custom_theme = Theme(
32
+ {
33
+ "prompt.choices": "bold white",
34
+ "prompt.default": "bold #ff8c00",
35
+ }
36
+ )
37
+ class RichConsoleUI:
38
+ def __init__(self, console: Console | None = None):
39
+ self._console = console or Console(
40
+ force_terminal=True,
41
+ color_system="truecolor",
42
+ theme=custom_theme,
43
+ )
44
+
45
+ def _ascii_fallback(self, message: str) -> str:
46
+ safe = (
47
+ message
48
+ .replace("⏳", "...")
49
+ .replace("💔", "Error")
50
+ .replace("🚀", "*")
51
+ .replace("✓", "OK")
52
+ .replace("⚠️", "WARN")
53
+ .replace("⚠", "WARN")
54
+ )
55
+ return safe.encode("ascii", errors="replace").decode("ascii")
56
+
57
+ def _print(self, renderable: object) -> None:
58
+ try:
59
+ self._console.print(renderable)
60
+ except UnicodeEncodeError:
61
+ if isinstance(renderable, str):
62
+ self._console.print(self._ascii_fallback(renderable))
63
+ return
64
+ raise
65
+
66
+ def welcome(self) -> None:
67
+ messages = [
68
+ "What are we building today?",
69
+ "What will you build today?",
70
+ "Let's build something great.",
71
+ "Your next project starts here.",
72
+ ]
73
+
74
+ message = random.choice(messages)
75
+ directory = Path.cwd().name
76
+
77
+ logo = Text(
78
+ "\n"
79
+ " ██████╗ ██╗ ██╗██████╗ ██████╗\n"
80
+ "██╔═══██╗╚██╗ ██╔╝██╔══██╗██╔═══██╗\n"
81
+ "██║ ██║ ╚████╔╝ ██████╔╝██║ ██║\n"
82
+ "██║▄▄ ██║ ╚██╔╝ ██╔══██╗██║ ██║\n"
83
+ "╚██████╔╝ ██║ ██║ ██║╚██████╔╝\n"
84
+ " ╚══▀▀═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝\n",
85
+ style="bold #ff8c00",
86
+ )
87
+
88
+ title = Text(message, style="bold white")
89
+ subtitle = Text(
90
+ f"Creating your next application in {directory}/",
91
+ style="dim",
92
+ )
93
+
94
+ content = Group(
95
+ Align.center(logo),
96
+ Align.center(title),
97
+ Align.center(subtitle),
98
+ )
99
+
100
+ self._console.print(
101
+ Panel(
102
+ content,
103
+ border_style="#ff8c00",
104
+ padding=(1, 4),
105
+ )
106
+ )
107
+
108
+ def ask_text(
109
+ self,
110
+ prompt: str,
111
+ default: str = "",
112
+ show_default: bool = True,
113
+ ) -> str:
114
+ return Prompt.ask(
115
+ f"[bold]{prompt}[/bold]",
116
+ default=default,
117
+ show_default=show_default,
118
+ console=self._console
119
+ )
120
+
121
+ def ask_choice(
122
+ self,
123
+ prompt: str,
124
+ choices: Sequence[str],
125
+ default: str = "",
126
+ ) -> str:
127
+ options = list(choices)
128
+
129
+ if not options:
130
+ return default
131
+
132
+ answer = questionary.select(
133
+ prompt,
134
+ choices=options,
135
+ default=default or options[0],
136
+ style=QUESTIONARY_STYLE,
137
+ ).ask()
138
+
139
+ if answer is None:
140
+ raise KeyboardInterrupt
141
+
142
+ return answer
143
+
144
+ def ask_multi_choice(
145
+ self,
146
+ prompt: str,
147
+ choices: Sequence[str],
148
+ ) -> list[str]:
149
+ answer = questionary.checkbox(
150
+ prompt,
151
+ choices=[
152
+ questionary.Choice(
153
+ choice,
154
+ value=choice,
155
+ checked=False,
156
+ )
157
+ for choice in choices
158
+ ],
159
+ style=QUESTIONARY_STYLE,
160
+ ).ask()
161
+
162
+ if answer is None:
163
+ raise KeyboardInterrupt
164
+
165
+ return answer
166
+
167
+ def confirm(
168
+ self,
169
+ prompt: str,
170
+ default: bool = True,
171
+ ) -> bool:
172
+ return Confirm.ask(
173
+ f"[bold #fff]{prompt}[/bold #fff]",
174
+ default=default,
175
+ console=self._console,
176
+ )
177
+
178
+ def show_summary(
179
+ self,
180
+ title: str,
181
+ fields: dict[str, str],
182
+ ) -> None:
183
+ table = Table(
184
+ title="Project Configuration",
185
+ show_header=False,
186
+ box=None,
187
+ )
188
+
189
+ for label, value in fields.items():
190
+ table.add_row(
191
+ f"{label}:",
192
+ f"[#fdba74]{value}[/#fdba74]",
193
+ )
194
+
195
+ self._console.print(
196
+ Panel(
197
+ table,
198
+ title=f"[bold]{title}[/bold]",
199
+ border_style="#ff8c00",
200
+ )
201
+ )
202
+
203
+ def info(self, message: str) -> None:
204
+ self._print(message)
205
+
206
+ def success(self, message: str) -> None:
207
+ self._print(
208
+ f"[bold #fff]{message}[/bold #fff]"
209
+ )
210
+
211
+ def warning(self, message: str) -> None:
212
+ self._print(
213
+ f"[#fdba74]{message}[/#fdba74]"
214
+ )
215
+
216
+ def progress(self, message: str) -> None:
217
+ self._print(
218
+ f"[#fdba74]⏳ {message}[/#fdba74]"
219
+ )
220
+
221
+ def error(self, message: str) -> None:
222
+ self._print(
223
+ f"\n💔 [bold red]Error:[/bold red] {message}"
224
+ )
@@ -0,0 +1,46 @@
1
+ from rich.console import Console
2
+ from rich.progress import (
3
+ BarColumn,
4
+ DownloadColumn,
5
+ Progress,
6
+ TextColumn,
7
+ TimeRemainingColumn,
8
+ TransferSpeedColumn,
9
+ )
10
+
11
+
12
+ class RichProgress:
13
+ def __init__(self, console: Console | None = None):
14
+ self._progress = Progress(
15
+ TextColumn("[progress.description]{task.description}"),
16
+ BarColumn(),
17
+ DownloadColumn(),
18
+ TransferSpeedColumn(),
19
+ TimeRemainingColumn(),
20
+ console=console,
21
+ )
22
+ self._task_id = None
23
+
24
+ def start(
25
+ self,
26
+ message: str,
27
+ total: int | None = None,
28
+ ) -> None:
29
+ self._task_id = self._progress.add_task(
30
+ message,
31
+ total=total,
32
+ )
33
+ self._progress.start()
34
+
35
+ def update(self, completed: int) -> None:
36
+ if self._task_id is not None:
37
+ self._progress.update(
38
+ self._task_id,
39
+ completed=completed,
40
+ )
41
+
42
+ def stop(self) -> None:
43
+ if self._task_id is not None:
44
+ self._progress.stop()
45
+ self._progress.remove_task(self._task_id)
46
+ self._task_id = None
@@ -0,0 +1,6 @@
1
+ """qyro.adapters.freeze package."""
2
+ from qyro_cli.adapters.freeze.framework_hooks import FrameworkHookResolver
3
+ from qyro_cli.adapters.freeze.optimizer import BinaryOptimizer
4
+ from qyro_cli.adapters.freeze.pyinstaller_adapter import PyInstallerFreezer
5
+
6
+ __all__ = ["FrameworkHookResolver", "BinaryOptimizer", "PyInstallerFreezer"]
@@ -0,0 +1,352 @@
1
+
2
+ """
3
+ qyro.adapters.freeze.framework_hooks
4
+ Framework-specific resolution hooks for Qyro's Freeze system.
5
+ Handles PyQt5, PyQt6, PySide2, PySide6, Kivy, Tkinter hidden imports and resource data,
6
+ integrating with Qyro's domain Binding and Project entities.
7
+ """
8
+
9
+ from pathlib import Path
10
+ from typing import Callable, List
11
+
12
+ from qyro_cli.application.ports import FrameworkHookResolverPort
13
+ from qyro_cli.domain.build import FreezeManifest
14
+ from qyro_cli.domain.project import Binding, AddonModule
15
+
16
+
17
+ class FrameworkHookResolver(FrameworkHookResolverPort):
18
+ """
19
+ Intelligently inspects project files and domain Binding to resolve:
20
+ - PyInstaller hidden imports
21
+ - Collect data / resource paths
22
+ - Binary dependencies (SDL2, GLEW, etc. for Kivy)
23
+ - Plugin directories for Qt family
24
+ - Common project settings and assets
25
+ """
26
+
27
+ def resolve_args(
28
+ self,
29
+ project_root: Path,
30
+ manifest: FreezeManifest,
31
+ ) -> List[str]:
32
+ args: List[str] = []
33
+
34
+ binding = Binding.parse(manifest.binding)
35
+
36
+ resolvers: dict[Binding, Callable[[Path, FreezeManifest], List[str]]] = {
37
+ Binding.PYSIDE6: self._resolve_pyside6,
38
+ Binding.PYQT6: self._resolve_pyqt6,
39
+ Binding.PYQT5: self._resolve_pyqt5,
40
+ Binding.PYSIDE2: self._resolve_pyside2,
41
+ Binding.KIVY: self._resolve_kivy,
42
+ Binding.TKINTER: self._resolve_tkinter,
43
+ }
44
+
45
+ resolver = resolvers.get(binding)
46
+
47
+ if resolver:
48
+ args.extend(resolver(project_root, manifest))
49
+
50
+ args.extend(self._resolve_competing_framework_exclusions(binding, manifest))
51
+
52
+ args.extend(self._resolve_addons(manifest))
53
+ args.extend(self._resolve_project_assets(project_root, manifest))
54
+
55
+ return args
56
+
57
+ def _resolve_qt(
58
+ self,
59
+ package: str,
60
+ *,
61
+ sip: bool = False,
62
+ ) -> List[str]:
63
+ args = [
64
+ "--hidden-import", package,
65
+ "--hidden-import", f"{package}.QtCore",
66
+ "--hidden-import", f"{package}.QtGui",
67
+ "--hidden-import", f"{package}.QtWidgets",
68
+ ]
69
+
70
+ if package == "PySide6":
71
+ args.extend(["--exclude-module", "PySide6.scripts"])
72
+
73
+ if package == "PySide2":
74
+ args.extend(["--exclude-module", "PySide2.scripts"])
75
+
76
+ if package == "PyQt6":
77
+ args.extend(["--exclude-module", "PyQt6.scripts"])
78
+
79
+ if package == "PyQt5":
80
+ args.extend(["--exclude-module", "PyQt5.scripts"])
81
+
82
+ if sip:
83
+ args.extend(["--hidden-import", f"{package}.sip"])
84
+
85
+ return args
86
+
87
+ def _resolve_pyside6(
88
+ self,
89
+ project_root: Path,
90
+ manifest: FreezeManifest,
91
+ ) -> List[str]:
92
+ args = self._resolve_qt("PySide6")
93
+
94
+ # Check for QML / Quick in project only if not explicitly excluded
95
+ user_excludes = {m.lower() for m in manifest.optimization.exclude_modules}
96
+ if self._has_qml(project_root):
97
+ if "pyside6.qtquick" not in user_excludes:
98
+ args.extend(["--hidden-import", "PySide6.QtQuick"])
99
+ if "pyside6.qtqml" not in user_excludes:
100
+ args.extend(["--hidden-import", "PySide6.QtQml"])
101
+
102
+ return args
103
+
104
+ def _resolve_pyqt6(
105
+ self,
106
+ project_root: Path,
107
+ manifest: FreezeManifest,
108
+ ) -> List[str]:
109
+ return self._resolve_qt("PyQt6", sip=True)
110
+
111
+ def _resolve_pyqt5(
112
+ self,
113
+ project_root: Path,
114
+ manifest: FreezeManifest,
115
+ ) -> List[str]:
116
+ return self._resolve_qt("PyQt5", sip=True)
117
+
118
+ def _resolve_pyside2(
119
+ self,
120
+ project_root: Path,
121
+ manifest: FreezeManifest,
122
+ ) -> List[str]:
123
+ args = self._resolve_qt("PySide2")
124
+ args.extend(["--collect-all", "PySide2", "--collect-all", "shiboken2"])
125
+ return args
126
+
127
+ def _resolve_kivy(
128
+ self,
129
+ project_root: Path,
130
+ manifest: FreezeManifest,
131
+ ) -> List[str]:
132
+ args = [
133
+ "--hidden-import", "kivy",
134
+ "--hidden-import", "kivy.app",
135
+ "--hidden-import", "kivy.uix",
136
+ "--hidden-import", "kivy.factory",
137
+ "--hidden-import", "kivy.lang",
138
+ "--hidden-import", "kivy.lang.builder",
139
+ "--hidden-import", "kivy.core.window",
140
+ "--hidden-import", "kivy.core.window.window_sdl2",
141
+ "--hidden-import", "kivy.core.text",
142
+ "--hidden-import", "kivy.core.text.text_sdl2",
143
+ "--hidden-import", "kivy.core.image",
144
+ "--hidden-import", "kivy.core.image.img_sdl2",
145
+ "--hidden-import", "kivy.core.clipboard.clipboard_sdl2",
146
+ "--hidden-import", "kivy.graphics",
147
+ "--hidden-import", "kivy.graphics.cgl",
148
+ "--hidden-import", "kivy.graphics.cgl_backend.cgl_glew",
149
+ "--collect-data", "kivy",
150
+ ]
151
+
152
+ # Scan and bundle .kv files (Kivy declarative layout files)
153
+ kv_patterns = [
154
+ "*.kv",
155
+ "views/**/*.kv",
156
+ "components/**/*.kv",
157
+ ]
158
+
159
+ collected_kv: set[str] = set()
160
+
161
+ for pattern in kv_patterns:
162
+ for path in project_root.glob(pattern):
163
+ if not path.is_file():
164
+ continue
165
+
166
+ relative = path.relative_to(project_root)
167
+ source = path.resolve().as_posix()
168
+ destination = relative.parent.as_posix()
169
+
170
+ if destination == ".":
171
+ destination = "."
172
+
173
+ collected_kv.add(
174
+ f"{source}{self._data_separator(manifest)}{destination}"
175
+ )
176
+
177
+ for item in sorted(collected_kv):
178
+ args.extend(["--add-data", item])
179
+
180
+ # Attach Qyro's bundled Kivy runtime hook to set custom window icon
181
+ kivy_rthook = Path(__file__).parent / "hooks" / "rthook_kivy_icon.py"
182
+ if kivy_rthook.exists():
183
+ args.extend(["--runtime-hook", kivy_rthook.as_posix()])
184
+
185
+ return args
186
+
187
+ def _resolve_tkinter(
188
+ self,
189
+ project_root: Path,
190
+ manifest: FreezeManifest,
191
+ ) -> List[str]:
192
+ args = [
193
+ "--hidden-import", "_tkinter",
194
+ "--hidden-import", "tkinter",
195
+ "--hidden-import", "tkinter.ttk",
196
+ "--hidden-import", "tkinter.messagebox",
197
+ "--hidden-import", "tkinter.filedialog",
198
+ "--collect-data", "tkinter",
199
+ ]
200
+
201
+ import sys
202
+ sep = self._data_separator(manifest)
203
+ py_prefix = Path(sys.prefix)
204
+
205
+ tcl_tk_candidates = [
206
+ (py_prefix / "lib" / "tcl8.6", "tcl8.6"),
207
+ (py_prefix / "lib" / "tk8.6", "tk8.6"),
208
+ (py_prefix / "lib" / "tcl8.5", "tcl8.5"),
209
+ (py_prefix / "lib" / "tk8.5", "tk8.5"),
210
+ (py_prefix / "tcl", "tcl"),
211
+ (py_prefix / "tk", "tk"),
212
+ ]
213
+
214
+ for src_path, target_name in tcl_tk_candidates:
215
+ if src_path.is_dir():
216
+ args.extend(["--add-data", f"{src_path.as_posix()}{sep}{target_name}"])
217
+
218
+ return args
219
+
220
+ def _resolve_addons(
221
+ self,
222
+ manifest: FreezeManifest,
223
+ ) -> List[str]:
224
+ """Resolves PyInstaller args for registered addons."""
225
+ args: List[str] = []
226
+ addons = {str(addon).lower() for addon in (manifest.addons or [])}
227
+
228
+ if AddonModule.REQUESTS.value.lower() in addons:
229
+ args.extend([
230
+ "--hidden-import", "requests",
231
+ "--hidden-import", "urllib3",
232
+ "--hidden-import", "certifi",
233
+ "--collect-data", "certifi",
234
+ ])
235
+
236
+ if "kivymd" in addons:
237
+ args.extend([
238
+ "--hidden-import", "kivymd",
239
+ "--hidden-import", "kivymd.app",
240
+ "--hidden-import", "kivymd.uix",
241
+ "--collect-data", "kivymd",
242
+ ])
243
+
244
+ if "plyer" in addons:
245
+ args.extend([
246
+ "--hidden-import", "plyer",
247
+ "--hidden-import", "plyer.platforms",
248
+ ])
249
+
250
+ return args
251
+
252
+ def _resolve_project_assets(
253
+ self,
254
+ project_root: Path,
255
+ manifest: FreezeManifest,
256
+ ) -> List[str]:
257
+ args: List[str] = []
258
+ sep = self._data_separator(manifest)
259
+
260
+ resources_dir_name = manifest.resources_dir or "resources"
261
+ resources_path = (project_root / resources_dir_name).resolve()
262
+
263
+ if resources_path.is_dir():
264
+ # If resources/base exists, include platform profiles
265
+ base_dir = resources_path / "base"
266
+ if base_dir.is_dir():
267
+ # Add base resources
268
+ args.extend(["--add-data", f"{base_dir.as_posix()}{sep}{resources_dir_name}"])
269
+
270
+ # Add platform-specific resources if present
271
+ plat = manifest.target_platform.lower()
272
+ plat_dir = None
273
+ if plat in ("mac", "macos", "darwin"):
274
+ plat_dir = resources_path / "mac"
275
+ elif plat == "windows":
276
+ plat_dir = resources_path / "windows"
277
+ elif plat == "linux":
278
+ plat_dir = resources_path / "linux"
279
+
280
+ if plat_dir and plat_dir.is_dir():
281
+ args.extend(["--add-data", f"{plat_dir.as_posix()}{sep}{resources_dir_name}"])
282
+ else:
283
+ # Flat resources directory
284
+ args.extend([
285
+ "--add-data",
286
+ f"{resources_path.as_posix()}{sep}{resources_dir_name}",
287
+ ])
288
+
289
+ settings_path = (project_root / "settings").resolve()
290
+ if settings_path.is_dir():
291
+ args.extend([
292
+ "--add-data",
293
+ f"{settings_path.as_posix()}{sep}settings",
294
+ ])
295
+
296
+ return args
297
+
298
+ def _data_separator(self, manifest: FreezeManifest) -> str:
299
+ return ";" if manifest.target_platform == "windows" else ":"
300
+
301
+ def _has_qml(self, project_root: Path) -> bool:
302
+ # Avoid detecting .qml in build directories or virtual environments
303
+ for path in project_root.rglob("*.qml"):
304
+ rel_parts = path.relative_to(project_root).parts
305
+ if any(part in ("build", "dist", ".git", ".venv", "venv", "temp") for part in rel_parts):
306
+ continue
307
+ return True
308
+ return False
309
+
310
+ def _resolve_competing_framework_exclusions(
311
+ self,
312
+ binding: Binding,
313
+ manifest: FreezeManifest,
314
+ ) -> List[str]:
315
+ """
316
+ Automatically excludes competing UI framework packages from PyInstaller scanning
317
+ so users never need to manually list 'kivy', 'kivy_install', etc. in exclude_modules.
318
+ """
319
+ exclusions: List[str] = []
320
+ user_excludes = {m.lower() for m in manifest.optimization.exclude_modules}
321
+
322
+ # If active binding is Qt-based (PySide6, PyQt6, PyQt5, PySide2)
323
+ if binding.is_pyside or binding in (Binding.PYQT6, Binding.PYQT5):
324
+ competing = ["kivy", "kivy_install", "tkinter"]
325
+ if binding != Binding.PYSIDE6:
326
+ competing.append("PySide6")
327
+ if binding != Binding.PYQT6:
328
+ competing.append("PyQt6")
329
+ if binding != Binding.PYQT5:
330
+ competing.append("PyQt5")
331
+ if binding != Binding.PYSIDE2:
332
+ competing.append("PySide2")
333
+
334
+ for mod in competing:
335
+ if mod.lower() not in user_excludes:
336
+ exclusions.extend(["--exclude-module", mod])
337
+
338
+ # If active binding is Kivy
339
+ elif binding == Binding.KIVY:
340
+ competing = ["PySide6", "PyQt6", "PyQt5", "PySide2", "tkinter"]
341
+ for mod in competing:
342
+ if mod.lower() not in user_excludes:
343
+ exclusions.extend(["--exclude-module", mod])
344
+
345
+ # If active binding is Tkinter
346
+ elif binding == Binding.TKINTER:
347
+ competing = ["kivy", "kivy_install", "PySide6", "PyQt6", "PyQt5", "PySide2"]
348
+ for mod in competing:
349
+ if mod.lower() not in user_excludes:
350
+ exclusions.extend(["--exclude-module", mod])
351
+
352
+ return exclusions
File without changes