pysfi 0.1.13__py3-none-any.whl → 0.1.15__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,30 @@
1
+ """PDF encryption/decryption utilities.
2
+
3
+ This module provides functionality to encrypt and decrypt PDF files
4
+ using strong encryption algorithms. It supports batch processing
5
+ of multiple files with configurable passwords and security settings.
6
+ """
7
+
8
+ from .pdfcrypt import (
9
+ PDFCryptConfig,
10
+ conf,
11
+ decrypt,
12
+ decrypt_pdf,
13
+ encrypt,
14
+ encrypt_pdf,
15
+ is_encrypted,
16
+ main,
17
+ )
18
+
19
+ __all__ = [
20
+ "PDFCryptConfig",
21
+ "conf",
22
+ "decrypt",
23
+ "decrypt_pdf",
24
+ "encrypt",
25
+ "encrypt_pdf",
26
+ "is_encrypted",
27
+ "main",
28
+ ]
29
+
30
+ __version__ = "0.1.15"
@@ -0,0 +1,435 @@
1
+ """Function: Encrypt/decrypt all PDF files in the current directory.
2
+
3
+ This module provides functionality to encrypt and decrypt PDF files
4
+ using strong encryption algorithms. It supports batch processing
5
+ of multiple files with configurable passwords and security settings.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import atexit
12
+ import contextlib
13
+ import json
14
+ import logging
15
+ from concurrent.futures import ThreadPoolExecutor, as_completed
16
+ from dataclasses import dataclass
17
+ from functools import cached_property, partial
18
+ from pathlib import Path
19
+ from typing import Final
20
+
21
+ import pypdf
22
+
23
+ # Constants
24
+ MIN_PASSWORD_LENGTH: Final[int] = 6
25
+ CONFIG_FILE: Final[Path] = Path.home() / ".pysfi" / "pdfcrypt.json"
26
+ CWD: Final[Path] = Path.cwd()
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ __version__ = "1.0.0"
31
+ __build__ = "20260204"
32
+
33
+
34
+ @dataclass
35
+ class PDFCryptConfig:
36
+ """Configuration for PDF encryption/decryption operations."""
37
+
38
+ max_workers: int = 4
39
+ default_password: str = ""
40
+ output_suffix_encrypted: str = ".enc.pdf"
41
+ output_suffix_decrypted: str = ".dec.pdf"
42
+ _config_loaded: bool = False
43
+
44
+ def __post_init__(self) -> None:
45
+ """Initialize configuration and load from file if exists."""
46
+ if CONFIG_FILE.exists():
47
+ try:
48
+ config_data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
49
+ for key, value in config_data.items():
50
+ if hasattr(self, key):
51
+ setattr(self, key, value)
52
+ self._config_loaded = True
53
+ logger.info("Configuration loaded successfully")
54
+ except (json.JSONDecodeError, TypeError, AttributeError) as e:
55
+ logger.warning(f"Failed to load configuration: {e}")
56
+ logger.info("Using default configuration")
57
+ else:
58
+ logger.info("Using default configuration")
59
+
60
+ def save(self) -> None:
61
+ """Save current configuration to file."""
62
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
63
+ config_dict = {
64
+ "max_workers": self.max_workers,
65
+ "default_password": self.default_password,
66
+ "output_suffix_encrypted": self.output_suffix_encrypted,
67
+ "output_suffix_decrypted": self.output_suffix_decrypted,
68
+ }
69
+ CONFIG_FILE.write_text(json.dumps(config_dict, indent=4), encoding="utf-8")
70
+ logger.info("Configuration saved successfully")
71
+
72
+ @cached_property
73
+ def is_configured(self) -> bool:
74
+ """Check if configuration has been loaded from file."""
75
+ return self._config_loaded
76
+
77
+ @cached_property
78
+ def config_summary(self) -> dict[str, str | int]:
79
+ """Get configuration summary for display/logging purposes."""
80
+ return {
81
+ "max_workers": self.max_workers,
82
+ "output_suffix_encrypted": self.output_suffix_encrypted,
83
+ "output_suffix_decrypted": self.output_suffix_decrypted,
84
+ "source": "loaded" if self._config_loaded else "default",
85
+ }
86
+
87
+
88
+ # Global configuration instance
89
+ conf = PDFCryptConfig()
90
+ atexit.register(conf.save)
91
+
92
+
93
+ def is_encrypted(filepath: Path) -> bool:
94
+ """Check if a PDF file is encrypted.
95
+
96
+ Args:
97
+ filepath: Path to the PDF file
98
+
99
+ Returns:
100
+ bool: True if file is encrypted, False otherwise
101
+ """
102
+ try:
103
+ return pypdf.PdfReader(filepath).is_encrypted
104
+ except Exception:
105
+ logger.error(f"Failed to check encryption status for {filepath}")
106
+ return False
107
+
108
+
109
+ def _validate_password_strength(password: str) -> None:
110
+ """Validate password strength and log warnings if needed.
111
+
112
+ Args:
113
+ password: Password to validate
114
+ """
115
+ if len(password) < MIN_PASSWORD_LENGTH:
116
+ logger.warning(
117
+ f"Password length less than {MIN_PASSWORD_LENGTH} characters, "
118
+ f"recommend using a stronger password"
119
+ )
120
+
121
+
122
+ def _setup_pdf_writer(reader: pypdf.PdfReader) -> pypdf.PdfWriter:
123
+ """Create and configure PDF writer with pages from reader.
124
+
125
+ Args:
126
+ reader: Source PDF reader
127
+
128
+ Returns:
129
+ Configured PDF writer
130
+ """
131
+ writer = pypdf.PdfWriter()
132
+ for page in reader.pages:
133
+ writer.add_page(page)
134
+ return writer
135
+
136
+
137
+ def _handle_output_file_conflict(filepath: Path) -> None:
138
+ """Log warning if output file already exists.
139
+
140
+ Args:
141
+ filepath: Target file path
142
+ """
143
+ if filepath.exists():
144
+ logger.warning(f"Target file already exists, will overwrite: {filepath}")
145
+
146
+
147
+ def _cleanup_resources(reader=None, writer=None) -> None:
148
+ """Clean up PDF resources properly.
149
+
150
+ Args:
151
+ reader: PDF reader to close
152
+ writer: PDF writer to close
153
+ """
154
+ if reader:
155
+ with contextlib.suppress(Exception):
156
+ reader.stream.close()
157
+ # pypdf.PdfWriter has no explicit close method
158
+ if writer:
159
+ with contextlib.suppress(Exception):
160
+ writer.close()
161
+
162
+
163
+ def encrypt_pdf(
164
+ filepath: Path,
165
+ password: str,
166
+ ) -> tuple[Path, Path | None]:
167
+ """Encrypt a single PDF file.
168
+
169
+ Args:
170
+ filepath: Path to the PDF file to encrypt
171
+ password: Encryption password
172
+
173
+ Returns:
174
+ Tuple containing original file path and encrypted file path (if successful)
175
+ """
176
+ # Validate password strength
177
+ _validate_password_strength(password)
178
+
179
+ reader = None
180
+ writer = None
181
+ enc_pdf_file = filepath.with_suffix(conf.output_suffix_encrypted)
182
+
183
+ try:
184
+ # Use context manager to ensure resource cleanup
185
+ with filepath.open("rb") as input_file:
186
+ reader = pypdf.PdfReader(input_file)
187
+ writer = _setup_pdf_writer(reader)
188
+
189
+ writer.encrypt(
190
+ user_password=password,
191
+ owner_password=password,
192
+ use_128bit=True,
193
+ )
194
+
195
+ # Check if target file already exists
196
+ _handle_output_file_conflict(enc_pdf_file)
197
+
198
+ with enc_pdf_file.open("wb") as output_file:
199
+ writer.write(output_file)
200
+ except OSError:
201
+ logger.exception(f"Failed to write encrypted file [{enc_pdf_file.name}]")
202
+ return filepath, None
203
+ except Exception:
204
+ logger.exception(f"Error encrypting file [{filepath.name}]")
205
+ return filepath, None
206
+ else:
207
+ logger.info(f"Successfully encrypted file: {enc_pdf_file}")
208
+ return filepath, enc_pdf_file
209
+ finally:
210
+ # Explicitly clean up resources
211
+ _cleanup_resources(reader, writer)
212
+
213
+
214
+ def _attempt_pdf_decryption(
215
+ reader: pypdf.PdfReader, password: str, filepath: Path
216
+ ) -> bool:
217
+ """Attempt to decrypt PDF file and log results.
218
+
219
+ Args:
220
+ reader: PDF reader instance
221
+ password: Decryption password
222
+ filepath: Path to the PDF file
223
+
224
+ Returns:
225
+ True if decryption successful, False otherwise
226
+ """
227
+ if reader.decrypt(password):
228
+ logger.info(f"Successfully decrypted file [{filepath.name}]!")
229
+ return True
230
+ else:
231
+ logger.error(f"Failed to decrypt file [{filepath.name}], incorrect password.")
232
+ return False
233
+
234
+
235
+ def _copy_pdf_pages(reader: pypdf.PdfReader) -> pypdf.PdfWriter:
236
+ """Copy all pages from reader to a new writer.
237
+
238
+ Args:
239
+ reader: Source PDF reader
240
+
241
+ Returns:
242
+ PDF writer with copied pages
243
+ """
244
+ writer = pypdf.PdfWriter()
245
+ for page_num in range(len(reader.pages)):
246
+ page = reader.pages[page_num]
247
+ writer.add_page(page)
248
+ return writer
249
+
250
+
251
+ def decrypt_pdf(
252
+ filepath: Path,
253
+ password: str,
254
+ ) -> tuple[Path, Path | None]:
255
+ """Decrypt a PDF file.
256
+
257
+ Args:
258
+ filepath: Path to the PDF file to decrypt
259
+ password: Decryption password
260
+
261
+ Returns:
262
+ Tuple containing original file path and decrypted file path (if successful)
263
+ """
264
+ reader = None
265
+ writer = None
266
+
267
+ try:
268
+ # Open the input PDF file
269
+ with filepath.open("rb") as f:
270
+ reader = pypdf.PdfReader(f)
271
+
272
+ # Try to decrypt the file
273
+ if not _attempt_pdf_decryption(reader, password, filepath):
274
+ return filepath, None
275
+
276
+ # Create a new PdfWriter object and copy pages
277
+ writer = _copy_pdf_pages(reader)
278
+
279
+ # Write the decrypted PDF to output file
280
+ outfile = filepath.with_suffix(conf.output_suffix_decrypted)
281
+
282
+ # Check if target file already exists
283
+ _handle_output_file_conflict(outfile)
284
+
285
+ with outfile.open("wb") as output_file:
286
+ writer.write(output_file)
287
+ logger.info(f"Wrote decrypted file to [{outfile}]")
288
+ return filepath, outfile
289
+
290
+ except OSError:
291
+ logger.exception("Failed to write decrypted file")
292
+ return filepath, None
293
+ except Exception:
294
+ logger.exception(f"Error decrypting file [{filepath.name}]")
295
+ return filepath, None
296
+ finally:
297
+ # Explicitly clean up resources
298
+ _cleanup_resources(reader, writer)
299
+
300
+
301
+ def list_pdf() -> tuple[list[Path], list[Path]]:
302
+ """List PDF files in current directory.
303
+
304
+ Returns:
305
+ Tuple containing lists of unencrypted and encrypted PDF files
306
+ """
307
+ un_encrypted = [_ for _ in CWD.rglob("*.pdf") if not is_encrypted(_)]
308
+ encrypted = [_ for _ in CWD.rglob("*.pdf") if is_encrypted(_)]
309
+
310
+ logger.info(f"Encrypted files: [green bold]{encrypted}")
311
+ logger.info(f"Unencrypted files: [green bold]{un_encrypted}")
312
+ return un_encrypted, encrypted
313
+
314
+
315
+ def run_batch(func, files: list[Path]) -> None:
316
+ """Run function on multiple files concurrently.
317
+
318
+ Args:
319
+ func: Function to apply to each file
320
+ files: List of files to process
321
+ """
322
+ if not files:
323
+ logger.info("No files to process")
324
+ return
325
+
326
+ logger.info(f"Processing {len(files)} files...")
327
+
328
+ with ThreadPoolExecutor(max_workers=conf.max_workers) as executor:
329
+ # Submit all tasks
330
+ future_to_file = {executor.submit(func, file): file for file in files}
331
+
332
+ # Process completed tasks
333
+ for future in as_completed(future_to_file):
334
+ file = future_to_file[future]
335
+ try:
336
+ result = future.result()
337
+ if result[1]: # If processing was successful
338
+ logger.info(f"Completed: {file.name}")
339
+ else:
340
+ logger.error(f"Failed: {file.name}")
341
+ except Exception as e:
342
+ logger.error(f"Error processing {file.name}: {e}")
343
+
344
+
345
+ def decrypt(password: str) -> None:
346
+ """Execute decryption operation.
347
+
348
+ Args:
349
+ password: Decryption password
350
+ """
351
+ _, encrypted_files = list_pdf()
352
+ if not encrypted_files:
353
+ logger.error(f"No encrypted PDF files found in directory: {CWD}")
354
+ return
355
+
356
+ dec_func = partial(decrypt_pdf, password=password)
357
+ run_batch(dec_func, encrypted_files)
358
+
359
+
360
+ def encrypt(password: str) -> None:
361
+ """Execute encryption operation.
362
+
363
+ Args:
364
+ password: Encryption password
365
+ """
366
+ unencrypted_files, _ = list_pdf()
367
+ if not unencrypted_files:
368
+ logger.error(f"No unencrypted PDF files found in directory: {CWD}")
369
+ return
370
+
371
+ enc_func = partial(encrypt_pdf, password=password)
372
+ run_batch(enc_func, unencrypted_files)
373
+
374
+
375
+ def parse_args() -> argparse.Namespace:
376
+ """Parse command line arguments.
377
+
378
+ Returns:
379
+ Parsed arguments namespace
380
+ """
381
+ parser = argparse.ArgumentParser(
382
+ description="Encrypt/decrypt PDF files in current directory",
383
+ formatter_class=argparse.RawDescriptionHelpFormatter,
384
+ epilog="""
385
+ Examples:
386
+ pdfcrypt encrypt mypassword # Encrypt all unencrypted PDFs
387
+ pdfcrypt decrypt mypassword # Decrypt all encrypted PDFs
388
+ pdfcrypt --help # Show this help message
389
+ """,
390
+ )
391
+
392
+ parser.add_argument(
393
+ "action",
394
+ choices=["encrypt", "decrypt"],
395
+ help="Action to perform: encrypt or decrypt PDF files",
396
+ )
397
+
398
+ parser.add_argument("password", help="Password for encryption/decryption")
399
+
400
+ parser.add_argument(
401
+ "--workers",
402
+ type=int,
403
+ default=4,
404
+ help="Number of concurrent workers (default: 4)",
405
+ )
406
+
407
+ parser.add_argument(
408
+ "-v",
409
+ "--version",
410
+ action="version",
411
+ version=f"%(prog)s {__version__} (build {__build__})",
412
+ )
413
+
414
+ return parser.parse_args()
415
+
416
+
417
+ def main() -> None:
418
+ """Main entry point for the pdfcrypt CLI tool."""
419
+ args = parse_args()
420
+
421
+ # Update configuration
422
+ conf.max_workers = args.workers
423
+ conf.default_password = args.password
424
+
425
+ # Execute requested action
426
+ if args.action == "encrypt":
427
+ encrypt(args.password)
428
+ elif args.action == "decrypt":
429
+ decrypt(args.password)
430
+ else:
431
+ logger.error(f"Unknown action: {args.action}")
432
+
433
+
434
+ if __name__ == "__main__":
435
+ main()
sfi/pdfsplit/pdfsplit.py CHANGED
@@ -7,7 +7,6 @@ from pathlib import Path
7
7
  import fitz
8
8
 
9
9
  logging.basicConfig(level=logging.INFO, format="%(message)s")
10
- cwd = Path.cwd()
11
10
  logger = logging.getLogger(__name__)
12
11
 
13
12
 
@@ -50,7 +49,9 @@ def split_by_number(input_file: Path, output_file: Path, number: int) -> None:
50
49
 
51
50
  end_page = min(current_page + pages_in_this_part, total_pages)
52
51
 
53
- part_file = output_file.parent / f"{output_file.stem}_part{i + 1}{output_file.suffix}"
52
+ part_file = (
53
+ output_file.parent / f"{output_file.stem}_part{i + 1}{output_file.suffix}"
54
+ )
54
55
  part_doc = fitz.open()
55
56
 
56
57
  for page_num in range(current_page, end_page):
@@ -58,7 +59,9 @@ def split_by_number(input_file: Path, output_file: Path, number: int) -> None:
58
59
 
59
60
  part_doc.save(part_file)
60
61
  part_doc.close()
61
- logger.info(f"Created part {i + 1}: {part_file} (pages {current_page + 1}-{end_page})")
62
+ logger.info(
63
+ f"Created part {i + 1}: {part_file} (pages {current_page + 1}-{end_page})"
64
+ )
62
65
 
63
66
  current_page = end_page
64
67
 
@@ -77,7 +80,10 @@ def split_by_size(input_file: Path, output_file: Path, size: int) -> None:
77
80
 
78
81
  while start_page < total_pages:
79
82
  end_page = min(start_page + size, total_pages)
80
- part_file = output_file.parent / f"{output_file.stem}_part{part + 1}{output_file.suffix}"
83
+ part_file = (
84
+ output_file.parent
85
+ / f"{output_file.stem}_part{part + 1}{output_file.suffix}"
86
+ )
81
87
  part_doc = fitz.open()
82
88
 
83
89
  for page_num in range(start_page, end_page):
@@ -85,7 +91,9 @@ def split_by_size(input_file: Path, output_file: Path, size: int) -> None:
85
91
 
86
92
  part_doc.save(part_file)
87
93
  part_doc.close()
88
- logger.info(f"Created part {part + 1}: {part_file} (pages {start_page + 1}-{end_page})")
94
+ logger.info(
95
+ f"Created part {part + 1}: {part_file} (pages {start_page + 1}-{end_page})"
96
+ )
89
97
 
90
98
  start_page = end_page
91
99
  part += 1
@@ -122,18 +130,37 @@ def split_by_range(input_file: Path, output_file: Path, range_str: str) -> None:
122
130
 
123
131
 
124
132
  def main() -> None:
133
+ """Main entry point for pdfsplit CLI."""
125
134
  parser = argparse.ArgumentParser(description="Split PDF files")
126
135
  parser.add_argument("input", help="Input PDF file")
127
- parser.add_argument("output", nargs="?", help="Output PDF file (optional for -n and -s modes)")
128
- parser.add_argument("-o", "--output-dir", default=str(cwd), help="Output directory (default: current directory)")
129
- parser.add_argument("-f", "--output-format", help="Output file format pattern, e.g., 'split_{part:02d}.pdf'")
136
+ parser.add_argument(
137
+ "output", nargs="?", help="Output PDF file (optional for -n and -s modes)"
138
+ )
139
+ parser.add_argument(
140
+ "-o",
141
+ "--output-dir",
142
+ default=".",
143
+ help="Output directory (default: current directory)",
144
+ )
145
+ parser.add_argument(
146
+ "-f",
147
+ "--output-format",
148
+ help="Output file format pattern, e.g., 'split_{part:02d}.pdf'",
149
+ )
130
150
  parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
131
151
 
132
152
  # Split by number, size, or range
133
153
  group = parser.add_mutually_exclusive_group(required=True)
134
154
  group.add_argument("-n", "--number", type=int, help="Number of splits")
135
- group.add_argument("-s", "--size", type=int, default=1, help="Size of each split in pages")
136
- group.add_argument("-r", "--range", type=str, help="Range of pages to extract, e.g., '1,2,4-10,15-20,25-'")
155
+ group.add_argument(
156
+ "-s", "--size", type=int, default=1, help="Size of each split in pages"
157
+ )
158
+ group.add_argument(
159
+ "-r",
160
+ "--range",
161
+ type=str,
162
+ help="Range of pages to extract, e.g., '1,2,4-10,15-20,25-'",
163
+ )
137
164
 
138
165
  args = parser.parse_args()
139
166
 
@@ -142,7 +169,9 @@ def main() -> None:
142
169
 
143
170
  output_dir = Path(args.output_dir)
144
171
  if not output_dir.is_dir():
145
- logger.error(f"Output directory {args.output_dir} does not exist, please check the path.")
172
+ logger.error(
173
+ f"Output directory {args.output_dir} does not exist, please check the path."
174
+ )
146
175
  return
147
176
 
148
177
  input_file = Path(args.input)
@@ -157,7 +186,11 @@ def main() -> None:
157
186
  return
158
187
 
159
188
  if not args.range:
160
- output_file = output_dir / (input_file.stem + "_split.pdf") if not args.output else Path(args.output)
189
+ output_file = (
190
+ output_dir / (input_file.stem + "_split.pdf")
191
+ if not args.output
192
+ else Path(args.output)
193
+ )
161
194
  else:
162
195
  output_file = Path(args.output)
163
196
 
sfi/pyarchive/__init__.py CHANGED
@@ -1 +1 @@
1
-
1
+
@@ -312,7 +312,7 @@ class PyArchiver:
312
312
  @cached_property
313
313
  def solution(self) -> Solution:
314
314
  """Get the solution from the target directory."""
315
- return Solution.from_directory(self.root_dir, update=True)
315
+ return Solution.from_directory(self.root_dir)
316
316
 
317
317
  @cached_property
318
318
  def projects(self) -> dict[str, Project]:
@@ -456,7 +456,7 @@ class EmbedInstaller:
456
456
  @cached_property
457
457
  def solution(self) -> Solution:
458
458
  """Get the solution from the target directory."""
459
- return Solution.from_directory(self.root_dir, update=True)
459
+ return Solution.from_directory(self.root_dir)
460
460
 
461
461
  @cached_property
462
462
  def arch(self) -> str:
@@ -54,7 +54,7 @@ DEFAULT_MAX_WORKERS: Final[int] = 4
54
54
  DEFAULT_MIRROR: Final[str] = "aliyun"
55
55
  DEFAULT_OPTIMIZE: Final[bool] = True
56
56
 
57
- PYPI_MIRRORS = {
57
+ PYPI_MIRRORS: Final[dict[str, str]] = {
58
58
  "pypi": "https://pypi.org/simple",
59
59
  "tsinghua": "https://pypi.tuna.tsinghua.edu.cn/simple",
60
60
  "aliyun": "https://mirrors.aliyun.com/pypi/simple/",
@@ -915,14 +915,10 @@ class LibraryCache:
915
915
  @staticmethod
916
916
  def _should_skip_dist_info(file_path: Path) -> bool:
917
917
  """Check if the file path should be skipped because it's a dist-info directory."""
