fspachinko 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. fspachinko/__init__.py +6 -0
  2. fspachinko/_data/configs/fspachinko.json +60 -0
  3. fspachinko/_data/configs/logging.json +36 -0
  4. fspachinko/_data/icons/add_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
  5. fspachinko/_data/icons/close_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
  6. fspachinko/_data/icons/file_open_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
  7. fspachinko/_data/icons/folder_open_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
  8. fspachinko/_data/icons/icon.icns +0 -0
  9. fspachinko/_data/icons/icon.ico +0 -0
  10. fspachinko/_data/icons/play_arrow_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
  11. fspachinko/_data/icons/remove_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
  12. fspachinko/_data/icons/save_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
  13. fspachinko/_data/icons/save_as_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
  14. fspachinko/_data/icons/stop_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
  15. fspachinko/_data/icons/sync_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
  16. fspachinko/_data/icons/windowIcon.png +0 -0
  17. fspachinko/cli/__init__.py +1 -0
  18. fspachinko/cli/__main__.py +19 -0
  19. fspachinko/cli/app.py +62 -0
  20. fspachinko/cli/observer.py +37 -0
  21. fspachinko/config/__init__.py +39 -0
  22. fspachinko/config/config.py +213 -0
  23. fspachinko/config/converter.py +163 -0
  24. fspachinko/config/schemas.py +96 -0
  25. fspachinko/core/__init__.py +20 -0
  26. fspachinko/core/builder.py +92 -0
  27. fspachinko/core/engine.py +129 -0
  28. fspachinko/core/quota.py +46 -0
  29. fspachinko/core/reporter.py +55 -0
  30. fspachinko/core/state.py +300 -0
  31. fspachinko/core/transfer.py +100 -0
  32. fspachinko/core/validator.py +70 -0
  33. fspachinko/core/walker.py +184 -0
  34. fspachinko/gui/__init__.py +1 -0
  35. fspachinko/gui/__main__.py +43 -0
  36. fspachinko/gui/actions.py +68 -0
  37. fspachinko/gui/centralwidget.py +70 -0
  38. fspachinko/gui/components.py +581 -0
  39. fspachinko/gui/mainwindow.py +153 -0
  40. fspachinko/gui/observer.py +54 -0
  41. fspachinko/gui/qthelpers.py +102 -0
  42. fspachinko/gui/settings.py +53 -0
  43. fspachinko/gui/uibuilder.py +127 -0
  44. fspachinko/gui/workers.py +56 -0
  45. fspachinko/utils/__init__.py +89 -0
  46. fspachinko/utils/constants.py +212 -0
  47. fspachinko/utils/helpers.py +143 -0
  48. fspachinko/utils/interfaces.py +35 -0
  49. fspachinko/utils/loggers.py +16 -0
  50. fspachinko/utils/paths.py +33 -0
  51. fspachinko/utils/timestamp.py +29 -0
  52. fspachinko-0.0.2.dist-info/METADATA +322 -0
  53. fspachinko-0.0.2.dist-info/RECORD +56 -0
  54. fspachinko-0.0.2.dist-info/WHEEL +4 -0
  55. fspachinko-0.0.2.dist-info/entry_points.txt +5 -0
  56. fspachinko-0.0.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,300 @@
