pysfi 0.1.14__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pysfi
3
- Version: 0.1.14
3
+ Version: 0.1.15
4
4
  Summary: Single File commands for Interactive python.
5
5
  Requires-Python: >=3.8
6
6
  Requires-Dist: tomli>=2.4.0; python_version < '3.11'
@@ -1,8 +1,8 @@
1
- sfi/__init__.py,sha256=eGpIupcAG_5RqNCm908TwzDk-31Rhdn0iM-5Y2OwmIM,685
1
+ sfi/__init__.py,sha256=TVJQhNdT9_xTAmwbMdf5rw-JogSq0EdM2iR-igNIHLk,685
2
2
  sfi/cli.py,sha256=o-sFO6I3X3DMO2I_Gzc9LziWBdm2ruHXpjbraXGzZi0,933
3
3
  sfi/alarmclock/__init__.py,sha256=AUnUxITgFPYgSjn9ug2DEejrz5HDP56zZKYu-6JW1vY,72
4
4
  sfi/alarmclock/alarmclock.py,sha256=ixVkbg548smUivRsqyI3YSZ81BWIrKawnuezAp3BzyE,11635
5
- sfi/bumpversion/__init__.py,sha256=ExAxydqhwVCrA3YRid3JYult88HKsSTqYPt6996x0UU,122
5
+ sfi/bumpversion/__init__.py,sha256=yAaQaGUKnwPERhTaS4XXANQc9AT31nseY2dKomRPRmU,122
6
6
  sfi/bumpversion/bumpversion.py,sha256=Uxj0S8ZGmIRhfCx2ziWkVe5gHv74r68UPkiYzZcAwxo,21288
7
7
  sfi/cleanbuild/__init__.py,sha256=_bXrBcQkHQTE2484-UMWAWz8kq-2Yjn9hleLcjF-pJg,98
8
8
  sfi/cleanbuild/cleanbuild.py,sha256=Ilk4zBE48y6tUC3C5qp1pXdQ6CF0NZlra4ZbdiU5s2k,5270
@@ -10,7 +10,7 @@ sfi/condasetup/__init__.py,sha256=4W8VliAYUP1KY2gLJ_YDy2TmcXYVm-PY7XikQD_bFwA,2
10
10
  sfi/condasetup/condasetup.py,sha256=tuRzRi4NKmIfqN4Gq5w-VmY7BnleizPhAbYta8AzH4c,4976
11
11
  sfi/docdiff/__init__.py,sha256=4W8VliAYUP1KY2gLJ_YDy2TmcXYVm-PY7XikQD_bFwA,2
12
12
  sfi/docdiff/docdiff.py,sha256=YR8e-gkbvjH0Bjt5fVVW0-8b7lbinQs6TRo4B5XgEjE,7783
13
- sfi/docscan/__init__.py,sha256=eXv_iPeT_xOxv5lv4-axJQV28IISYYTqb6IRy3QTmIU,121
13
+ sfi/docscan/__init__.py,sha256=v7sLqtKfRfuxZ4bAKVvR5FBc_SX44W9MOBL_vmmAuss,121
14
14
  sfi/docscan/docscan.py,sha256=hXSOrfMikyg-Zsi_mpZvn31ea1J0un90ftwpRoXxNdA,42765
15
15
  sfi/docscan/docscan_gui.py,sha256=KoXVHOQjUwtkxh5tI4V6mLBMl9PAP3Hp7zydJ_lppoQ,52345
16
16
  sfi/docscan/lang/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -30,12 +30,14 @@ sfi/llmserver/__init__.py,sha256=4W8VliAYUP1KY2gLJ_YDy2TmcXYVm-PY7XikQD_bFwA,2
30
30
  sfi/llmserver/llmserver.py,sha256=Fm4Go7wif4xMGomMFDsyJnYMafXsWemGkr-VfaeYa6w,13530
31
31
  sfi/makepython/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
32
  sfi/makepython/makepython.py,sha256=GrzQTJyyB2PXugJK3yLxP1V5I_bSymW0qfkeqZvxRPA,43154