918
- name = file_path.name
919
- if name.endswith(".dist-info"):
918
+ if file_path.name.endswith(".dist-info"):
920
919
  return True
921
920
  # Check if any parent directory ends with .dist-info
922
- for part in file_path.parts:
923
- if part.endswith(".dist-info"):
924
- return True
925
- return False
921
+ return any(part.endswith(".dist-info") for part in file_path.parts)
926
922
 
927
923
  def clear_cache(self) -> None:
928
924
  """Clear all cached packages."""
@@ -1410,14 +1406,10 @@ class PyLibPacker:
1410
1406
  @staticmethod
1411
1407
  def _should_skip_dist_info(file_path: Path) -> bool:
1412
1408
  """Check if the file path should be skipped because it's a dist-info directory."""
1413
- name = file_path.name
1414
- if name.endswith(".dist-info"):
1409
+ if file_path.name.endswith(".dist-info"):
1415
1410
  return True
1416
1411
  # Check if any parent directory ends with .dist-info
1417
- for part in file_path.parts:
1418
- if part.endswith(".dist-info"):
1419
- return True
1420
- return False
1412
+ return any(part.endswith(".dist-info") for part in file_path.parts)
1421
1413
 
1422
1414
  def run(self) -> None:
1423
1415
  """Pack project dependencies from base directory with concurrent processing."""
@@ -967,8 +967,10 @@ class EntryFile:
967
967
  try:
968
968
  content = self.source_file.read_text(encoding="utf-8")
969
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)
970
+ return any(
971
+ f"from {kw}" in content.lower() or f"import {kw}" in content.lower()
972
+ for kw in gui_keywords
973
+ )
972
974
  except Exception:
973
975
  return False
974
976
 
@@ -1358,7 +1360,8 @@ class PyLoaderGenerator:
1358
1360
  # Prepare project tasks
1359
1361
  project_tasks = []
1360
1362
  for project_name, project in projects.items():
1361
- project_dir = self.root_dir / project_name
1363
+ # Use the actual directory from toml_path instead of project name
1364
+ project_dir = project.toml_path.parent
1362
1365
  if not project_dir.is_dir():
1363
1366
  logger.error(
1364
1367
  f"Project directory not found: {project_dir}, skipping..."