pysfi 0.1.11__py3-none-any.whl → 0.1.13__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.
@@ -6,58 +6,28 @@ import platform
6
6
  import shutil
7
7
  import subprocess
8
8
  import time
9
- from dataclasses import dataclass
9
+ from concurrent.futures import ThreadPoolExecutor, as_completed
10
+ from dataclasses import dataclass, field
11
+ from functools import cached_property
10
12
  from pathlib import Path
13
+ from typing import Final
11
14
 
12
- from sfi.pyprojectparse.pyprojectparse import Project
15
+ from sfi.pyprojectparse.pyprojectparse import Project, Solution
13
16
 
14
17
  is_windows = platform.system() == "Windows"
15
18
  is_linux = platform.system() == "Linux"
16
19
  is_macos = platform.system() == "Darwin"
17
20
  ext = ".exe" if is_windows else ""
18
21
 
19
- logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
22
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
20
23
  logger = logging.getLogger(__name__)
21
24
  cwd = Path.cwd()
22
25
 
26
+ # Default cache directory
27
+ _DEFAULT_BUILD_DIR = Path(".cbuild")
28
+ _DEFAULT_OUTPUT_DIR = Path("dist")
23
29
 
24
- @dataclass(frozen=True)
25
- class CompilerConfig:
26
- """Compiler configuration dataclass.
27
-
28
- Attributes:
29
- name: Compiler name
30
- args: List of compiler arguments
31
- """
32
-
33
- name: str
34
- args: tuple[str, ...]
35
-
36
- def to_list(self) -> list[str]:
37
- """Convert arguments to list.
38
-
39
- Returns:
40
- List of compiler arguments
41
- """
42
- return list(self.args)
43
-
44
-
45
- @dataclass(frozen=True)
46
- class EntryFile:
47
- """Entry file dataclass."""
48
-
49
- project_name: str
50
- source_file: Path
51
-
52
- @property
53
- def entry_name(self) -> str:
54
- """Get entry file name."""
55
- return self.source_file.stem
56
-
57
- def __repr__(self) -> str:
58
- return f"<EntryFile project_name={self.project_name} source_file={self.source_file}>"
59
-
60
-
30
+ # Platform templates
61
31
  _WINDOWS_GUI_TEMPLATE: str = r"""#include <windows.h>
62
32
  #include <stdio.h>
63
33
  #include <stdlib.h>
@@ -84,17 +54,31 @@ static void build_python_command(
84
54
  char script_path[MAX_PATH_LEN];
85
55
 
86
56
  // Build Python interpreter path
87
- snprintf(python_runtime, MAX_PATH_LEN, "%s\\runtime\\%s", exe_dir, is_debug ? "python.exe" : "pythonw.exe");
57
+ // Debug mode: use python.exe to show console output
58
+ // Production GUI mode: use pythonw.exe to hide console window
59
+ if (is_debug) {
60
+ snprintf(python_runtime, MAX_PATH_LEN, "%s\\runtime\\python.exe", exe_dir);
61
+ } else {
62
+ snprintf(python_runtime, MAX_PATH_LEN, "%s\\runtime\\pythonw.exe", exe_dir);
63
+ }
88
64
 
89
65
  // Build startup script path
90
66
  snprintf(script_path, MAX_PATH_LEN, "%s\\%s", exe_dir, entry_file);
91
67
 
92
- // Build command line: python runtime -u script [args] [2>&1 for production]
93
- snprintf(cmd, MAX_PATH_LEN * 2, "\"%s\" -u \"%s\"%s%s%s",
94
- python_runtime, script_path,
95
- lpCmdLine && strlen(lpCmdLine) > 0 ? " " : "",
96
- lpCmdLine && strlen(lpCmdLine) > 0 ? lpCmdLine : "",
97
- is_debug ? "" : " 2>&1");
68
+ // Build command line
69
+ if (is_debug) {
70
+ // Debug mode: don't redirect output, let output show in console
71
+ snprintf(cmd, MAX_PATH_LEN * 2, "\"%s\" -u \"%s\"%s%s",
72
+ python_runtime, script_path,
73
+ lpCmdLine && strlen(lpCmdLine) > 0 ? " " : "",
74
+ lpCmdLine && strlen(lpCmdLine) > 0 ? lpCmdLine : "");
75
+ } else {
76
+ // Production mode: redirect all output to pipe
77
+ snprintf(cmd, MAX_PATH_LEN * 2, "\"%s\" -u \"%s\"%s%s 2>&1",
78
+ python_runtime, script_path,
79
+ lpCmdLine && strlen(lpCmdLine) > 0 ? " " : "",
80
+ lpCmdLine && strlen(lpCmdLine) > 0 ? lpCmdLine : "");
81
+ }
98
82
  }
99
83
 
100
84
  // Read process output
@@ -166,6 +150,12 @@ int APIENTRY WinMain(
166
150
  return 1;
167
151
  }
168
152
 
153
+ // Build Python command
154
+ build_python_command(cmd, exe_dir, "${ENTRY_FILE}", ${DEBUG_MODE}, lpCmdLine);
155
+
156
+ // Setup startup info
157
+ si.cb = sizeof(si);
158
+
169
159
  // Create pipe for capturing output (only in production mode)
170
160
  if (!${DEBUG_MODE}) {
171
161
  sa.nLength = sizeof(SECURITY_ATTRIBUTES);
@@ -177,23 +167,17 @@ int APIENTRY WinMain(
177
167
  return 1;
178
168
  }
179
169
 
180
- si.cb = sizeof(si);
181
170
  si.dwFlags = STARTF_USESTDHANDLES;
182
171
  si.hStdError = hWritePipe;
183
172
  si.hStdOutput = hWritePipe;
184
- } else {
185
- si.cb = sizeof(si);
186
173
  }
187
174
 
188
- // Build and execute Python command
189
- build_python_command(cmd, exe_dir, "${ENTRY_FILE}", ${DEBUG_MODE}, lpCmdLine);
190
-
191
175
  // Create Python process
192
176
  // Debug mode: do not use CREATE_NO_WINDOW, let python.exe create console
193
177
  // Production GUI mode: use CREATE_NO_WINDOW to ensure no console is created
194
178
  DWORD createFlags = ${DEBUG_MODE} ? 0 : CREATE_NO_WINDOW;
195
179
  success = CreateProcessA(
196
- NULL, cmd, NULL, NULL, FALSE,
180
+ NULL, cmd, NULL, NULL, ${DEBUG_MODE} ? TRUE : FALSE,
197
181
  createFlags,
198
182
  NULL, exe_dir, &si, &pi
199
183
  );
@@ -331,7 +315,7 @@ int main(int argc, char* argv[]) {
331
315
  // Get executable directory
332
316
  GetModuleFileNameA(NULL, exe_dir, MAX_PATH_LEN);
333
317
  if ((last_slash = strrchr(exe_dir, '\\'))) *last_slash = '\0';
334
- `
318
+
335
319
  // Check Python runtime
336
320
  if (!check_python_runtime(exe_dir)) {
337
321
  fprintf(stderr, "Error: Python runtime not found at %s\\runtime\\\n", exe_dir);
@@ -418,12 +402,17 @@ _UNIX_GUI_TEMPLATE: str = r"""#define _GNU_SOURCE
418
402
  static void build_python_command(
419
403
  char* cmd,
420
404
  const char* exe_dir,
421
- const char* entry_file
405
+ const char* entry_file,
406
+ int argc,
407
+ char* argv[]
422
408
  ) {
423
409
  char script_path[MAX_PATH_LEN];
410
+ char* p = cmd;
411
+ size_t remaining = MAX_PATH_LEN * 3;
412
+ int len;
424
413
 
425
414
  // Build startup script path
426
- snprintf(script_path, MAX_PATH_LEN * 2, "%s/%s", exe_dir, entry_file);
415
+ snprintf(script_path, MAX_PATH_LEN, "%s/%s", exe_dir, entry_file);
427
416
 
428
417
  // Build log file path (record error information)
429
418
  char log_file[MAX_PATH_LEN];
@@ -431,15 +420,28 @@ static void build_python_command(
431
420
 
432
421
  // Build command line - use system python3 instead of bundled runtime
433
422
  // GUI mode uses nohup and background execution, error output to log
434
- snprintf(cmd, MAX_PATH_LEN * 2,
435
- "cd \"%s\" && nohup python3 -u \"%s\" >\"%s\" 2>&1 &",
436
- exe_dir, script_path, log_file
437
- );
423
+ len = snprintf(p, remaining,
424
+ "cd \"%s\" && nohup python3 -u \"%s\"",
425
+ exe_dir, script_path);
426
+ p += len;
427
+ remaining -= len;
428
+
429
+ // Append command line arguments
430
+ for (int i = 1; i < argc && remaining > 0; i++) {
431
+ len = snprintf(p, remaining, " \"%s\"", argv[i]);
432
+ if (len < 0 || (size_t)len >= remaining) break;
433
+ p += len;
434
+ remaining -= len;
435
+ }
436
+
437
+ // Append log redirection
438
+ if (remaining > 0) {
439
+ snprintf(p, remaining, " >\"%s\" 2>&1 &", log_file);
440
+ }
438
441
  }
439
442
 
440
443
  // Unix GUI entry point
441
444
  int main(int argc, char* argv[]) {
442
- (void)argc;
443
445
  char exe_dir[MAX_PATH_LEN];
444
446
  char cmd[MAX_PATH_LEN * 3];
445
447
  char log_file[MAX_PATH_LEN];
@@ -458,7 +460,7 @@ int main(int argc, char* argv[]) {
458
460
  if ((last_slash = strrchr(exe_dir, '/'))) *last_slash = '\0';
459
461
 
460
462
  // Build and execute Python command
461
- build_python_command(cmd, exe_dir, "${ENTRY_FILE}");
463
+ build_python_command(cmd, exe_dir, "${ENTRY_FILE}", argc, argv);
462
464
  snprintf(log_file, MAX_PATH_LEN, "%s/.error_log", exe_dir);
463
465
 
464
466
  // Execute Python process in background
@@ -508,25 +510,37 @@ _UNIX_CONSOLE_TEMPLATE: str = r"""#define _GNU_SOURCE
508
510
  static void build_python_command(
509
511
  char* cmd,
510
512
  const char* exe_dir,
511
- const char* entry_file
513
+ const char* entry_file,
514
+ int argc,
515
+ char* argv[]
512
516
  ) {
513
517
  char script_path[MAX_PATH_LEN];
518
+ char* p = cmd;
519
+ size_t remaining = MAX_PATH_LEN * 2;
520
+ int len;
514
521
 
515
522
  // Build startup script path
516
523
  snprintf(script_path, MAX_PATH_LEN, "%s/%s", exe_dir, entry_file);
517
524
 
518
525
  // Build command line - use system python3 instead of bundled runtime
519
526
  // add -u parameter for real-time output capture
520
- snprintf(cmd, MAX_PATH_LEN * 2,
527
+ len = snprintf(p, remaining,
521
528
  "cd \"%s\" && python3 -u \"%s\"",
522
- exe_dir, script_path
523
- );
529
+ exe_dir, script_path);
530
+ p += len;
531
+ remaining -= len;
532
+
533
+ // Append command line arguments
534
+ for (int i = 1; i < argc && remaining > 0; i++) {
535
+ len = snprintf(p, remaining, " \"%s\"", argv[i]);
536
+ if (len < 0 || (size_t)len >= remaining) break;
537
+ p += len;
538
+ remaining -= len;
539
+ }
524
540
  }
525
541
 
526
542
  // Unix Console entry point
527
543
  int main(int argc, char* argv[]) {
528
- (void)argc;
529
- (void)argv;
530
544
  char exe_dir[MAX_PATH_LEN];
531
545
  char cmd[MAX_PATH_LEN * 3];
532
546
  pid_t pid;
@@ -543,7 +557,7 @@ int main(int argc, char* argv[]) {
543
557
  if ((last_slash = strrchr(exe_dir, '/'))) *last_slash = '\0';
544
558
 
545
559
  // Build and execute Python command
546
- build_python_command(cmd, exe_dir, "${ENTRY_FILE}");
560
+ build_python_command(cmd, exe_dir, "${ENTRY_FILE}", argc, argv);
547
561
 
548
562
  // Fork and execute Python process
549
563
  pid = fork();
@@ -637,11 +651,16 @@ static void show_error_dialog(const char* message) {
637
651
  static void build_python_command(
638
652
  char* cmd,
639
653
  const char* exe_dir,
640
- const char* entry_file
654
+ const char* entry_file,
655
+ int argc,
656
+ char* argv[]
641
657
  ) {
642
658
  char python_runtime[MAX_PATH_LEN];
643
659
  char script_path[MAX_PATH_LEN];
644
660
  char log_file[MAX_PATH_LEN];
661
+ char* p = cmd;
662
+ size_t remaining = MAX_PATH_LEN * 2;
663
+ int len;
645
664
 
646
665
  // macOS Python path
647
666
  snprintf(python_runtime, MAX_PATH_LEN, "%s/runtime/bin/python3", exe_dir);
@@ -651,16 +670,28 @@ static void build_python_command(
651
670
  snprintf(log_file, MAX_PATH_LEN, "%s/.error_log", exe_dir);
652
671
 
653
672
  // Build command line (GUI mode uses nohup and background execution, error output to log)
654
- snprintf(cmd, MAX_PATH_LEN * 2,
655
- "cd \"%s\" && nohup \"%s\" -u \"%s\" >\"%s\" 2>&1 &",
656
- exe_dir, python_runtime, script_path, log_file
657
- );
673
+ len = snprintf(p, remaining,
674
+ "cd \"%s\" && nohup \"%s\" -u \"%s\"",
675
+ exe_dir, python_runtime, script_path);
676
+ p += len;
677
+ remaining -= len;
678
+
679
+ // Append command line arguments
680
+ for (int i = 1; i < argc && remaining > 0; i++) {
681
+ len = snprintf(p, remaining, " \"%s\"", argv[i]);
682
+ if (len < 0 || (size_t)len >= remaining) break;
683
+ p += len;
684
+ remaining -= len;
685
+ }
686
+
687
+ // Append log redirection
688
+ if (remaining > 0) {
689
+ snprintf(p, remaining, " >\"%s\" 2>&1 &", log_file);
690
+ }
658
691
  }
659
692
 
660
693
  // macOS GUI entry point
661
694
  int main(int argc, char* argv[]) {
662
- (void)argc;
663
- (void)argv;
664
695
  char exe_dir[MAX_PATH_LEN];
665
696
  char cmd[MAX_PATH_LEN * 3];
666
697
  char log_file[MAX_PATH_LEN];
@@ -684,7 +715,7 @@ int main(int argc, char* argv[]) {
684
715
  }
685
716
 
686
717
  // Build and execute Python command
687
- build_python_command(cmd, exe_dir, "${ENTRY_FILE}");
718
+ build_python_command(cmd, exe_dir, "${ENTRY_FILE}", argc, argv);
688
719
  snprintf(log_file, MAX_PATH_LEN, "%s/.error_log", exe_dir);
689
720
 
690
721
  // Execute Python process in background
@@ -738,6 +769,8 @@ _MACOS_CONSOLE_TEMPLATE: str = r"""#define _GNU_SOURCE
738
769
  #include <unistd.h>
739
770
  #include <sys/stat.h>
740
771
  #include <sys/wait.h>
772
+ #include <errno.h>
773
+ #include <CoreFoundation/CoreFoundation.h>
741
774
 
742
775
  #define MAX_PATH_LEN 4096
743
776
 
@@ -772,25 +805,37 @@ static void get_exe_dir_macos(char* exe_dir, size_t size) {
772
805
  static void build_python_command(
773
806
  char* cmd,
774
807
  const char* exe_dir,
775
- const char* entry_file
808
+ const char* entry_file,
809
+ int argc,
810
+ char* argv[]
776
811
  ) {
777
812
  char python_runtime[MAX_PATH_LEN];
778
813
  char script_path[MAX_PATH_LEN];
814
+ char* p = cmd;
815
+ size_t remaining = MAX_PATH_LEN;
816
+ int len;
779
817
 
780
818
  snprintf(python_runtime, MAX_PATH_LEN, "%s/runtime/bin/python3", exe_dir);
781
819
  snprintf(script_path, MAX_PATH_LEN, "%s/%s", exe_dir, entry_file);
782
820
 
783
821
  // Build command line (add -u parameter for real-time output capture)
784
- snprintf(cmd, MAX_PATH_LEN,
822
+ len = snprintf(p, remaining,
785
823
  "cd \"%s\" && \"%s\" -u \"%s\"",
786
- exe_dir, python_runtime, script_path
787
- );
824
+ exe_dir, python_runtime, script_path);
825
+ p += len;
826
+ remaining -= len;
827
+
828
+ // Append command line arguments
829
+ for (int i = 1; i < argc && remaining > 0; i++) {
830
+ len = snprintf(p, remaining, " \"%s\"", argv[i]);
831
+ if (len < 0 || (size_t)len >= remaining) break;
832
+ p += len;
833
+ remaining -= len;
834
+ }
788
835
  }
789
836
 
790
837
  // macOS Console entry point
791
838
  int main(int argc, char* argv[]) {
792
- (void)argc;
793
- (void)argv;
794
839
  char exe_dir[MAX_PATH_LEN];
795
840
  char cmd[MAX_PATH_LEN * 3];
796
841
  char python_runtime[MAX_PATH_LEN];
@@ -809,7 +854,7 @@ int main(int argc, char* argv[]) {
809
854
  }
810
855
 
811
856
  // Build and execute Python command
812
- build_python_command(cmd, exe_dir, "${ENTRY_FILE}");
857
+ build_python_command(cmd, exe_dir, "${ENTRY_FILE}", argc, argv);
813
858
 
814
859
  // Fork and execute Python process
815
860
  pid = fork();
@@ -867,12 +912,76 @@ from src.$PROJECT_NAME.$ENTRY_NAME import main
867
912
  main()
868
913
  """
869
914
 
870
- # Compiler configurations using frozenset for better performance
871
- _COMPILER_CONFIGS: frozenset[CompilerConfig] = frozenset([
915
+
916
+ @dataclass(frozen=True)
917
+ class CompilerConfig:
918
+ """Compiler configuration dataclass.
919
+
920
+ Attributes:
921
+ name: Compiler name
922
+ args: Tuple of compiler arguments
923
+ """
924
+
925
+ name: str
926
+ args: tuple[str, ...]
927
+
928
+ def to_list(self) -> list[str]:
929
+ """Convert arguments to list.
930
+
931
+ Returns:
932
+ List of compiler arguments
933
+ """
934
+ return list(self.args)
935
+
936
+
937
+ @dataclass(frozen=True)
938
+ class EntryFile:
939
+ """Entry file dataclass with enhanced functionality."""
940
+
941
+ project_name: str
942
+ source_file: Path
943
+
944
+ @cached_property
945
+ def entry_name(self) -> str:
946
+ """Get entry file name."""
947
+ return self.source_file.stem
948
+
949
+ @cached_property
950
+ def module_name(self) -> str:
951
+ """Get module name from source file stem."""
952
+ return self.source_file.stem.replace("-", "_")
953
+
954
+ @cached_property
955
+ def is_gui(self) -> bool:
956
+ """Determine if this entry file is a GUI application.
957
+
958
+ Entry file is considered GUI if:
959
+ 1. File name contains 'gui' (e.g., docscan_gui.py, myapp-gui.py)
960
+ 2. File imports GUI frameworks (PySide2, PyQt5, PyQt6, tkinter)
961
+ """
962
+ # Check file name for 'gui' keyword
963
+ if "gui" in self.source_file.stem.lower():
964
+ return True
965
+
966
+ # Check file content for GUI framework imports
967
+ try:
968
+ content = self.source_file.read_text(encoding="utf-8")
969
+ gui_keywords = ["pyside2", "pyqt5", "pyqt6", "tkinter", "tkinter.ttk"]
970
+ return any(f"from {kw}" in content.lower() or f"import {kw}" in content.lower()
971
+ for kw in gui_keywords)
972
+ except Exception:
973
+ return False
974
+
975
+ def __repr__(self) -> str:
976
+ return f"<EntryFile project_name={self.project_name} source_file={self.source_file} entry_name={self.entry_name} is_gui={self.is_gui}>"
977
+
978
+
979
+ # Compiler configurations
980
+ _COMPILER_CONFIGS: Final = [
872
981
  CompilerConfig(name="gcc", args=("-std=c99", "-Wall", "-O2")),
873
982
  CompilerConfig(name="clang", args=("-std=c99", "-Wall", "-O2")),
874
983
  CompilerConfig(name="cl", args=("/std:c99", "/O2")),
875
- ])
984
+ ]
876
985
 
877
986
 
878
987
  def find_compiler() -> str | None:
@@ -1090,156 +1199,199 @@ def _detect_entry_files(
1090
1199
  return entry_files
1091
1200
 
1092
1201
 
1093
- def _generate_single_loader(
1094
- project: Project,
1095
- project_dir: Path,
1096
- entry_file: EntryFile,
1097
- is_debug: bool,
1098
- ) -> bool:
1099
- """Generate a single loader executable and entry file.
1202
+ @dataclass
1203
+ class PyLoaderBuilder:
1204
+ """Helper class to build individual loaders."""
1100
1205
 
1101
- Args:
1102
- project_dir: Project directory
1103
- entry_name: Entry file name (e.g., "docscan", "docscan-gui")
1104
- entry_module: Python module name (e.g., "docscan", "docscan_gui")
1105
- loader_type: Loader type ("console" or "gui")
1106
- use_qt: Whether this is a Qt application
1107
- is_debug: Whether to generate debug version
1108
- compiler: Optional compiler to use
1206
+ parent: PyLoaderGenerator
1207
+ project: Project
1208
+ entry_file: EntryFile
1209
+ is_debug: bool = False
1109
1210
 
1110
- Returns:
1111
- True if successful, False otherwise
1112
- """
1113
- entry_name = entry_file.entry_name
1114
-
1115
- # Prepare directories
1116
- build_dir = project_dir / ".cbuild"
1117
- output_dir = project_dir / "dist"
1118
- build_dir.mkdir(parents=True, exist_ok=True)
1119
- output_dir.mkdir(parents=True, exist_ok=True)
1120
-
1121
- # Output paths
1122
- output_exe = output_dir / f"{entry_name}{ext}"
1123
- c_source_filename = (
1124
- f"{entry_name}_debug_console.c"
1125
- if is_debug
1126
- else f"{entry_name}_{project.is_gui}.c"
1127
- )
1128
- entry_filepath = output_dir / f"{entry_name}.ent"
1129
- c_source_path = build_dir / c_source_filename
1130
-
1131
- t0 = time.perf_counter()
1132
-
1133
- # Generate entry file
1134
- # Extract project name from entry_name (remove -gui suffix if present)
1135
- entry_file_code = prepare_entry_file(project=project, entry_stem=entry_name)
1136
- entry_filepath.write_text(entry_file_code, encoding="utf-8")
1137
- logger.debug(f"Generated entry file: {entry_filepath}")
1138
-
1139
- # Generate and compile C source
1140
- c_template = select_c_template(project.loader_type, is_debug)
1141
- c_code = prepare_c_source(c_template, entry_filepath.name, is_debug)
1142
- c_source_path.write_text(c_code, encoding="utf-8")
1143
- logger.debug(f"Generated C source code: {c_source_path}")
1144
-
1145
- compiler = find_compiler()
1146
- if not compiler:
1147
- logger.error(f"Failed to find compiler: {compiler}")
1148
- return False
1211
+ @cached_property
1212
+ def build_dir(self) -> Path:
1213
+ """Build directory path."""
1214
+ return self.parent.root_dir / _DEFAULT_BUILD_DIR
1149
1215
 
1150
- success = compile_c_source(c_source_path, output_exe, compiler)
1151
- if success:
1152
- logger.info(f"Loader executable generated successfully: {output_exe}")
1153
- logger.debug(f"Entry: {entry_file}")
1154
- logger.debug(f"Type: {project.loader_type}")
1155
- logger.debug(f"Qt: {project.has_qt}")
1156
- logger.debug(f"Debug mode: {is_debug}")
1157
- logger.debug(f"Compilation time: {time.perf_counter() - t0:.4f} seconds")
1158
- else:
1159
- logger.error(f"Failed to compile loader for {entry_name}")
1216
+ @cached_property
1217
+ def output_dir(self) -> Path:
1218
+ """Output directory path."""
1219
+ return self.parent.root_dir / _DEFAULT_OUTPUT_DIR
1160
1220
 
1161
- return success
1221
+ @cached_property
1222
+ def output_exe(self) -> Path:
1223
+ """Output executable path."""
1224
+ return self.output_dir / f"{self.entry_file.entry_name}{ext}"
1162
1225
 
1226
+ @cached_property
1227
+ def loader_type(self) -> str:
1228
+ """Determine loader type based on entry file.
1163
1229
 
1164
- def generate_loader_for_project(
1165
- project: Project,
1166
- project_dir: Path,
1167
- debug: bool = False,
1168
- ):
1169
- """Generate loader executables and entry files for a single project.
1230
+ Entry file is GUI if:
1231
+ - The entry file itself is detected as GUI (contains 'gui' in name or imports GUI libs)
1232
+ - Otherwise, fall back to project-level loader_type
1233
+ """
1234
+ return "gui" if self.entry_file.is_gui else self.project.loader_type
1235
+
1236
+ @cached_property
1237
+ def c_source_path(self) -> Path:
1238
+ """C source file path."""
1239
+ filename = (
1240
+ f"{self.entry_file.entry_name}_debug_console.c"
1241
+ if self.is_debug
1242
+ else f"{self.entry_file.entry_name}_{self.loader_type}.c"
1243
+ )
1244
+ return self.build_dir / filename
1170
1245
 
1171
- This function detects all entry files (main and GUI versions) and generates
1172
- a loader for each one.
1246
+ @cached_property
1247
+ def entry_filepath(self) -> Path:
1248
+ """Entry file path."""
1249
+ return self.output_dir / f"{self.entry_file.entry_name}.ent"
1173
1250
 
1174
- Args:
1175
- project: Project information dataclass
1176
- project_dir: Directory containing the project (pyproject.toml location)
1177
- debug: Whether to generate debug version
1178
- """
1179
- # Determine base loader type and Qt usage from project info
1180
- # Detect all entry files in project directory
1181
- entry_files = _detect_entry_files(project_dir, project.name)
1182
- if not entry_files:
1183
- logger.error(f"No entry files found in {project_dir}")
1184
- return False
1251
+ def generate(self) -> bool:
1252
+ """Generate loader for a single entry file."""
1253
+ t0 = time.perf_counter()
1254
+
1255
+ # Prepare directories
1256
+ self.build_dir.mkdir(parents=True, exist_ok=True)
1257
+ self.output_dir.mkdir(parents=True, exist_ok=True)
1258
+
1259
+ # Generate entry file
1260
+ entry_file_code = prepare_entry_file(
1261
+ project=self.project, entry_stem=self.entry_file.entry_name
1262
+ )
1263
+ self.entry_filepath.write_text(entry_file_code, encoding="utf-8")
1264
+ logger.debug(f"Generated entry file: {self.entry_filepath}")
1265
+
1266
+ # Generate and compile C source
1267
+ c_template = select_c_template(self.loader_type, self.is_debug)
1268
+ c_code = prepare_c_source(c_template, self.entry_filepath.name, self.is_debug)
1269
+ self.c_source_path.write_text(c_code, encoding="utf-8")
1270
+ logger.debug(f"Generated C source code: {self.c_source_path}")
1185
1271
 
1186
- # Generate loaders for all entry files
1187
- for entry_file in entry_files:
1188
- logger.debug(f"Generating loader for: {entry_file.project_name}")
1189
- if not _generate_single_loader(
1190
- project=project,
1191
- project_dir=project_dir,
1192
- entry_file=entry_file,
1193
- is_debug=debug,
1194
- ):
1272
+ compiler = find_compiler()
1273
+ if not compiler:
1274
+ logger.error(f"Failed to find compiler: {compiler}")
1195
1275
  return False
1196
1276
 
1197
- return True
1277
+ success = compile_c_source(self.c_source_path, self.output_exe, compiler)
1278
+ if success:
1279
+ logger.info(f"Loader executable generated successfully: {self.output_exe}")
1280
+ logger.debug(f"Entry: {self.entry_file}")
1281
+ logger.debug(f"Type: {self.loader_type}")
1282
+ logger.debug(f"Qt: {self.project.has_qt}")
1283
+ logger.debug(f"Debug mode: {self.is_debug}")
1284
+ logger.debug(f"Compilation time: {time.perf_counter() - t0:.4f} seconds")
1285
+ else:
1286
+ logger.error(f"Failed to compile loader for {self.entry_file.entry_name}")
1198
1287
 
1288
+ return success
1199
1289
 
1200
- def generate_loader(base_dir: Path, debug: bool) -> None:
1201
- """Generate loader executables for all projects in the given directory."""
1202
- from sfi.pyprojectparse.pyprojectparse import Solution
1203
1290
 
1204
- projects = Solution.projects
1205
- if not projects:
1206
- logger.error("Failed to load project information")
1291
+ @dataclass
1292
+ class PyLoaderGenerator:
1293
+ """Main class for generating Python loader executables."""
1207
1294
 
1208
- success_count = 0
1209
- failed_projects: list[str] = []
1295
+ root_dir: Path
1296
+ build_dir: Path = field(default_factory=lambda: _DEFAULT_BUILD_DIR)
1297
+ output_dir: Path = field(default_factory=lambda: _DEFAULT_OUTPUT_DIR)
1298
+ compiler: str | None = None
1210
1299
 
1211
- if len(projects) == 1:
1212
- logger.debug("Only one project found, using current directory")
1213
- project_dir = base_dir
1214
- if generate_loader_for_project(
1215
- project=next(iter(projects.values())),
1216
- project_dir=project_dir,
1217
- debug=debug,
1218
- ):
1219
- success_count += 1
1220
- else:
1221
- failed_projects.append(next(iter(projects)))
1222
- else:
1223
- for project_name, project in projects.items():
1224
- project_dir = base_dir / project_name
1225
- if not project_dir.is_dir():
1226
- logger.error(f"Project directory not found: {project_dir}, skipping...")
1227
- failed_projects.append(project_name)
1228
- continue
1229
-
1230
- if generate_loader_for_project(
1231
- project=project,
1300
+ @cached_property
1301
+ def solution(self) -> Solution:
1302
+ """Get the solution from the target directory."""
1303
+ return Solution.from_directory(self.root_dir)
1304
+
1305
+ def generate_for_project(
1306
+ self, project: Project, project_dir: Path, debug: bool = False
1307
+ ) -> bool:
1308
+ """Generate loader executables and entry files for a single project.
1309
+
1310
+ This function detects all entry files (main and GUI versions) and generates
1311
+ a loader for each one.
1312
+
1313
+ Args:
1314
+ project: Project information dataclass
1315
+ project_dir: Directory containing the project (pyproject.toml location)
1316
+ debug: Whether to generate debug version
1317
+ """
1318
+ # Detect all entry files in project directory
1319
+ entry_files = _detect_entry_files(project_dir, project.name)
1320
+ if not entry_files:
1321
+ logger.error(f"No entry files found in {project_dir}")
1322
+ return False
1323
+
1324
+ # Generate loaders for all entry files
1325
+ for entry_file in entry_files:
1326
+ logger.debug(f"Generating loader for: {entry_file.project_name}")
1327
+ builder = PyLoaderBuilder(
1328
+ parent=self, project=project, entry_file=entry_file, is_debug=debug
1329
+ )
1330
+
1331
+ if not builder.generate():
1332
+ return False
1333
+
1334
+ return True
1335
+
1336
+ def run(self, debug: bool = False) -> None:
1337
+ """Generate loader executables for all projects concurrently."""
1338
+ projects = self.solution.projects
1339
+ if not projects:
1340
+ logger.error("Failed to load project information")
1341
+ return
1342
+
1343
+ success_count = 0
1344
+ failed_projects: list[str] = []
1345
+
1346
+ if len(projects) == 1:
1347
+ logger.debug("Only one project found, using current directory")
1348
+ project_dir = self.root_dir
1349
+ if self.generate_for_project(
1350
+ project=next(iter(projects.values())),
1232
1351
  project_dir=project_dir,
1233
1352
  debug=debug,
1234
1353
  ):
1235
1354
  success_count += 1
1236
1355
  else:
1237
- failed_projects.append(project_name)
1238
-
1239
- logger.info(f"Pack {success_count}/{len(projects)} projects successfully")
1240
-
1241
- if failed_projects:
1242
- logger.error(f"Failed: {failed_projects}")
1356
+ failed_projects.append(next(iter(projects)))
1357
+ else:
1358
+ # Prepare project tasks
1359
+ project_tasks = []
1360
+ for project_name, project in projects.items():
1361
+ project_dir = self.root_dir / project_name
1362
+ if not project_dir.is_dir():
1363
+ logger.error(
1364
+ f"Project directory not found: {project_dir}, skipping..."
1365
+ )
1366
+ failed_projects.append(project_name)
1367
+ continue
1368
+ project_tasks.append((project_name, project, project_dir))
1369
+
1370
+ # Execute compilation tasks concurrently
1371
+ logger.info(f"Compiling {len(project_tasks)} projects concurrently...")
1372
+ with ThreadPoolExecutor(max_workers=None) as executor:
1373
+ future_to_project = {
1374
+ executor.submit(
1375
+ self.generate_for_project, project, project_dir, debug
1376
+ ): project_name
1377
+ for project_name, project, project_dir in project_tasks
1378
+ }
1379
+
1380
+ for future in as_completed(future_to_project):
1381
+ project_name = future_to_project[future]
1382
+ try:
1383
+ if future.result():
1384
+ success_count += 1
1385
+ else:
1386
+ failed_projects.append(project_name)
1387
+ except Exception as e:
1388
+ logger.error(f"Project {project_name} compilation failed: {e}")
1389
+ failed_projects.append(project_name)
1390
+
1391
+ logger.info(f"Pack {success_count}/{len(projects)} projects successfully")
1392
+
1393
+ if failed_projects:
1394
+ logger.error(f"Failed: {failed_projects}")
1243
1395
 
1244
1396
 
1245
1397
  def parse_args() -> argparse.Namespace:
@@ -1273,7 +1425,9 @@ def main():
1273
1425
  return
1274
1426
 
1275
1427
  logger.info(f"Working directory: {working_dir}")
1276
- generate_loader(working_dir, args.debug)
1428
+
1429
+ generator = PyLoaderGenerator(root_dir=working_dir, compiler=args.compiler)
1430
+ generator.run(debug=args.debug)
1277
1431
 
1278
1432
 
1279
1433
  if __name__ == "__main__":