pysfi 0.1.6__py3-none-any.whl → 0.1.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import json
4
5
  import logging
5
6
  import platform
6
7
  import shutil
7
8
  import subprocess
8
9
  import time
10
+ from dataclasses import dataclass, field
9
11
  from pathlib import Path
12
+ from typing import Any
10
13
 
11
14
  is_windows = platform.system() == "Windows"
12
15
  is_linux = platform.system() == "Linux"
@@ -17,6 +20,84 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
17
20
  logger = logging.getLogger(__name__)
18
21
  cwd = Path.cwd()
19
22
 
23
+
24
+ @dataclass(frozen=True)
25
+ class ProjectInfo:
26
+ """Project information dataclass.
27
+
28
+ Attributes:
29
+ name: Project name
30
+ version: Project version
31
+ description: Project description
32
+ dependencies: List of project dependencies
33
+ keywords: Project keywords
34
+ scripts: Entry point scripts
35
+ """
36
+
37
+ name: str
38
+ version: str = "0.0.0"
39
+ description: str = ""
40
+ dependencies: list[str] = field(default_factory=list)
41
+ keywords: list[str] = field(default_factory=list)
42
+ scripts: dict[str, str] = field(default_factory=dict)
43
+
44
+ @classmethod
45
+ def from_dict(cls, project_name: str, data: dict[str, Any]) -> ProjectInfo:
46
+ """Create ProjectInfo from dictionary.
47
+
48
+ Args:
49
+ project_name: Project name
50
+ data: Dictionary containing project information
51
+
52
+ Returns:
53
+ ProjectInfo instance
54
+ """
55
+ return cls(
56
+ name=project_name,
57
+ version=data.get("version", "0.0.0"),
58
+ description=data.get("description", ""),
59
+ dependencies=data.get("dependencies", []),
60
+ keywords=data.get("keywords", []),
61
+ scripts=data.get("scripts", {}),
62
+ )
63
+
64
+ def to_dict(self) -> dict[str, Any]:
65
+ """Convert ProjectInfo to dictionary.
66
+
67
+ Returns:
68
+ Dictionary representation of ProjectInfo
69
+ """
70
+ return {
71
+ "name": self.name,
72
+ "version": self.version,
73
+ "description": self.description,
74
+ "dependencies": self.dependencies,
75
+ "keywords": self.keywords,
76
+ "scripts": self.scripts,
77
+ }
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class CompilerConfig:
82
+ """Compiler configuration dataclass.
83
+
84
+ Attributes:
85
+ name: Compiler name
86
+ args: List of compiler arguments
87
+ """
88
+
89
+ name: str
90
+ args: tuple[str, ...]
91
+
92
+ def to_list(self) -> list[str]:
93
+ """Convert arguments to list.
94
+
95
+ Returns:
96
+ List of compiler arguments
97
+ """
98
+ return list(self.args)
99
+
100
+
20
101
  _WINDOWS_GUI_TEMPLATE: str = r"""#include <windows.h>
21
102
  #include <stdio.h>
22
103
  #include <stdlib.h>
@@ -36,7 +117,8 @@ static void build_python_command(
36
117
  char* cmd,
37
118
  const char* exe_dir,
38
119
  const char* entry_file,
39
- int is_debug
120
+ int is_debug,
121
+ LPSTR lpCmdLine
40
122
  ) {
41
123
  char python_runtime[MAX_PATH_LEN];
42
124
  char script_path[MAX_PATH_LEN];
@@ -57,10 +139,18 @@ static void build_python_command(
57
139
  // Build command line (add -u parameter for real-time output capture)
58
140
  if (is_debug) {
59
141
  // Debug mode: do not redirect output, display output on console
60
- snprintf(cmd, MAX_PATH_LEN, "\"%s\" -u \"%s\"", python_runtime, script_path);
142
+ if (lpCmdLine && strlen(lpCmdLine) > 0) {
143
+ snprintf(cmd, MAX_PATH_LEN, "\"%s\" -u \"%s\" %s", python_runtime, script_path, lpCmdLine);
144
+ } else {
145
+ snprintf(cmd, MAX_PATH_LEN, "\"%s\" -u \"%s\"", python_runtime, script_path);
146
+ }
61
147
  } else {
62
148
  // Production mode: redirect all output to pipe
63
- snprintf(cmd, MAX_PATH_LEN, "\"%s\" -u \"%s\" 2>&1", python_runtime, script_path);
149
+ if (lpCmdLine && strlen(lpCmdLine) > 0) {
150
+ snprintf(cmd, MAX_PATH_LEN, "\"%s\" -u \"%s\" %s 2>&1", python_runtime, script_path, lpCmdLine);
151
+ } else {
152
+ snprintf(cmd, MAX_PATH_LEN, "\"%s\" -u \"%s\" 2>&1", python_runtime, script_path);
153
+ }
64
154
  }
65
155
  }
66
156
 
@@ -132,7 +222,7 @@ int APIENTRY WinMain(
132
222
  }
133
223
 
134
224
  // Build and execute Python command
135
- build_python_command(cmd, exe_dir, "${ENTRY_FILE}", ${DEBUG_MODE});
225
+ build_python_command(cmd, exe_dir, "${ENTRY_FILE}", ${DEBUG_MODE}, lpCmdLine);
136
226
 
137
227
  // Create pipe for capturing output
138
228
  sa.nLength = sizeof(SECURITY_ATTRIBUTES);
@@ -199,7 +289,13 @@ int APIENTRY WinMain(
199
289
  GetExitCodeProcess(pi.hProcess, &exit_code);
200
290
 
201
291
  // Debug output
202
- fprintf(stderr, "DEBUG: Python process exited with code: %lu\n", exit_code);
292
+ if (${DEBUG_MODE}) {
293
+ #ifdef _WIN32
294
+ fwprintf(stderr, L"DEBUG: Python process exited with code: %lu\n", exit_code);
295
+ #else
296
+ fprintf(stderr, "DEBUG: Python process exited with code: %lu\n", exit_code);
297
+ #endif
298
+ }
203
299
 
204
300
  // Cleanup
205
301
  CloseHandle(pi.hProcess);
@@ -227,6 +323,7 @@ _WINDOWS_CONSOLE_TEMPLATE: str = r"""#include <stdio.h>
227
323
  #include <locale.h>