33
+ sfi/pdfcrypt/__init__.py,sha256=cKY_rosu4Qo0QvJF5d_vFgvMMcH14JH6uElOMRXo654,565
34
+ sfi/pdfcrypt/pdfcrypt.py,sha256=s7LMABQt4gW8UabuwdKdy_hgmSa8ZDePFrtab3tEkEA,13236
33
35
  sfi/pdfsplit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
36
  sfi/pdfsplit/pdfsplit.py,sha256=LOz7PuLlLBiXlFTjJej9G0UvJ03GZ9H-Gghy8ErvJzE,6576
35
37
  sfi/pyarchive/__init__.py,sha256=4W8VliAYUP1KY2gLJ_YDy2TmcXYVm-PY7XikQD_bFwA,2
36
- sfi/pyarchive/pyarchive.py,sha256=OnPbIRA0C9JdeyNsVZ6rJg7ExItKyJz4jVw5W4c92DA,38293
38
+ sfi/pyarchive/pyarchive.py,sha256=ne8FZh79_0ZtIWCG6Ddg9uUF6JxTvFsvfilOLl4wYmI,38280
37
39
  sfi/pyembedinstall/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
38
- sfi/pyembedinstall/pyembedinstall.py,sha256=LHnuvr63DXuntdS6a_7uQynOfarK-30WBUerSzawSHE,24171
40
+ sfi/pyembedinstall/pyembedinstall.py,sha256=1XOTR6Mg2YtP_-t9jRjiNGSSTw5TklDO60XPc3I9TL0,24158
39
41
  sfi/pylibpack/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
40
42
  sfi/pylibpack/pylibpack.py,sha256=AJbMY4aHJq0YhyUXryBargNqCUTeSwaUuc3SiCcjcko,57685
41
43
  sfi/pylibpack/rules/numpy.json,sha256=ee4gA5NBudFi3MaJA-QlBKQwiQAUb-eluF8HNVkl7Vk,384
@@ -45,13 +47,13 @@ sfi/pylibpack/rules/pyside2.json,sha256=uSSteT-3wDohWwQ36Z5mSOaSbxrR4565In4uZj_e
45
47
  sfi/pylibpack/rules/scipy.json,sha256=vTSi3W5BGWcwMkaDnyD6Yg7ijZdicPEUMw4fnRTnNf4,468
46
48
  sfi/pylibpack/rules/shiboken2.json,sha256=9Pl3eslvergyjlyHNknkyN0oZlcH3049WULe5WjsmKM,515
47
49
  sfi/pyloadergen/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
48
- sfi/pyloadergen/pyloadergen.py,sha256=VWJzc0opmMVthHE_RGbWeLe-DBaMn94gZo2yYLkf8cI,45696
50
+ sfi/pyloadergen/pyloadergen.py,sha256=NiQGk4I5LMNvEaMc9aasXKHGNSg127AcUR5hmDPm6sQ,45801
49
51
  sfi/pypack/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
50
- sfi/pypack/pypack.py,sha256=vDT_lVkq0eHeFCCR3pwjZGnF0EyQZDesM6ze--Tl194,22894
52
+ sfi/pypack/pypack.py,sha256=v2qdSGL4cANO28mbaJSGbThiyJG33djsTcVy9RlwDgc,23031
51
53
  sfi/pyprojectparse/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
52
- sfi/pyprojectparse/pyprojectparse.py,sha256=qBHj8l-_ONxD4VA5KvCfXdvS3fbjfOls9gRX83I0TDE,30641
54
+ sfi/pyprojectparse/pyprojectparse.py,sha256=XPY1E3mW6qkvuAlGBK8fpTfPQJYkWpQFcYZTZ4IlROk,29784
53
55
  sfi/pysourcepack/__init__.py,sha256=4W8VliAYUP1KY2gLJ_YDy2TmcXYVm-PY7XikQD_bFwA,2
54
- sfi/pysourcepack/pysourcepack.py,sha256=5_KrI2Y1TKKcoYfsFpTNXWguj6n8CKdca8lgoBCsL8k,12160
56
+ sfi/pysourcepack/pysourcepack.py,sha256=HNhO0Ry3kAzZJ5-bXlxn81jrh_GdjKzWFOyz7SQhN1Q,12158
55
57
  sfi/quizbase/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