1
+ """Engine state classes."""
2
+
3
+ import logging
4
+ from abc import ABC, abstractmethod
5
+ from dataclasses import dataclass, field
6
+ from time import perf_counter
7
+ from typing import TYPE_CHECKING
8
+
9
+ from ..utils import DateTimeStamp, StateStatus, remove_directory
10
+
11
+ if TYPE_CHECKING:
12
+ from ..config import Folder, SizeLimit
13
+ from .quota import DiversityQuota
14
+ from .reporter import ReportWriter
15
+ from .walker import FSEntry
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class FolderStats:
22
+ """Dataclass for state."""
23
+
24
+ count: int = 0
25
+ starttime: float = 0.0
26
+ curr_size: int = 0
27
+ total_size: int = 0
28
+
29
+ def reset(self) -> None:
30
+ """Reset state variables for a new folder."""
31
+ self.count = 0
32
+ self.curr_size = 0
33
+ self.starttime = perf_counter()
34
+
35
+ def update(self, size: int) -> None:
36
+ """Update state on successful operation."""
37
+ self.count += 1
38
+ self.curr_size += size
39
+ self.total_size += size
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class Context(ABC):
44
+ """Abstract base class for engine state context."""
45
+
46
+ folder: Folder
47
+ quota: DiversityQuota
48
+ folder_size_limit: SizeLimit
49
+ total_size_limit: SizeLimit
50
+ reporter: ReportWriter
51
+ is_dry_run: bool
52
+ dtstamp: DateTimeStamp
53
+ is_stop_requested: bool = False
54
+ folderstats: FolderStats = field(default_factory=FolderStats)
55
+ _state: EngineState | None = None
56
+
57
+ @property
58
+ def state(self) -> EngineState:
59
+ """Get the current engine state."""
60
+ if self._state is None:
61
+ msg = "Engine state is not set."
62
+ raise ValueError(msg)
63
+ return self._state
64
+
65
+ @state.setter
66
+ def state(self, new_state: EngineState) -> None:
67
+ """Set a new engine state."""
68
+ self._state = new_state
69
+ self._state.context = self
70
+
71
+ @abstractmethod
72
+ def should_stop(self, target: int) -> bool:
73
+ """Check and update state before file validation."""
74
+
75
+ @abstractmethod
76
+ def is_none_found(self) -> bool:
77
+ """Check if no files were found in the current folder."""
78
+
79
+ @abstractmethod
80
+ def prepare(self, dest: str) -> None:
81
+ """Prepare the context for a new folder processing."""
82
+
83
+ @abstractmethod
84
+ def update_on_success(self, entry: FSEntry) -> None:
85
+ """Update context on successful file operation."""
86
+
87
+ @abstractmethod
88
+ def should_treat_as_dry_run(self, copy_path_str: str) -> bool:
89
+ """Check if a file has already been transferred."""
90
+
91
+ @abstractmethod
92
+ def set_errored(self, copy_path_str: str) -> None:
93
+ """Set the state to invalid file transfer."""
94
+
95
+ @abstractmethod
96
+ def set_transferred(self, copy_path_str: str) -> None:
97
+ """Set the state to successful file transfer."""
98
+
99
+ @abstractmethod
100
+ def finalize(self, target: int, dest: str) -> str:
101
+ """Finalize the context after processing."""
102
+
103
+
104
+ @dataclass(slots=True)
105
+ class EngineContext(Context):
106
+ """Class for engine state context."""
107
+
108
+ def should_stop(self, target: int) -> bool:
109
+ """Check and update state before file validation."""
110
+ if self.is_stop_requested:
111
+ self.state = UserStoppedState(
112
+ status=StateStatus.USER_STOPPED,
113
+ message="Stopped by user request",
114
+ )
115
+ return True
116
+
117
+ if self.folderstats.count == target:
118
+ self.state = SuccessState(
119
+ status=StateStatus.SUCCESS,
120
+ message=f"Copied {self.folderstats.count}/{target} files",
121
+ )
122
+ return True
123
+
124
+ if self.quota.is_all_locked():
125
+ self.state = AllSearched(
126
+ status=StateStatus.ALL_FILES_SEARCHED,
127
+ message="All files locked by diversity quota",
128
+ )
129
+ return True
130
+
131
+ if (
132
+ self.folder_size_limit.is_enabled
133
+ and self.folder_size_limit.size_limit > 0
134
+ and self.folder_size_limit.is_valid(self.folderstats.curr_size)
135
+ ):
136
+ self.state = FolderSizeLimitState(
137
+ status=StateStatus.FOLDER_SIZE_LIMIT_REACHED,
138
+ message=f"{(self.folderstats.curr_size)} B / {(self.folder_size_limit.size_limit)} B",
139
+ )
140
+ return True
141
+
142
+ if (
143
+ self.total_size_limit.is_enabled
144
+ and self.total_size_limit.size_limit > 0
145
+ and self.total_size_limit.is_valid(self.folderstats.total_size)
146
+ ):
147
+ self.state = TotalSizeLimitState(
148
+ status=StateStatus.TOTAL_SIZE_LIMIT_REACHED,
149
+ message=f"{(self.folderstats.total_size)} B / {(self.total_size_limit.size_limit)} B",
150
+ )
151
+ return True
152
+
153
+ return False
154
+
155
+ def is_none_found(self) -> bool:
156
+ """Check if no files were found in the current folder."""
157
+ none_found = self.folderstats.count == 0 and self.folder.is_enabled
158
+ if none_found:
159
+ if self.quota.is_all_locked():
160
+ self.state = NoFilesFoundAllSearchedState(
161
+ status=StateStatus.NO_FILES_FOUND_ALL_SEARCHED_FOLDER_DELETED,
162
+ message="No files found and all files locked by diversity quota",
163
+ )
164
+ return True
165
+
166
+ self.state = NoFilesFoundState(
167
+ status=StateStatus.NO_FILES_FOUND_FOLDER_DELETED,
168
+ message="No files found in the folder",
169
+ )
170
+ return True
171
+
172
+ return False
173
+
174
+ def prepare(self, dest: str) -> None:
175
+ """Prepare the context for a new folder processing."""
176
+ self.dtstamp.refresh()
177
+ self.folderstats.reset()
178
+ self.quota.reset()
179
+ self.reporter.reset(dest)
180
+
181
+ def update_on_success(self, entry: FSEntry) -> None:
182
+ """Update context on successful file operation."""
183
+ self.folderstats.update(entry.size)
184
+ self.quota.register_success(entry)
185
+
186
+ def should_treat_as_dry_run(self, copy_path_str: str) -> bool:
187
+ """Check if a file has already been transferred."""
188
+ if self.is_dry_run:
189
+ self.state = DryRunState(message=f"DRY - {copy_path_str}")
190
+ return True
191
+ return False
192
+
193
+ def set_errored(self, copy_path_str: str) -> None:
194
+ """Set the state to invalid file transfer."""
195
+ self.state = TransferErrorState(message=f"FAILED - {copy_path_str}")
196
+
197
+ def set_transferred(self, copy_path_str: str) -> None:
198
+ """Set the state to successful file transfer."""
199
+ self.state = TransferSuccessState(message=copy_path_str)
200
+
201
+ def finalize(self, target: int, dest: str) -> str:
202
+ """Finalize the context after processing."""
203
+ none_found = self.is_none_found()
204
+ report = self.reporter.generate_report(
205
+ status=f"{self.state.status}: {self.folderstats.count}/{target} files copied",
206
+ runtime=round(perf_counter() - self.folderstats.starttime, 2),
207
+ size=self.folderstats.curr_size,
208
+ )
209
+ self.reporter.save()
210
+
211
+ if none_found:
212
+ remove_directory(dest)
213
+
214
+ return report
215
+
216
+
217
+ @dataclass(slots=True)
218
+ class EngineState:
219
+ """Abstract base class for engine states."""
220
+
221
+ status: str = ""
222
+ message: str = ""
223
+ _context: Context | None = None
224
+
225
+ def __post_init__(self) -> None:
226
+ """Post-initialization tasks."""
227
+ logger.debug("Change state to: %s", self.__class__.__name__)
228
+
229
+ @property
230
+ def context(self) -> Context:
231
+ """Get the engine state context."""
232
+ if self._context is None:
233
+ msg = "Engine state context is not set."
234
+ raise ValueError(msg)
235
+ return self._context
236
+
237
+ @context.setter
238
+ def context(self, new_context: Context) -> None:
239
+ """Set a new engine state context."""
240
+ self._context = new_context
241
+
242
+
243
+ @dataclass(slots=True)
244
+ class RunningState(EngineState):
245
+ """State representing engine running."""
246
+
247
+
248
+ @dataclass(slots=True)
249
+ class DryRunState(RunningState):
250
+ """State representing engine running in dry-run mode."""
251
+
252
+
253
+ @dataclass(slots=True)
254
+ class TransferSuccessState(RunningState):
255
+ """State representing successful file transfer."""
256
+
257
+
258
+ @dataclass(slots=True)
259
+ class TransferErrorState(RunningState):
260
+ """State representing failed file transfer."""
261
+
262
+
263
+ @dataclass(slots=True)
264
+ class StoppedState(EngineState):
265
+ """State representing engine stopped due to an error."""
266
+
267
+
268
+ @dataclass(slots=True)
269
+ class SuccessState(StoppedState):
270
+ """State representing successful completion of folder processing."""
271
+
272
+
273
+ @dataclass(slots=True)
274
+ class UserStoppedState(StoppedState):
275
+ """State representing user-requested stop of processing."""
276
+
277
+
278
+ @dataclass(slots=True)
279
+ class NoFilesFoundAllSearchedState(StoppedState):
280
+ """State representing no files found in the folder and all files searched."""
281
+
282
+
283
+ @dataclass(slots=True)
284
+ class NoFilesFoundState(StoppedState):
285
+ """State representing no files found in the folder."""
286
+
287
+
288
+ @dataclass(slots=True)
289
+ class AllSearched(StoppedState):
290
+ """State representing all folders being searched."""
291
+
292
+
293
+ @dataclass(slots=True)
294
+ class FolderSizeLimitState(StoppedState):
295
+ """State representing folder size limit reached."""
296
+
297
+
298
+ @dataclass(slots=True)
299
+ class TotalSizeLimitState(StoppedState):
300
+ """State representing total size limit reached."""
@@ -0,0 +1,100 @@
1
+ """File transfer strategies."""
2
+
3
+ import os
4
+ import shutil
5
+ from io import UnsupportedOperation
6
+ from os import PathLike
7
+ from tempfile import TemporaryDirectory
8
+ from typing import TYPE_CHECKING
9
+
10
+ from ..utils import FileError, TransferMode
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Callable
14
+
15
+
16
+ def get_available_transfer_modes() -> tuple[TransferMode, ...]:
17
+ """Detect which transfer modes are supported on the current OS."""
18
+ # COPY and MOVE are always available
19
+ available = [TransferMode.COPY, TransferMode.COPY_PRESERVE, TransferMode.MOVE]
20
+
21
+ # SYMLINK availability
22
+ try:
23
+ with TemporaryDirectory() as tmpdir:
24
+ test_src = os.path.join(tmpdir, "test_src")
25
+ test_dst = os.path.join(tmpdir, "test_symlink")
26
+ open(test_src, "w").close()
27
+ os.symlink(test_src, test_dst)
28
+ os.unlink(test_dst)
29
+ os.unlink(test_src)
30
+ available.append(TransferMode.SYMLINK)
31
+ except (OSError, UnsupportedOperation, NotImplementedError):
32
+ pass
33
+
34
+ # HARDLINK availability
35
+ try:
36
+ with TemporaryDirectory() as tmpdir:
37
+ test_src = os.path.join(tmpdir, "test_src")
38
+ test_dst = os.path.join(tmpdir, "test_hardlink")
39
+ open(test_src, "w").close()
40
+ os.link(test_src, test_dst)
41
+ os.unlink(test_dst)
42
+ os.unlink(test_src)
43
+ available.append(TransferMode.HARDLINK)
44
+ except (OSError, UnsupportedOperation, NotImplementedError):
45
+ pass
46
+
47
+ return tuple(available)
48
+
49
+
50
+ def fetch_transfer_strategy(mode: str) -> Callable[[PathLike, str], None]:
51
+ """Return the appropriate transfer strategy instance.
52
+
53
+ Falls back to SYMLINK if the requested mode is not available.
54
+ """
55
+ strategy_map = {
56
+ TransferMode.COPY: transfer_copy,
57
+ TransferMode.COPY_PRESERVE: transfer_copy_preserve,
58
+ TransferMode.MOVE: transfer_move,
59
+ TransferMode.SYMLINK: transfer_symlink,
60
+ TransferMode.HARDLINK: transfer_hardlink,
61
+ }
62
+ available_modes = get_available_transfer_modes()
63
+ requested_mode = TransferMode(mode)
64
+ if requested_mode in available_modes:
65
+ return strategy_map[requested_mode]
66
+ return transfer_symlink
67
+
68
+
69
+ def transfer_copy(src: PathLike, dst: str) -> None:
70
+ """Copy a file from source to destination."""
71
+ shutil.copy(src, dst)
72
+
73
+
74
+ def transfer_copy_preserve(src: PathLike, dst: str) -> None:
75
+ """Copy a file from source to destination preserving metadata."""
76
+ shutil.copy2(src, dst)
77
+
78
+
79
+ def transfer_move(src: PathLike, dst: str) -> None:
80
+ """Move a file from source to destination."""
81
+ shutil.move(src, dst)
82
+
83
+
84
+ def transfer_symlink(src: PathLike, dst: str) -> None:
85
+ """Create a symlink from source to destination."""
86
+ os.symlink(src, dst)
87
+
88
+
89
+ def transfer_hardlink(src: PathLike, dst: str) -> None:
90
+ """Create a hardlink from source to destination.
91
+
92
+ Falls back to symlink if hardlinking across filesystems fails.
93
+ """
94
+ try:
95
+ os.link(src, dst)
96
+ except OSError as e:
97
+ if e.winerror == FileError.WINDOWS_CROSS_DRIVE_ERROR or e.errno == FileError.UNIX_CROSS_FILESYSTEM_ERROR:
98
+ os.symlink(src, dst)
99
+ else:
100
+ raise
@@ -0,0 +1,70 @@
1
+ """Config validation functions."""
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING
6
+
7
+ from ..utils import get_duration
8
+
9
+ if TYPE_CHECKING:
10
+ import os
11
+ from collections.abc import Callable
12
+
13
+ from ..config import ListIncludeExclude, MinMax
14
+ from .walker import FSEntry
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class FileValidator:
21
+ """Class for validating files based on configuration."""
22
+
23
+ keywords: ListIncludeExclude
24
+ extensions: ListIncludeExclude
25
+ filesize: MinMax
26
+ duration: MinMax
27
+ validators: tuple[Callable, ...] = ()
28
+
29
+ def __post_init__(self) -> None:
30
+ """Gather validation functions based on enabled filters."""
31
+ self._gather_validators()
32
+
33
+ def is_valid(self, entry: FSEntry) -> bool:
34
+ """Check if a file is valid based on the current filters."""
35
+ return all(is_valid(entry) for is_valid in self.validators)
36
+
37
+ def _gather_validators(self) -> None:
38
+ """Gather validation functions based on enabled filters."""
39
+ v = []
40
+
41
+ if self.filesize.is_enabled:
42
+ v.append(self._is_valid_filesize)
43
+
44
+ if self.extensions.is_enabled:
45
+ v.append(self._is_valid_extension)
46
+
47
+ if self.keywords.is_enabled:
48
+ v.append(self._is_valid_keyword)
49
+
50
+ self.validators = tuple(v)
51
+
52
+ def _is_valid_filesize(self, entry: FSEntry) -> bool:
53
+ """Check if a file is valid based on the current filters."""
54
+ return self.filesize.is_valid(entry.size)
55
+
56
+ def _is_valid_extension(self, entry: FSEntry) -> bool:
57
+ """Check if a file is valid based on the current filters."""
58
+ return self.extensions.is_valid(entry.ext)
59
+
60
+ def _is_valid_keyword(self, entry: FSEntry) -> bool:
61
+ """Check if a file is valid based on the current filters."""
62
+ return self.keywords.is_valid(entry.stem)
63
+
64
+ def is_valid_duration(self, entry: os.PathLike) -> bool:
65
+ """Check if a file is valid based on the current filters."""
66
+ if not self.duration.is_enabled:
67
+ return True
68
+
69
+ duration = get_duration(entry)
70
+ return self.duration.is_valid(duration)
@@ -0,0 +1,184 @@
1
+ """Random file system navigator."""
2
+
3
+ import logging
4
+ import os
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Iterator
11
+ from random import Random
12
+
13
+ from .quota import DiversityQuota
14
+ from .validator import FileValidator
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class FSWalker(ABC):
21
+ """Abstract file system walker."""
22
+
23
+ @abstractmethod
24
+ def reset(self) -> None:
25
+ """Reset the walker for a new batch."""
26
+
27
+ @abstractmethod
28
+ def walk(self) -> Iterator[FSEntry]:
29
+ """Generate candidates for a given directory."""
30
+
31
+
32
+ @dataclass(slots=True)
33
+ class FSEntry:
34
+ """Lightweight wrapper for os.DirEntry with only path and name."""
35
+
36
+ path: str
37
+ stem: str
38
+ ext: str
39
+ size: int
40
+
41
+ @classmethod
42
+ def from_direntry(cls, e: os.DirEntry) -> FSEntry:
43
+ """Create a lightweight FSEntry from an os.DirEntry."""
44
+ stem, ext = os.path.splitext(e.name)
45
+ return cls(
46
+ path=e.path,
47
+ stem=stem,
48
+ ext=ext,
49
+ size=e.stat().st_size,
50
+ )
51
+
52
+ def __hash__(self) -> int:
53
+ """Return the hash based on the file path."""
54
+ return hash(self.path)
55
+
56
+ def __fspath__(self) -> str:
57
+ """Return the file system path representation."""
58
+ return self.path
59
+
60
+
61
+ @dataclass(slots=True)
62
+ class FSPachinkoPin:
63
+ """Represents a 'pin' on the Pachinko board."""
64
+
65
+ path: str
66
+ subdirs: list[str] = field(default_factory=list)
67
+ files: list[FSEntry] = field(default_factory=list)
68
+ is_scanned: bool = False
69
+ is_exhausted: bool = False
70
+
71
+
72
+ @dataclass(slots=True)
73
+ class PachinkoFSWalker(FSWalker):
74
+ """Simulates a Pachinko machine.
75
+
76
+ For every file needed, we 'drop' a search cursor from the Root.
77
+ It bounces randomly down directory paths until it settles on a file.
78
+ """
79
+
80
+ root: str
81
+ quota: DiversityQuota
82
+ validator: FileValidator
83
+ rng: Random
84
+ should_follow_symlink: bool
85
+ board: dict[str, FSPachinkoPin] = field(default_factory=dict)
86
+
87
+ def __post_init__(self) -> None:
88
+ """Initialize the board with the root pin."""
89
+ self.reset()
90
+
91
+ def reset(self) -> None:
92
+ """Reset the walker and quota for a new batch."""
93
+ self.board.clear()
94
+ self.board[self.root] = FSPachinkoPin(path=self.root)
95
+
96
+ def walk(self) -> Iterator[FSEntry]:
97
+ """Continuously drop balls until the board is empty."""
98
+ while not self.board[self.root].is_exhausted:
99
+ if (entry := self.drop()) is not None:
100
+ yield entry
101
+
102
+ def drop(self) -> FSEntry | None:
103
+ """Drop a ball from the root."""
104
+ current_path = self.root
105
+
106
+ while True:
107
+ pin = self.board[current_path]
108
+
109
+ if pin.is_exhausted:
110
+ return None
111
+
112
+ if not pin.is_scanned:
113
+ self.scan(pin)
114
+
115
+ valid_subdirs = self.get_valid_subdirs(pin)
116
+ valid_files = pin.files
117
+
118
+ has_subdirs, has_files = bool(valid_subdirs), bool(valid_files)
119
+
120
+ if not (has_subdirs or has_files):
121
+ self.mark_exhausted(pin)
122
+ return None
123
+
124
+ if self.should_descend(has_subdirs=has_subdirs, has_files=has_files):
125
+ current_path = self.rng.choice(valid_subdirs)
126
+ continue
127
+
128
+ return pin.files.pop()
129
+
130
+ def get_valid_subdirs(self, pin: FSPachinkoPin) -> list[str]:
131
+ """Get valid subdirectories for a given pin."""
132
+ valid = [d for d in pin.subdirs if not self.quota.is_dir_locked(d) and not self.board[d].is_exhausted]
133
+ pin.subdirs = valid
134
+ return valid
135
+
136
+ def mark_exhausted(self, pin: FSPachinkoPin) -> None:
137
+ """Mark a pin and all its subdirs as exhausted."""
138
+ currpath = pin.path
139
+ pin.is_exhausted = True
140
+ self.quota.lock_dir(currpath)
141
+
142
+ def should_descend(self, *, has_subdirs: bool, has_files: bool) -> bool:
143
+ """Decide whether to descend into a subdir or select a file."""
144
+ if has_subdirs and has_files:
145
+ return self.rng.choice([True, False])
146
+ return has_subdirs
147
+
148
+ def scan(self, pin: FSPachinkoPin) -> None:
149
+ """Only look at the OS file system when a ball hits a specific folder for the first time."""
150
+ is_valid = self.validator.is_valid
151
+ subdirs = []
152
+ files = []
153
+ followlinks = self.should_follow_symlink
154
+
155
+ try:
156
+ with os.scandir(pin.path) as it:
157
+ for e in it:
158
+ try:
159
+ if e.is_symlink() and not followlinks:
160
+ continue
161
+
162
+ if e.is_dir(follow_symlinks=followlinks):
163
+ dirpath = e.path
164
+ subdirs.append(dirpath)
165
+ if dirpath not in self.board:
166
+ self.board[dirpath] = FSPachinkoPin(path=dirpath)
167
+ elif e.is_file(follow_symlinks=followlinks):
168
+ fsentry = FSEntry.from_direntry(e)
169
+ if is_valid(fsentry):
170
+ files.append(fsentry)
171
+ except OSError:
172
+ continue
173
+ except OSError:
174
+ pin.is_exhausted = True
175
+ return
176
+
177
+ if subdirs:
178
+ self.rng.shuffle(subdirs)
179
+ if files:
180
+ self.rng.shuffle(files)
181
+
182
+ pin.subdirs = subdirs
183
+ pin.files = files
184
+ pin.is_scanned = True
@@ -0,0 +1 @@
1
+ """GUI package."""
@@ -0,0 +1,43 @@
1
+ """Main entry point for GUI."""
2
+
3
+ import logging
4
+
5
+ from PySide6.QtCore import QCoreApplication
6
+ from PySide6.QtGui import QIcon
7
+ from PySide6.QtWidgets import QApplication
8
+ from qt_material import apply_stylesheet
9
+
10
+ from ..utils import AppSetting, IconFilename, Paths, initialize_logging
11
+ from .mainwindow import MainWindow
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ QCoreApplication.setOrganizationName(AppSetting.ORGANIZATION)
16
+ QCoreApplication.setOrganizationDomain(AppSetting.DOMAIN)
17
+ QCoreApplication.setApplicationName(AppSetting.APPLICATION)
18
+
19
+
20
+ try: # Windows only for taskbar icon
21
+ from ctypes import windll
22
+
23
+ myappid = "wonyoungjang.fspachinko.random_file_transfer_utility.0.0.1"
24
+ windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
25
+ except ImportError:
26
+ pass
27
+
28
+
29
+ def main() -> None:
30
+ """Run the application."""
31
+ initialize_logging()
32
+ logger.info("Start: fspachinko GUI")
33
+
34
+ app = QApplication()
35
+ app.setWindowIcon(QIcon(Paths.icon(IconFilename.WINDOW)))
36
+ apply_stylesheet(app, theme="dark_purple.xml", extra={"density_scale": "-2"})
37
+ w = MainWindow()
38
+ w.show()
39
+ app.exec()
40
+
41
+
42
+ if __name__ == "__main__":
43
+ main()