228
324
 
229
325
  #define MAX_PATH_LEN 4096
326
+ #define MAX_ERROR_LEN 8192
230
327
 
231
328
  // Set console encoding to UTF-8
232
329
  static void setup_encoding() {
@@ -245,7 +342,8 @@ static void build_python_command(
245
342
  char* cmd,
246
343
  const char* exe_dir,
247
344
  const char* entry_file,
248
- int debug_mode
345
+ int argc,
346
+ char* argv[]
249
347
  ) {
250
348
  char python_runtime[MAX_PATH_LEN];
251
349
  char script_path[MAX_PATH_LEN];
@@ -257,10 +355,30 @@ static void build_python_command(
257
355
  snprintf(script_path, MAX_PATH_LEN, "%s\\%s", exe_dir, entry_file);
258
356
 
259
357
  // Build command line (add -u parameter for real-time output capture)
260
- if (debug_mode) {
261
- snprintf(cmd, MAX_PATH_LEN, "\"%s\" \"%s\"", python_runtime, script_path);
262
- } else {
263
- snprintf(cmd, MAX_PATH_LEN, "\"%s\" -u \"%s\" 2>&1", python_runtime, script_path);
358
+ // Include original command line arguments
359
+ snprintf(cmd, MAX_PATH_LEN, "\"%s\" -u \"%s\"", python_runtime, script_path);
360
+
361
+ // Append original command line arguments if any
362
+ if (argc > 1) {
363
+ strcat(cmd, " ");
364
+ for (int i = 1; i < argc; i++) {
365
+ strcat(cmd, "\"");
366
+ strcat(cmd, argv[i]);
367
+ strcat(cmd, "\"");
368
+ if (i < argc - 1) {
369
+ strcat(cmd, " ");
370
+ }
371
+ }
372
+ }
373
+ }
374
+
375
+ // Read process output
376
+ static void read_process_output(HANDLE hPipe, char* output, int max_len) {
377
+ DWORD bytes_read;
378
+ output[0] = '\0';
379
+
380
+ while (ReadFile(hPipe, output + strlen(output), max_len - strlen(output) - 1, &bytes_read, NULL) && bytes_read > 0) {
381
+ output[bytes_read] = '\0';
264
382
  }
265
383
  }
266
384
 
@@ -270,6 +388,7 @@ int main(int argc, char* argv[]) {
270
388
  char cmd[MAX_PATH_LEN * 2];
271
389
  STARTUPINFOA si = {0};
272
390
  PROCESS_INFORMATION pi = {0};
391
+ BOOL success;
273
392
 
274
393
  // Set encoding to UTF-8
275
394
  setup_encoding();
@@ -282,37 +401,65 @@ int main(int argc, char* argv[]) {
282
401
  }
283
402
 
284
403
  // Check Python runtime
285
- char python_runtime_check[MAX_PATH_LEN];
286
- snprintf(python_runtime_check, MAX_PATH_LEN, "%s\\runtime\\python.exe", exe_dir);
287
- if (!check_python_runtime(python_runtime_check)) {
404
+ if (!check_python_runtime(exe_dir)) {
288
405
  fprintf(stderr, "Error: Python runtime not found at %s\\runtime\\\n", exe_dir);
289
406
  fprintf(stderr, "Please ensure the application is installed correctly.\n");
290
407
  return 1;
291
408
  }
292
409
 
293
- // Build and execute Python command
294
- build_python_command(cmd, exe_dir, "${ENTRY_FILE}", ${DEBUG_MODE});
410
+ // Build Python command
411
+ build_python_command(cmd, exe_dir, "${ENTRY_FILE}", argc, argv);
295
412
 
296
413
  // Debug output
297
414
  if (${DEBUG_MODE}) {
415
+ #ifdef _WIN32
416
+ // On Windows, convert to wide characters for proper Unicode display
417
+ {
418
+ // Convert and print command
419
+ int wcmd_len = MultiByteToWideChar(CP_UTF8, 0, cmd, -1, NULL, 0);
420
+ if (wcmd_len > 0) {
421
+ wchar_t* wcmd = (wchar_t*)malloc(wcmd_len * sizeof(wchar_t));
422
+ MultiByteToWideChar(CP_UTF8, 0, cmd, -1, wcmd, wcmd_len);
423
+ fwprintf(stderr, L"DEBUG: Command to execute: %ls\n", wcmd);
424
+ free(wcmd);
425
+ }
426
+
427
+ // Convert and print exe_dir
428
+ int wdir_len = MultiByteToWideChar(CP_UTF8, 0, exe_dir, -1, NULL, 0);
429
+ if (wdir_len > 0) {
430
+ wchar_t* wdir = (wchar_t*)malloc(wdir_len * sizeof(wchar_t));
431
+ MultiByteToWideChar(CP_UTF8, 0, exe_dir, -1, wdir, wdir_len);
432
+ fwprintf(stderr, L"DEBUG: exe_dir: %ls\n", wdir);
433
+ free(wdir);
434
+ }
435
+ }
436
+ #else
298
437
  fprintf(stderr, "DEBUG: Command to execute: %s\n", cmd);
299
438
  fprintf(stderr, "DEBUG: exe_dir: %s\n", exe_dir);
439
+ #endif
300
440
  }
301
441
 
302
- // Set startup info to inherit console
442
+ // Set startup info
303
443
  si.cb = sizeof(si);
304
444
 
305
- // Ensure standard handles are inherited so output displays on console
306
- si.dwFlags = STARTF_USESTDHANDLES;
307
- si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
308
- si.hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE);
309
- si.hStdError = GetStdHandle(STD_ERROR_HANDLE);
445
+ // For console applications, always inherit console handles to display output directly
446
+ // Don't use pipes in non-debug mode to allow all output to display in console
447
+ if (${DEBUG_MODE}) {
448
+ // Debug mode: do not use pipe, inherit console to display output directly
449
+ // Keep si.dwFlags as 0, do not set STARTF_USESTDHANDLES
450
+ } else {
451
+ // Production mode for console apps: inherit console handles to show ALL output in console
452
+ // Do not use pipes at all to ensure all stdout/stderr displays in console
453
+ }
310
454
 
311
455
  // Create Python process
312
- if (!CreateProcessA(
313
- NULL, cmd, NULL, NULL, TRUE, // TRUE to inherit standard handles
456
+ // TRUE - inherit handles so Python can write to console
457
+ success = CreateProcessA(
458
+ NULL, cmd, NULL, NULL, TRUE,
314
459
  0, NULL, exe_dir, &si, &pi
315
- )) {
460
+ );
461
+
462
+ if (!success) {
316
463
  DWORD error = GetLastError();
317
464
  fprintf(stderr, "Error: Failed to start Python process.\n");
318
465
  fprintf(stderr, "Error code: %lu\n", error);
@@ -320,6 +467,9 @@ int main(int argc, char* argv[]) {
320
467
  return 1;
321
468
  }
322
469
 
470
+ // In non-debug mode for console apps, we don't use pipes, so no error output capture
471
+ // All output (stdout/stderr) goes directly to console
472
+
323
473
  // Wait for process to end
324
474
  WaitForSingleObject(pi.hProcess, INFINITE);
325
475
 
@@ -336,10 +486,11 @@ int main(int argc, char* argv[]) {
336
486
  CloseHandle(pi.hProcess);
337
487
  CloseHandle(pi.hThread);
338
488
 
339
- // If process exits abnormally, display prompt
340
- if (exit_code != 0 && !${DEBUG_MODE}) {
341
- fprintf(stderr, "\nApplication exited abnormally, error code: %lu\n", exit_code);
342
- fprintf(stderr, "Please check the error information above for details.\n");
489
+ // In non-debug mode, we don't capture output, so just report exit code if non-zero
490
+ if (exit_code != 0) {
491
+ fprintf(stderr, "\nApplication exited with error code: %lu\n", exit_code);
492
+ fprintf(stderr, "Check console output above for details.\n");
493
+ return exit_code;
343
494
  }
344
495
 
345
496
  return exit_code;
@@ -382,6 +533,7 @@ static void build_python_command(
382
533
 
383
534
  // Unix GUI entry point
384
535
  int main(int argc, char* argv[]) {
536
+ (void)argc; // Unused parameter (argv[0] is used)
385
537
  char exe_dir[MAX_PATH_LEN];
386
538
  char cmd[MAX_PATH_LEN * 2];
387
539
  char log_file[MAX_PATH_LEN];
@@ -471,6 +623,8 @@ static void build_python_command(
471
623
 
472
624
  // Unix Console entry point
473
625
  int main(int argc, char* argv[]) {
626
+ (void)argc; // Unused parameter
627
+ (void)argv; // Unused parameter
474
628
  char exe_dir[MAX_PATH_LEN];
475
629
  char cmd[MAX_PATH_LEN * 2];
476
630
  pid_t pid;
@@ -558,10 +712,10 @@ static void get_exe_dir_macos(char* exe_dir, size_t size) {
558
712
  if (macos_path) {
559
713
  *macos_path = '\0';
560
714
  }
561
- }
715
+ }
562
716
 
563
- // Display macOS error dialog
564
- static void show_error_dialog(const char* message) {
717
+ // Display macOS error dialog
718
+ static void show_error_dialog(const char* message) {
565
719
  CFStringRef cf_message = CFStringCreateWithCString(
566
720
  NULL, message, kCFStringEncodingUTF8
567
721
  );
@@ -603,6 +757,8 @@ static void build_python_command(
603
757
 
604
758
  // macOS GUI entry point
605
759
  int main(int argc, char* argv[]) {
760
+ (void)argc; // Unused parameter
761
+ (void)argv; // Unused parameter
606
762
  char exe_dir[MAX_PATH_LEN];
607
763
  char cmd[MAX_PATH_LEN * 2];
608
764
  char log_file[MAX_PATH_LEN];
@@ -729,6 +885,8 @@ static void build_python_command(
729
885
 
730
886
  // macOS Console entry point
731
887
  int main(int argc, char* argv[]) {
888
+ (void)argc; // Unused parameter
889
+ (void)argv; // Unused parameter
732
890
  char exe_dir[MAX_PATH_LEN];
733
891
  char cmd[MAX_PATH_LEN * 2];
734
892
  pid_t pid;
@@ -772,40 +930,89 @@ int main(int argc, char* argv[]) {
772
930
  }
773
931
  """
774
932
 
775
- _COMPILER_OPTIONS: dict[str, list[str]] = {
776
- "gcc": ["-std=c99", "-Wall", "-Wextra", "-pedantic", "-O2", "-D_GNU_SOURCE"],
777
- "clang": ["-std=c99", "-Wall", "-Wextra", "-pedantic", "-O2", "-D_GNU_SOURCE"],
778
- "cl": ["/std:c99", "/Wall", "/Wextra", "/pedantic", "/O2", "/D_GNU_SOURCE"],
779
- }
933
+ ENTRY_FILE_TEMPLATE = r"""
934
+ import os
935
+ import sys
936
+ from pathlib import Path
780
937
 
938
+ # Setup environment
939
+ cwd = Path.cwd()
940
+ site_dirs = [cwd / "site-packages", cwd / "lib"]
941
+ dirs = [cwd, cwd / "src", cwd / "runtime", *site_dirs]
942
+
943
+ for dir in dirs:
944
+ sys.path.append(str(dir))
945
+
946
+ # Fix zipimporter compatibility for packages imported from zip archives
947
+ # Some packages (e.g., markdown) require exec_module on zipimporter
948
+ import zipimport
949
+ if not hasattr(zipimport.zipimporter, 'exec_module'):
950
+ def _create_module(self, spec):
951
+ return None
952
+ def _exec_module(self, module):
953
+ code = self.get_code(module.__name__)
954
+ exec(code, module.__dict__)
955
+ zipimport.zipimporter.create_module = _create_module
956
+ zipimport.zipimporter.exec_module = _exec_module
957
+
958
+ # Qt configuration (optional)
959
+ $QT_CONFIG
960
+
961
+ # Main entry point
962
+ from src.$PROJECT_NAME.$ENTRY_FILE import main
963
+ main()
964
+ """
781
965
 
782
- def find_compiler() -> str | None:
783
- """Find the path to the specified compiler."""
784
- compilers = _COMPILER_OPTIONS.keys()
966
+ # Compiler configurations using frozenset for better performance
967
+ _COMPILER_CONFIGS: frozenset[CompilerConfig] = frozenset([
968
+ CompilerConfig(name="gcc", args=("-std=c99", "-Wall", "-O2")),
969
+ CompilerConfig(name="clang", args=("-std=c99", "-Wall", "-O2")),
970
+ CompilerConfig(name="cl", args=("/std:c99", "/O2")),
971
+ ])
972
+
973
+ # Qt-related keywords and dependencies for faster detection
974
+ _QT_DEPENDENCIES: frozenset[str] = frozenset((
975
+ "qt",
976
+ "pyside",
977
+ "pyqt",
978
+ "pyside2",
979
+ "pyside6",
980
+ "pyqt5",
981
+ "pyqt6",
982
+ "qt5",
983
+ "qt6",
984
+ ))
985
+ _GUI_KEYWORDS: frozenset[str] = frozenset(("gui", "desktop"))
785
986
 
786
- for compiler in compilers:
787
- if shutil.which(compiler) is not None:
788
- return compiler
789
987
 
988
+ def find_compiler() -> str | None:
989
+ """Find available C compiler."""
990
+ for compiler in ("gcc", "clang", "cl"):
991
+ if shutil.which(compiler):
992
+ return compiler
790
993
  logger.error("No compiler found")
791
994
  return None
792
995
 
793
996
 
794
- def get_compiler_args(
795
- compiler: str,
796
- ) -> list[str]:
797
- """Get the arguments for the specified compiler."""
997
+ def get_compiler_args(compiler: str) -> list[str]:
998
+ """Get compiler arguments for specified compiler.
999
+
1000
+ Args:
1001
+ compiler: Compiler name or path
1002
+
1003
+ Returns:
1004
+ List of compiler arguments
1005
+ """
798
1006
  compiler_name = Path(compiler).stem if "\\" in compiler or "/" in compiler else compiler
799
1007
 
800
- if compiler_name in ("gcc", "clang"):
801
- return ["-std=c99", "-Wall", "-pedantic", "-Werror"]
802
- elif compiler_name == "cl":
803
- return ["/std:c99", "/Wall", "/WX", "/Werror"]
804
- else:
805
- return []
1008
+ for config in _COMPILER_CONFIGS:
1009
+ if config.name == compiler_name:
1010
+ return config.to_list()
806
1011
 
1012
+ return []
807
1013
 
808
- def select_template(
1014
+
1015
+ def select_c_template(
809
1016
  loader_type: str,
810
1017
  is_debug: bool,
811
1018
  ) -> str:
@@ -840,7 +1047,33 @@ def select_template(
840
1047
  return _UNIX_CONSOLE_TEMPLATE
841
1048
 
842
1049
 
843
- def generate_c_source(
1050
+ def prepare_entry_file(entry_stem: str, is_qt: bool) -> str:
1051
+ """Generate entry file code by replacing placeholders in template.
1052
+
1053
+ Args:
1054
+ entry_stem: Entry file name without extension (e.g., "myapp" for "myapp.py" or "myapp.ent")
1055
+ is_qt: Whether this is a Qt application
1056
+
1057
+ Returns:
1058
+ Generated entry file code
1059
+ """
1060
+ entry_file_code = ENTRY_FILE_TEMPLATE.replace("$PROJECT_NAME", entry_stem)
1061
+
1062
+ # Add Qt configuration if needed
1063
+ if is_qt:
1064
+ qt_config = """# Qt configuration
1065
+ qt_dir = cwd / "site-packages" / "PySide2"
1066
+ plugin_path = str(qt_dir / "plugins" / "platforms")
1067
+ os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = plugin_path
1068
+
1069
+ """
1070
+ else:
1071
+ qt_config = ""
1072
+
1073
+ return entry_file_code.replace("$QT_CONFIG", qt_config)
1074
+
1075
+
1076
+ def prepare_c_source(
844
1077
  template: str,
845
1078
  entry_file: str,
846
1079
  is_debug: bool,
@@ -914,79 +1147,432 @@ def compile_c_source(
914
1147
  return False
915
1148
 
916
1149
 
917
- def main():
918
- parser = argparse.ArgumentParser(description="Generate a Python loader executable for current platform")
919
- parser.add_argument("--debug", "-d", action="store_true", help="Enable debug mode")
920
- parser.add_argument(
921
- "-t", "--type", choices=["gui", "console"], default="console", help="Loader type (default: console)"
922
- )
923
- parser.add_argument("-o", "--output", default="", help="Output executable path (default: pyloader or pyloader.exe)")
1150
+ def ensure_projects_json(directory: Path) -> Path | None:
1151
+ """Ensure projects.json exists by running projectparse if needed.
1152
+
1153
+ Args:
1154
+ directory: Directory to check for projects.json
1155
+
1156
+ Returns:
1157
+ Path to projects.json if successful, None otherwise
1158
+ """
1159
+ projects_json = directory / "projects.json"
1160
+ if projects_json.exists():
1161
+ logger.debug(f"Found existing projects.json at {projects_json}")
1162
+ return projects_json
1163
+
1164
+ logger.info("projects.json not found, running projectparse...")
1165
+
1166
+ try:
1167
+ import sys
1168
+
1169
+ from sfi.projectparse import projectparse as pp
1170
+
1171
+ original_argv = sys.argv.copy()
1172
+ sys.argv = ["projectparse", "--directory", str(directory), "--output", "projects.json", "--recursive"]
1173
+ try:
1174
+ pp.main()
1175
+ finally:
1176
+ sys.argv = original_argv
1177
+
1178
+ return projects_json if projects_json.exists() else None
1179
+ except ImportError:
1180
+ sfi_dir = Path(__file__).parent.parent.parent
1181
+ projectparse_script = sfi_dir / "projectparse" / "projectparse.py"
1182
+
1183
+ if not projectparse_script.exists():
1184
+ logger.error(f"Cannot find projectparse script at {projectparse_script}")
1185
+ return None
1186
+
1187
+ result = subprocess.run(
1188
+ [
1189
+ "python",
1190
+ str(projectparse_script),
1191
+ "--directory",
1192
+ str(directory),
1193
+ "--output",
1194
+ "projects.json",
1195
+ "--recursive",
1196
+ ],
1197
+ capture_output=True,
1198
+ text=True,
1199
+ cwd=directory,
1200
+ )
1201
+ if result.returncode == 0 and projects_json.exists():
1202
+ logger.info("projectparse completed successfully")
1203
+ return projects_json
1204
+ logger.error(f"projectparse failed: {result.stderr}")
1205
+ return None
1206
+ except Exception as e:
1207
+ logger.error(f"Failed to run projectparse: {e}")
1208
+ return None
1209
+
1210
+
1211
+ def load_projects_json(projects_json: Path) -> dict | None:
1212
+ """Load project information from projects.json.
1213
+
1214
+ Args:
1215
+ projects_json: Path to projects.json file
1216
+
1217
+ Returns:
1218
+ Dictionary of project information, None if failed
1219
+ """
1220
+ try:
1221
+ with open(projects_json, encoding="utf-8") as f:
1222
+ return json.load(f)
1223
+ except Exception as e:
1224
+ logger.error(f"Failed to load projects.json: {e}")
1225
+ return None
1226
+
1227
+
1228
+ def _detect_loader_type(project_info: ProjectInfo) -> tuple[str, bool]:
1229
+ """Detect loader type and Qt usage from project info.
1230
+
1231
+ Args:
1232
+ project_info: Project information dataclass
1233
+
1234
+ Returns:
1235
+ Tuple of (loader_type: str, use_qt: bool)
1236
+ """
1237
+ # Convert dependencies and keywords to frozenset for O(1) lookup
1238
+ deps_lower = frozenset(dep.lower() for dep in project_info.dependencies)
1239
+ keywords_lower = frozenset(kw.lower() for kw in project_info.keywords)
1240
+
1241
+ # Check for Qt dependencies using set intersection (O(min(n,m)) instead of O(n*m))
1242
+ use_qt = bool(deps_lower.intersection(_QT_DEPENDENCIES))
1243
+
1244
+ # Determine loader type: GUI if has Qt dependencies or GUI keywords
1245
+ loader_type = "gui" if use_qt or bool(keywords_lower.intersection(_GUI_KEYWORDS)) else "console"
1246
+ return loader_type, use_qt
1247
+
1248
+
1249
+ def _get_entry_module(project_info: ProjectInfo) -> str:
1250
+ """Get entry module from project info.
1251
+
1252
+ Args:
1253
+ project_info: Project information dataclass
1254
+
1255
+ Returns:
1256
+ Entry module name
1257
+ """
1258
+ if not project_info.scripts:
1259
+ return project_info.name.replace("-", "_")
1260
+ first_script = next(iter(project_info.scripts.values()))
1261
+ return first_script.split(":")[0].strip()
1262
+
1263
+
1264
+ def _is_entry_file(file_path: Path) -> bool:
1265
+ """Check if a Python file is an entry file.
1266
+
1267
+ An entry file must contain either:
1268
+ - def main() function
1269
+ - if __name__ == '__main__': block
1270
+
1271
+ Args:
1272
+ file_path: Path to Python file
1273
+
1274
+ Returns:
1275
+ True if file is an entry file, False otherwise
1276
+ """
1277
+ if not file_path.exists() or not file_path.is_file():
1278
+ return False
1279
+
1280
+ try:
1281
+ content = file_path.read_text(encoding="utf-8")
1282
+ # Check for def main() or def main(
1283
+ has_main_func = "def main(" in content or "def main():" in content
1284
+ # Check for if __name__ == '__main__':
1285
+ has_main_block = "if __name__ == '__main__':" in content or 'if __name__ == "__main__":' in content
1286
+ return has_main_func or has_main_block
1287
+ except Exception as e:
1288
+ logger.debug(f"Failed to read {file_path}: {e}")
1289
+ return False
1290
+
1291
+
1292
+ def _detect_entry_files(project_dir: Path, project_name: str, loader_type: str) -> list[tuple[str, str, bool]]:
1293
+ """Detect all entry files in project directory.
1294
+
1295
+ Args:
1296
+ project_dir: Project directory
1297
+ project_name: Project name
1298
+ loader_type: Base loader type from project info
1299
+
1300
+ Returns:
1301
+ List of tuples (entry_file_name, module_name, is_gui)
1302
+ e.g., [("docscan", "docscan", False), ("docscan-gui", "docscan_gui", True)]
1303
+ """
1304
+ normalized_name = project_name.replace("-", "_")
1305
+ entry_files = []
1306
+
1307
+ # Scan all Python files in project directory
1308
+ for py_file in project_dir.glob("*.py"):
1309
+ if not _is_entry_file(py_file):
1310
+ continue
1311
+
1312
+ # Get file stem (name without .py extension)
1313
+ file_stem = py_file.stem
1314
+
1315
+ # Determine if this is a GUI entry based on file name
1316
+ is_gui_file = file_stem.endswith("_gui")
1317
+
1318
+ # Generate entry name (use hyphen for GUI version)
1319
+ if is_gui_file:
1320
+ # e.g., "docscan_gui" -> "docscan-gui"
1321
+ base_name = file_stem.replace("_gui", "")
1322
+ entry_name = f"{base_name}-gui"
1323
+ else:
1324
+ entry_name = file_stem
1325
+
1326
+ # Module name is just the file stem
1327
+ module_name = file_stem
1328
+
1329
+ entry_files.append((entry_name, module_name, is_gui_file))
1330
+ logger.debug(f"Found entry file: {py_file.name} -> {entry_name} (GUI: {is_gui_file})")
1331
+
1332
+ # If no entry files found, fallback to default
1333
+ if not entry_files:
1334
+ logger.warning(f"No entry files found in {project_dir}, using default: {normalized_name}.py")
1335
+ entry_files.append((project_name, normalized_name, False))
1336
+
1337
+ return entry_files
1338
+
1339
+
1340
+ def generate_loader(
1341
+ project_name: str,
1342
+ projects_json_path: Path,
1343
+ is_debug: bool = False,
1344
+ compiler: str | None = None,
1345
+ ) -> bool:
1346
+ """Generate loader executable and entry file from projects.json.
1347
+
1348
+ Args:
1349
+ project_name: Name of the project to generate loader for
1350
+ projects_json_path: Path to projects.json file
1351
+ is_debug: Whether to generate debug version
1352
+ compiler: Optional compiler to use
1353
+
1354
+ Returns:
1355
+ True if successful, False otherwise
1356
+ """
1357
+ # Load projects.json
1358
+ projects_data = load_projects_json(projects_json_path)
1359
+ if not projects_data:
1360
+ logger.error("Failed to load projects.json")
1361
+ return False
1362
+
1363
+ # Check if project exists
1364
+ if project_name not in projects_data:
1365
+ logger.error(f"Project '{project_name}' not found in projects.json")
1366
+ return False
1367
+
1368
+ # Convert to ProjectInfo
1369
+ project_info = ProjectInfo.from_dict(project_name, projects_data[project_name])
1370
+ project_dir = projects_json_path.parent
1371
+
1372
+ return generate_loader_for_project(project_dir, project_info, is_debug, compiler)
1373
+
1374
+
1375
+ def _generate_single_loader(
1376
+ project_dir: Path,
1377
+ entry_name: str,
1378
+ entry_module: str,
1379
+ loader_type: str,
1380
+ use_qt: bool,
1381
+ is_debug: bool,
1382
+ compiler: str | None,
1383
+ ) -> bool:
1384
+ """Generate a single loader executable and entry file.
1385
+
1386
+ Args:
1387
+ project_dir: Project directory
1388
+ entry_name: Entry file name (e.g., "docscan", "docscan-gui")
1389
+ entry_module: Python module name (e.g., "docscan", "docscan_gui")
1390
+ loader_type: Loader type ("console" or "gui")
1391
+ use_qt: Whether this is a Qt application
1392
+ is_debug: Whether to generate debug version
1393
+ compiler: Optional compiler to use
1394
+
1395
+ Returns:
1396
+ True if successful, False otherwise
1397
+ """
1398
+ # Prepare directories
1399
+ build_dir = project_dir / ".cbuild"
1400
+ output_dir = project_dir / "dist"
1401
+ build_dir.mkdir(parents=True, exist_ok=True)
1402
+ output_dir.mkdir(parents=True, exist_ok=True)
1403
+
1404
+ # Output paths
1405
+ output_exe = output_dir / f"{entry_name}{ext}"
1406
+ c_source_filename = f"{entry_name}_debug_console.c" if is_debug else f"{entry_name}_{loader_type}.c"
1407
+ entry_filepath = output_dir / f"{entry_name}.ent"
1408
+ c_source_path = build_dir / c_source_filename
1409
+
1410
+ t0 = time.perf_counter()
1411
+
1412
+ # Generate entry file
1413
+ # Extract project name from entry_name (remove -gui suffix if present)
1414
+ project_name = entry_name.replace("-gui", "")
1415
+ entry_file_code = prepare_entry_file(project_name, use_qt)
1416
+ entry_file_code = entry_file_code.replace("$ENTRY_FILE", entry_module)
1417
+ entry_filepath.write_text(entry_file_code, encoding="utf-8")
1418
+ logger.info(f"Generated entry file: {entry_filepath}")
1419
+
1420
+ # Generate and compile C source
1421
+ c_template = select_c_template(loader_type, is_debug)
1422
+ c_code = prepare_c_source(c_template, entry_filepath.name, is_debug)
1423
+ c_source_path.write_text(c_code, encoding="utf-8")
1424
+ logger.info(f"Generated C source code: {c_source_path}")
1425
+
1426
+ success = compile_c_source(c_source_path, output_exe, compiler)
1427
+ if success:
1428
+ logger.info(f"Loader executable generated successfully: {output_exe}")
1429
+ logger.debug(f"Entry: {entry_name}")
1430
+ logger.debug(f"Module: {entry_module}")
1431
+ logger.debug(f"Type: {loader_type}")
1432
+ logger.debug(f"Debug mode: {is_debug}")
1433
+ logger.debug(f"Compilation time: {time.perf_counter() - t0:.4f} seconds")
1434
+ else:
1435
+ logger.error(f"Failed to compile loader for {entry_name}")
1436
+
1437
+ return success
1438
+
1439
+
1440
+ def generate_loader_for_project(
1441
+ project_dir: Path,
1442
+ project_info: ProjectInfo,
1443
+ is_debug: bool = False,
1444
+ compiler: str | None = None,
1445
+ ) -> bool:
1446
+ """Generate loader executables and entry files for a single project.
1447
+
1448
+ This function detects all entry files (main and GUI versions) and generates
1449
+ a loader for each one.
1450
+
1451
+ Args:
1452
+ project_dir: Directory containing the project (pyproject.toml location)
1453
+ project_info: Project information dataclass
1454
+ is_debug: Whether to generate debug version
1455
+ compiler: Optional compiler to use
1456
+
1457
+ Returns:
1458
+ True if all loaders generated successfully, False otherwise
1459
+ """
1460
+ logger.info(f"\n{'=' * 60}")
1461
+ logger.info(f"Generating loaders for project: {project_info.name}")
1462
+ logger.info(f"{'=' * 60}")
1463
+
1464
+ # Determine base loader type and Qt usage from project info
1465
+ base_loader_type, use_qt = _detect_loader_type(project_info)
1466
+
1467
+ # Detect all entry files in project directory
1468
+ entry_files = _detect_entry_files(project_dir, project_info.name, base_loader_type)
1469
+ logger.info(f"Found {len(entry_files)} entry file(s): {[e[0] for e in entry_files]}")
1470
+
1471
+ # Generate loader for each entry file
1472
+ all_success = True
1473
+ for entry_name, entry_module, is_gui in entry_files:
1474
+ logger.info(f"\n--- Generating loader for: {entry_name} ---")
1475
+
1476
+ # Use GUI loader type for GUI entries, otherwise use base type
1477
+ loader_type = "gui" if is_gui else base_loader_type
1478
+ logger.debug(f"Loader type: {loader_type}")
1479
+ logger.debug(f"Use Qt: {use_qt}")
1480
+ logger.debug(f"Entry module: {entry_module}")
1481
+
1482
+ success = _generate_single_loader(
1483
+ project_dir=project_dir,
1484
+ entry_name=entry_name,
1485
+ entry_module=entry_module,
1486
+ loader_type=loader_type,
1487
+ use_qt=use_qt,
1488
+ is_debug=is_debug,
1489
+ compiler=compiler,
1490
+ )
1491
+
1492
+ if not success:
1493
+ all_success = False
1494
+ logger.error(f"Failed to generate loader for {entry_name}")
1495
+
1496
+ return all_success
1497
+
1498
+
1499
+ def main() -> int:
1500
+ """Main entry point for pyloadergen.
1501
+
1502
+ Returns:
1503
+ Exit code (0 for success, 1 for failure)
1504
+ """
1505
+ parser = argparse.ArgumentParser(description="Generate Python loader executables for all projects in directory")
924
1506
  parser.add_argument(
925
- "-e", "--entry-file", default="main.py", help="Entry Python file path to execute (default: main.py)"
1507
+ "directory", nargs="?", default=str(cwd), help="Directory containing projects (will create/load projects.json)"
926
1508
  )
1509
+ parser.add_argument("--debug", "-d", action="store_true", help="Enable debug mode")
927
1510
  parser.add_argument(
928
1511
  "--compiler", help="Specify compiler to use. Examples: gcc, clang, cl, or full path like C:\\vc\\bin\\cl.exe"
929
1512
  )
930
- parser.add_argument("--run", "-r", action="store_true", help="Run the generated executable after compilation")
931
-
932
1513
  args = parser.parse_args()
933
1514
 
934
1515
  if args.debug:
935
1516
  logger.setLevel(logging.DEBUG)
936
1517
 
937
- # Determine default output file name based on platform and type
938
- output_filepath = cwd / f"pyloader{ext}" if not args.output else Path(args.output)
1518
+ base_dir = Path(args.directory)
1519
+ if not base_dir.is_dir():
1520
+ logger.error(f"Directory does not exist: {base_dir}")
1521
+ return 1
939
1522
 
940
- # Select appropriate template (in debug mode, always use console)
941
- template = select_template(args.type, args.debug)
1523
+ logger.info(f"Working directory: {base_dir}")
942
1524
 
943
- # Generate C source code
944
- c_code = generate_c_source(template, args.entry_file, args.debug)
1525
+ # Ensure projects.json exists and load projects
1526
+ projects_json = ensure_projects_json(base_dir)
1527
+ if not projects_json:
1528
+ logger.error("Failed to create projects.json")
1529
+ return 1
945
1530
 
946
- # Write C source code to file
947
- # File name should reflect actual template type, not just args.type
948
- # (debug mode forces console template)
949
- if args.debug:
950
- c_source_file = "pyloader_debug_console.c"
951
- elif args.type == "console":
952
- c_source_file = "pyloader_console.c"
953
- else: # gui type in non-debug mode
954
- c_source_file = "pyloader_gui.c"
1531
+ projects_data = load_projects_json(projects_json)
1532
+ if not projects_data:
1533
+ logger.error("Failed to load project information")
1534
+ return 1
955
1535
 
956
- c_source_path = Path(c_source_file)
957
- c_source_path.write_text(c_code, encoding="utf-8")
958
- logger.info(f"Generated C source code: {c_source_path}")
1536
+ # Convert dict to ProjectInfo dataclasses
1537
+ projects: dict[str, ProjectInfo] = {name: ProjectInfo.from_dict(name, data) for name, data in projects_data.items()}
959
1538
 
960
- t0 = time.perf_counter()
1539
+ logger.info(f"Found {len(projects)} project(s) to process")
961
1540
 
962
- # Compile to executable
963
- if compile_c_source(c_source_file, output_filepath, args.compiler):
964
- logger.info("Loader executable generated successfully!")
965
- logger.debug(f"Platform: {platform.system()}")
966
- logger.debug(f"Type: {args.type}")
967
- logger.debug(f"Debug mode: {args.debug}")
968
- logger.debug(f"Python entry file: {args.entry_file}")
969
- logger.debug(f"Output: {output_filepath}")
970
-
971
- # Run the executable if --run flag is set
972
- if args.run:
973
- logger.debug("\nRunning the executable...")
974
- import subprocess
975
-
976
- try:
977
- subprocess.run([str(Path(args.output))], check=True)
978
- except subprocess.CalledProcessError as e:
979
- logger.error(f"\nExecutable exited with code: {e.returncode}")
980
- return e.returncode
981
- except FileNotFoundError:
982
- logger.error(f"\nError: Executable not found at {args.output}")
983
- return 1
984
-
985
- logger.info(f"Compilation time: {time.perf_counter() - t0:.4f} seconds")
986
- return 0
987
- else:
988
- logger.error("\nFailed to compile loader executable")
989
- return 1
1541
+ # Generate loaders
1542
+ total_start = time.perf_counter()
1543
+ success_count = 0
1544
+ failed_projects: list[str] = []
1545
+ use_current_dir = len(projects) == 1
1546
+
1547
+ if use_current_dir:
1548
+ logger.debug("Only one project found, using current directory")
1549
+
1550
+ for project_name, project_info in projects.items():
1551
+ project_dir = base_dir if use_current_dir else base_dir / project_name
1552
+ if not project_dir.is_dir():
1553
+ logger.warning(f"Project directory not found: {project_dir}, skipping")
1554
+ failed_projects.append(project_name)
1555
+ continue
1556
+
1557
+ if generate_loader_for_project(project_dir, project_info, args.debug, args.compiler):
1558
+ success_count += 1
1559
+ else:
1560
+ failed_projects.append(project_name)
1561
+
1562
+ total_time = time.perf_counter() - total_start
1563
+
1564
+ # Print summary
1565
+ logger.info(f"\n{'=' * 60}")
1566
+ logger.info("Summary")
1567
+ logger.info(f"{'=' * 60}")
1568
+ logger.info(f"Total projects: {len(projects)}")
1569
+ logger.info(f"Successfully generated: {success_count}")
1570
+ logger.info(f"Failed: {len(failed_projects)}")
1571
+ if failed_projects:
1572
+ logger.info(f"Failed projects: {', '.join(failed_projects)}")
1573
+ logger.info(f"Total time: {total_time:.4f} seconds")
1574
+
1575
+ return 0 if not failed_projects else 1
990
1576
 
991
1577
 
992
1578
  if __name__ == "__main__":