56
58
  sfi/quizbase/quizbase.py,sha256=3tPUuYexZ9TVsNPPO_Itmr5OvyHSgY5OSUZwPoQt9zg,30605
57
59
  sfi/quizbase/quizbase_gui.py,sha256=m_Lj3au1a8gEv5x7KOTjomiP1NpXHUgHSPE4lLv63hY,34733
@@ -62,7 +64,7 @@ sfi/taskkill/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
62
64
  sfi/taskkill/taskkill.py,sha256=wM9g8sWJVTy4GxXe26rKdax2lIBI-uH9wP5wRenriH4,11606
63
65
  sfi/which/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
64
66
  sfi/which/which.py,sha256=2YbGgSiT1ySapKVV1ESoPf4P-JU8vvzmsZY39NiVr6k,2596
65
- pysfi-0.1.14.dist-info/METADATA,sha256=1fFxs9DSKrmVtp4oKAD5HWOQa9qV9hvxKSDyLNPTaKI,4198
66
- pysfi-0.1.14.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
67
- pysfi-0.1.14.dist-info/entry_points.txt,sha256=iPXOCvVm9O0_qaqFnlFFlwklWsK7K4LygikeAhnkf2I,1259
68
- pysfi-0.1.14.dist-info/RECORD,,
67
+ pysfi-0.1.15.dist-info/METADATA,sha256=LyUkSywzxLXVtmVvhzMuSKMxEo5EApdoyHk6q3eFcA0,4198
68
+ pysfi-0.1.15.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
69
+ pysfi-0.1.15.dist-info/entry_points.txt,sha256=j0zUSQnJ1YpYEKTK0oEICBjIyOAVZOd5Ss2_k0id530,1297
70
+ pysfi-0.1.15.dist-info/RECORD,,
@@ -14,6 +14,7 @@ llmqnt = sfi.llmquantize.llmquantize:main
14
14
  llmsvr = sfi.llmserver.llmserver:main
15
15
  makepython = sfi.makepython.makepython:main
16
16
  mkp = sfi.makepython.makepython:main
17
+ pdfcrypt = sfi.pdfcrypt.pdfcrypt:main
17
18
  pdfsplit = sfi.pdfsplit.pdfsplit:main
18
19
  pyarchive = sfi.pyarchive.pyarchive:main
19
20
  pyembedinstall = sfi.pyembedinstall.pyembedinstall:main
sfi/__init__.py CHANGED
@@ -16,5 +16,5 @@ or imported as a Python library.
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
- __version__ = "0.1.14"
19
+ __version__ = "0.1.15"
20
20
  __all__ = ["__version__"]
@@ -2,4 +2,4 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.1.14"
5
+ __version__ = "0.1.15"
sfi/docscan/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Document scanner module for scanning and extracting content from various document formats."""
2
2
 
3
- __version__ = "0.1.14"
3
+ __version__ = "0.1.15"
@@ -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()
@@ -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:
@@ -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..."
sfi/pypack/pypack.py CHANGED
@@ -229,6 +229,10 @@ class PackageWorkflow:
229
229
  """
230
230
  return self.solution.projects
231
231
 
232
+ @cached_property
233
+ def sorted_projects(self) -> dict[str, Project]:
234
+ return dict(sorted(self.projects.items()))
235
+
232
236
  @property
233
237
  def dist_dir(self) -> Path:
234
238
  """Get distribution directory path."""
@@ -403,7 +407,7 @@ class PackageWorkflow:
403
407
  def list_projects(self) -> None:
404
408
  """List all available projects."""
405
409
  logger.info(f"Listing projects in {self.root_dir}")
406
- for project in self.projects.values():
410
+ for project in self.sorted_projects.values():
407
411
  logger.info(f" - {project}")
408
412
 
409
413
  def _scan_executables(self) -> list[Path]:
@@ -457,7 +457,6 @@ class Solution:
457
457
 
458
458
  root_dir: Path
459
459
  projects: dict[str, Project]
460
- update: bool = False
461
460
  start_time: float = field(default_factory=time.perf_counter)
462
461
  time_stamp: datetime.datetime = field(default_factory=datetime.datetime.now)
463
462
 
@@ -466,7 +465,6 @@ class Solution:
466
465
  f"<Solution(\n"
467
466
  f" root_dir={self.root_dir!r},\n"
468
467
  f" projects: {len(self.projects)},\n"
469
- f" update={self.update!r},\n"
470
468
  f" time_used={self.elapsed_time:.4f}s,\n"
471
469
  f" timestamp={self.time_stamp!r}\n"
472
470
  f")>"
@@ -570,9 +568,7 @@ class Solution:
570
568
  return None
571
569
 
572
570
  @classmethod
573
- def from_toml_files(
574
- cls, root_dir: Path, toml_files: list[Path], update: bool = False
575
- ) -> Solution:
571
+ def from_toml_files(cls, root_dir: Path, toml_files: list[Path]) -> Solution:
576
572
  """Create a Solution instance by parsing multiple pyproject.toml files.
577
573
 
578
574
  Args:
@@ -620,12 +616,10 @@ class Solution:
620
616
 
621
617
  projects[project.name] = project
622
618
 
623
- return cls(root_dir=root_dir, projects=projects, update=update)
619
+ return cls(root_dir=root_dir, projects=projects)
624
620
 
625
621
  @classmethod
626
- def from_json_data(
627
- cls, root_dir: Path, json_data: dict[str, Any], update: bool = False
628
- ) -> Solution:
622
+ def from_json_data(cls, root_dir: Path, json_data: dict[str, Any]) -> Solution:
629
623
  """Create a Solution instance from JSON data.
630
624
 
631
625
  Args:
@@ -660,10 +654,10 @@ class Solution:
660
654
  except Exception as e:
661
655
  logger.error(f"Unknown error loading project data from JSON data: {e}")
662
656
 
663
- return cls(root_dir=root_dir, projects=projects, update=update)
657
+ return cls(root_dir=root_dir, projects=projects)
664
658
 
665
659
  @classmethod
666
- def from_json_file(cls, json_file: Path, update: bool = False) -> Solution:
660
+ def from_json_file(cls, json_file: Path) -> Solution:
667
661
  """Create a Solution instance from a JSON file.
668
662
 
669
663
  Args:
@@ -682,7 +676,7 @@ class Solution:
682
676
  with json_file.open("r", encoding="utf-8") as f:
683
677
  loaded_data = json.load(f)
684
678
  logger.debug(f"\t - Loaded project data from {json_file}")
685
- return cls.from_json_data(json_file.parent, loaded_data, update=update)
679
+ return cls.from_json_data(json_file.parent, loaded_data)
686
680
  except (OSError, json.JSONDecodeError, KeyError) as e:
687
681
  logger.error(f"Error loading project data from {json_file}: {e}")
688
682
  return cls(root_dir=json_file.parent, projects={})
@@ -691,7 +685,7 @@ class Solution:
691
685
  return cls(root_dir=json_file.parent, projects={})
692
686
 
693
687
  @classmethod
694
- def from_directory(cls, root_dir: Path, update: bool = False) -> Solution:
688
+ def from_directory(cls, root_dir: Path) -> Solution:
695
689
  """Create a Solution instance by scanning a directory for pyproject.toml files.
696
690
 
697
691
  This method recursively searches the given directory for pyproject.toml files,
@@ -709,13 +703,9 @@ class Solution:
709
703
  logger.error(f"Error: {root_dir} is not a directory")
710
704
  return cls(root_dir=root_dir, projects={})
711
705
 
712
- # Use walrus operator to avoid intermediate variable
713
- if not update and (project_json := root_dir / "projects.json").is_file():
714
- return cls.from_json_file(project_json, update=update)
715
-
716
706
  logger.debug(f"Parsing pyproject.toml in {root_dir}...")
717
707
  toml_files = list(root_dir.rglob("pyproject.toml"))
718
- return cls.from_toml_files(root_dir, toml_files, update=update)
708
+ return cls.from_toml_files(root_dir, toml_files)
719
709
 
720
710
  def _write_project_json(self):
721
711
  """Write the project data to a projects.json file for caching.
@@ -729,12 +719,6 @@ class Solution:
729
719
  """
730
720
  # Cache json_file reference to avoid repeated cached_property access
731
721
  json_file = self.json_file
732
- if json_file.exists() and not self.update:
733
- logger.info(
734
- f"\t - Skip write project data file {json_file}, already exists"
735
- )
736
- return
737
-
738
722
  try:
739
723
  # Pre-cache raw_data access to avoid repeated property access
740
724
  serializable_data = {
@@ -772,12 +756,6 @@ def create_parser() -> argparse.ArgumentParser:
772
756
  parser.add_argument(
773
757
  "--debug", "-d", action="store_true", help="Enable debug logging output"
774
758
  )
775
- parser.add_argument(
776
- "--update",
777
- "-u",
778
- action="store_true",
779
- help="Force update by re-parsing projects instead of using cache",
780
- )
781
759
  return parser
782
760
 
783
761
 
@@ -794,4 +772,4 @@ def main() -> None:
794
772
  if args.debug:
795
773
  logger.setLevel(logging.DEBUG)
796
774
 
797
- Solution.from_directory(Path(args.directory), update=args.update)
775
+ Solution.from_directory(Path(args.directory))
@@ -118,7 +118,6 @@ class ProjectPacker:
118
118
 
119
119
  parent: PySourcePacker
120
120
  project: Project
121
- project_name: str
122
121
  include_patterns: set[str]
123
122
  exclude_patterns: set[str]
124
123
 
@@ -130,16 +129,13 @@ class ProjectPacker:
130
129
  @cached_property
131
130
  def project_path(self) -> Path:
132
131
  """Get project directory path."""
133
- return (
134
- self.parent.root_dir
135
- if self.is_single_project
136
- else self.parent.root_dir / self.project_name
137
- )
132
+ # Use the actual directory from toml_path instead of project name
133
+ return self.project.toml_path.parent
138
134
 
139
135
  @cached_property
140
136
  def output_dir(self) -> Path:
141
137
  """Get output directory path."""
142
- return self.parent.root_dir / "dist" / "src" / self.project_name
138
+ return self.parent.root_dir / "dist" / "src" / self.project.normalized_name
143
139
 
144
140
  def validate_project_path(self) -> bool:
145
141
  """Validate project path exists and is a directory."""
@@ -159,14 +155,14 @@ class ProjectPacker:
159
155
  Returns:
160
156
  True if packing succeeded, False otherwise
161
157
  """
162
- logger.debug(f"Start packing project: {self.project_name}")
158
+ logger.debug(f"Start packing project: {self.project.normalized_name}")
163
159
 
164
- if not self.project_name:
160
+ if not self.project.normalized_name:
165
161
  logger.error("Project name cannot be empty")
166
162
  return False
167
163
 
168
164
  logger.debug(
169
- f"Project path: {self.project_path}, project_name: {self.project_name}"
165
+ f"Project path: {self.project_path}, project_name: {self.project.normalized_name}"
170
166
  )
171
167
  if not self.validate_project_path():
172
168
  return False
@@ -177,7 +173,9 @@ class ProjectPacker:
177
173
  copied_files = 0
178
174
  copied_dirs = 0
179
175
 
180
- logger.info(f"Packing project '{self.project_name}' to {self.output_dir}")
176
+ logger.info(
177
+ f"Packing project '{self.project.normalized_name}' to {self.output_dir}"
178
+ )
181
179
 
182
180
  try:
183
181
  for item in self.project_path.iterdir():
@@ -202,12 +200,12 @@ class ProjectPacker:
202
200
  copied_dirs += 1
203
201
 
204
202
  logger.info(
205
- f"Successfully packed {self.project_name}: {copied_files} files, {copied_dirs} directories"
203
+ f"Successfully packed {self.project.normalized_name}: {copied_files} files, {copied_dirs} directories"
206
204
  )
207
205
  return True
208
206
 
209
207
  except Exception as e:
210
- logger.error(f"Error packing project {self.project_name}: {e}")
208
+ logger.error(f"Error packing project {self.project.normalized_name}: {e}")
211
209
  return False
212
210
 
213
211
 
@@ -248,7 +246,6 @@ class PySourcePacker:
248
246
  packer = ProjectPacker(
249
247
  parent=self,
250
248
  project=project,
251
- project_name=project_name,
252
249
  include_patterns=self.include_patterns,
253
250
  exclude_patterns=self.exclude_patterns,
254
251
  )
File without changes