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,163 @@
1
+ """Convert between GUI profiles and configuration files."""
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Any
5
+
6
+ from ..utils import ByteUnit, TimeUnit, TransferMode, load_json, save_json
7
+ from .schemas import ConfigModel
8
+
9
+
10
+ def _combo_value(data: dict[str, Any], key: str, default: str = "") -> str:
11
+ val = data.get(key)
12
+ if isinstance(val, str):
13
+ return val
14
+
15
+ items = data.get(f"{key}_items")
16
+ if isinstance(val, int) and isinstance(items, Sequence) and 0 <= val < len(items):
17
+ return str(items[val])
18
+
19
+ return default
20
+
21
+
22
+ def _set_combo_value(profile: dict[str, Any], key: str, value: str, items: Sequence[str]) -> None:
23
+ items_list = [str(i) for i in items]
24
+ if value and value not in items_list:
25
+ items_list.append(value)
26
+
27
+ profile[f"{key}_items"] = items_list
28
+ profile[key] = items_list.index(value) if value in items_list else 0
29
+
30
+
31
+ def convert_profile_to_config(profile_data: dict[str, Any]) -> dict[str, Any]:
32
+ """Convert GUI profile data to configuration data."""
33
+ root = _combo_value(profile_data, "root_combo")
34
+ dest = _combo_value(profile_data, "dest_combo")
35
+
36
+ should_create = bool(profile_data.get("folder", False))
37
+ folder_count = int(profile_data.get("folder_count", 1)) if should_create else 1
38
+
39
+ data = {
40
+ "root": root,
41
+ "dest": dest,
42
+ "filecount": {
43
+ "count": int(profile_data.get("filecount_fixed_val", 0)),
44
+ "is_rand_enabled": bool(profile_data.get("filecount_rand_chk", False)),
45
+ "rand_min": int(profile_data.get("filecount_rand_min", 0)),
46
+ "rand_max": int(profile_data.get("filecount_rand_max", 0)),
47
+ },
48
+ "folder": {
49
+ "should_create": should_create,
50
+ "is_unique": bool(profile_data.get("folder_unique", True)),
51
+ "name": str(profile_data.get("folder_name", "")),
52
+ "count": folder_count,
53
+ },
54
+ "filename": {
55
+ "template": str(profile_data.get("filename_template", "{original}")) or "{original}",
56
+ },
57
+ "transfermode": {
58
+ "transfer_mode": _combo_value(profile_data, "transfermode_mode", TransferMode.SYMLINK),
59
+ },
60
+ "keyword": {
61
+ "is_enabled": bool(profile_data.get("keyword", False)),
62
+ "should_include": bool(profile_data.get("keyword_include", True)),
63
+ "text": str(profile_data.get("keyword_text", "")),
64
+ },
65
+ "extension": {
66
+ "is_enabled": bool(profile_data.get("extension", False)),
67
+ "should_include": bool(profile_data.get("extension_include", True)),
68
+ "text": str(profile_data.get("extension_text", "")),
69
+ },
70
+ "filesize": {
71
+ "is_enabled": bool(profile_data.get("filesize", False)),
72
+ "minimum": float(profile_data.get("filesize_minimum", 0.0)),
73
+ "maximum": float(profile_data.get("filesize_maximum", 0.0)),
74
+ "unit": _combo_value(profile_data, "filesize_unit"),
75
+ },
76
+ "duration": {
77
+ "is_enabled": bool(profile_data.get("duration", False)),
78
+ "minimum": float(profile_data.get("duration_minimum", 0.0)),
79
+ "maximum": float(profile_data.get("duration_maximum", 0.0)),
80
+ "unit": _combo_value(profile_data, "duration_unit"),
81
+ },
82
+ "folder_size_limit": {
83
+ "is_enabled": bool(profile_data.get("folder_size_limit", False)),
84
+ "size_limit": float(profile_data.get("folder_size_limit_size", 0.0)),
85
+ "unit": _combo_value(profile_data, "folder_size_limit_unit"),
86
+ },
87
+ "total_size_limit": {
88
+ "is_enabled": bool(profile_data.get("total_size_limit", False)),
89
+ "size_limit": float(profile_data.get("total_size_limit_size", 0.0)),
90
+ "unit": _combo_value(profile_data, "total_size_limit_unit"),
91
+ },
92
+ "options": {
93
+ "max_per_folder": int(profile_data.get("options_max_per_folder", 0)),
94
+ "should_follow_symlink": bool(profile_data.get("options_should_follow_symlink", False)),
95
+ "is_dry_run": bool(profile_data.get("options_dry_run", False)),
96
+ },
97
+ }
98
+
99
+ config = ConfigModel.model_validate(data)
100
+ return config.model_dump()
101
+
102
+
103
+ def convert_config_to_profile(config_data: dict[str, Any]) -> dict[str, Any]:
104
+ """Convert configuration data to GUI profile data."""
105
+ config = ConfigModel.model_validate(config_data)
106
+ data = config.model_dump()
107
+
108
+ profile: dict[str, Any] = {
109
+ "folder": data["folder"]["should_create"],
110
+ "filecount_fixed_val": data["filecount"]["count"],
111
+ "filecount_rand_chk": data["filecount"]["is_rand_enabled"],
112
+ "filecount_rand_min": data["filecount"]["rand_min"],
113
+ "filecount_rand_max": data["filecount"]["rand_max"],
114
+ "folder_count": data["folder"]["count"],
115
+ "folder_name": data["folder"]["name"],
116
+ "folder_unique": data["folder"]["is_unique"],
117
+ "filename_template": data["filename"]["template"],
118
+ "keyword": data["keyword"]["is_enabled"],
119
+ "keyword_include": data["keyword"]["should_include"],
120
+ "keyword_text": data["keyword"]["text"],
121
+ "extension": data["extension"]["is_enabled"],
122
+ "extension_include": data["extension"]["should_include"],
123
+ "extension_text": data["extension"]["text"],
124
+ "filesize": data["filesize"]["is_enabled"],
125
+ "filesize_minimum": data["filesize"]["minimum"],
126
+ "filesize_maximum": data["filesize"]["maximum"],
127
+ "duration": data["duration"]["is_enabled"],
128
+ "duration_minimum": data["duration"]["minimum"],
129
+ "duration_maximum": data["duration"]["maximum"],
130
+ "folder_size_limit": data["folder_size_limit"]["is_enabled"],
131
+ "folder_size_limit_size": data["folder_size_limit"]["size_limit"],
132
+ "total_size_limit": data["total_size_limit"]["is_enabled"],
133
+ "total_size_limit_size": data["total_size_limit"]["size_limit"],
134
+ "options_max_per_folder": data["options"]["max_per_folder"],
135
+ "options_should_follow_symlink": data["options"]["should_follow_symlink"],
136
+ "options_dry_run": data["options"]["is_dry_run"],
137
+ }
138
+
139
+ _set_combo_value(profile, "root_combo", data["root"], [data["root"]])
140
+ _set_combo_value(profile, "dest_combo", data["dest"], [data["dest"]])
141
+ _set_combo_value(profile, "filesize_unit", data["filesize"]["unit"], list(ByteUnit))
142
+ _set_combo_value(profile, "duration_unit", data["duration"]["unit"], list(TimeUnit))
143
+ _set_combo_value(profile, "folder_size_limit_unit", data["folder_size_limit"]["unit"], list(ByteUnit))
144
+ _set_combo_value(profile, "total_size_limit_unit", data["total_size_limit"]["unit"], list(ByteUnit))
145
+
146
+ transfer_modes = list(TransferMode)
147
+ _set_combo_value(profile, "transfermode_mode", data["transfermode"]["transfer_mode"], transfer_modes)
148
+
149
+ return profile
150
+
151
+
152
+ def profile_to_config_file(profile_path: str, config_path: str) -> None:
153
+ """Convert GUI profile JSON to configuration JSON."""
154
+ profile_data = load_json(profile_path)
155
+ config_data = convert_profile_to_config(profile_data)
156
+ save_json(config_path, config_data)
157
+
158
+
159
+ def config_to_profile_file(config_path: str, profile_path: str) -> None:
160
+ """Convert configuration JSON to GUI profile JSON."""
161
+ config_data = load_json(config_path)
162
+ profile_data = convert_config_to_profile(config_data)
163
+ save_json(profile_path, profile_data)
@@ -0,0 +1,96 @@
1
+ """Pydantic schemas for configuration."""
2
+
3
+ import os
4
+
5
+ from pydantic import BaseModel, field_validator
6
+
7
+ from ..utils import TransferMode
8
+
9
+
10
+ class FilecountModel(BaseModel):
11
+ """Model for file count configuration."""
12
+
13
+ count: int = 0
14
+ is_rand_enabled: bool = False
15
+ rand_min: int = 0
16
+ rand_max: int = 0
17
+
18
+
19
+ class DirectoryModel(BaseModel):
20
+ """Model for directory creation configuration."""
21
+
22
+ is_enabled: bool = False
23
+ is_unique: bool = True
24
+ name: str = ""
25
+ count: int = 1
26
+
27
+
28
+ class FilenameModel(BaseModel):
29
+ """Model for file renaming."""
30
+
31
+ template: str = "{original}"
32
+
33
+
34
+ class TransferModeModel(BaseModel):
35
+ """Model for mode configuration."""
36
+
37
+ transfer_mode: str = TransferMode.SYMLINK
38
+
39
+
40
+ class ListIncludeExcludeModel(BaseModel):
41
+ """Model for list filtering."""
42
+
43
+ is_enabled: bool = True
44
+ should_include: bool = True
45
+ text: str = ""
46
+
47
+
48
+ class MinMaxModel(BaseModel):
49
+ """Model for size filter."""
50
+
51
+ is_enabled: bool = False
52
+ minimum: float = 0.0
53
+ maximum: float = 0.0
54
+ unit: str = ""
55
+
56
+
57
+ class SizeLimitModel(BaseModel):
58
+ """Model for output folder size limits."""
59
+
60
+ is_enabled: bool = False
61
+ size_limit: float = 0.0
62
+ unit: str = ""
63
+
64
+
65
+ class OptionsModel(BaseModel):
66
+ """Model for additional options."""
67
+
68
+ max_per_folder: int = 0
69
+ should_follow_symlink: bool = False
70
+ is_dry_run: bool = True
71
+
72
+
73
+ class ConfigModel(BaseModel):
74
+ """Model for configuration."""
75
+
76
+ root: str
77
+ dest: str
78
+ filecount: FilecountModel
79
+ folder: DirectoryModel
80
+ filename: FilenameModel
81
+ transfermode: TransferModeModel
82
+ keyword: ListIncludeExcludeModel
83
+ extension: ListIncludeExcludeModel
84
+ filesize: MinMaxModel
85
+ duration: MinMaxModel
86
+ folder_size_limit: SizeLimitModel
87
+ total_size_limit: SizeLimitModel
88
+ options: OptionsModel
89
+
90
+ @field_validator("root", "dest")
91
+ @classmethod
92
+ def is_absolute(cls, val: str) -> str:
93
+ """Ensure root and dest paths are absolute."""
94
+ if not os.path.isabs(val):
95
+ return os.path.realpath(val)
96
+ return val
@@ -0,0 +1,20 @@
1
+ """Core package."""
2
+
3
+ from .builder import build_engine
4
+ from .engine import Engine
5
+ from .quota import DiversityQuota
6
+ from .reporter import ReportWriter
7
+ from .transfer import fetch_transfer_strategy, get_available_transfer_modes
8
+ from .validator import FileValidator
9
+ from .walker import FSWalker
10
+
11
+ __all__ = [
12
+ "DiversityQuota",
13
+ "Engine",
14
+ "FSWalker",
15
+ "FileValidator",
16
+ "ReportWriter",
17
+ "build_engine",
18
+ "fetch_transfer_strategy",
19
+ "get_available_transfer_modes",
20
+ ]
@@ -0,0 +1,92 @@
1
+ """Builder module for core functionality."""
2
+
3
+ from random import Random
4
+ from typing import TYPE_CHECKING
5
+
6
+ from ..config import Filecount, Filename, Folder, ListIncludeExclude, MinMax, SizeLimit
7
+ from ..utils import SIZE_MAP, TIME_MAP, DateTimeStamp, ReStrFmt
8
+ from .engine import Engine
9
+ from .quota import DiversityQuota
10
+ from .reporter import ReportWriter
11
+ from .state import EngineContext
12
+ from .transfer import fetch_transfer_strategy
13
+ from .validator import FileValidator
14
+ from .walker import PachinkoFSWalker
15
+
16
+ if TYPE_CHECKING:
17
+ from ..config import ConfigModel
18
+
19
+
20
+ def build_file_validator(m: ConfigModel) -> FileValidator:
21
+ """Build and return a FileValidator based on the configuration."""
22
+ keywords = ListIncludeExclude.from_model(m.keyword, re_fmt=ReStrFmt.KEYWORD)
23
+ extensions = ListIncludeExclude.from_model(m.extension, re_fmt=ReStrFmt.EXTENSION)
24
+ filesize = MinMax.from_model(m.filesize, mapping=SIZE_MAP)
25
+ duration = MinMax.from_model(m.duration, mapping=TIME_MAP)
26
+ return FileValidator(
27
+ keywords=keywords,
28
+ extensions=extensions,
29
+ filesize=filesize,
30
+ duration=duration,
31
+ )
32
+
33
+
34
+ def build_engine(m: ConfigModel) -> Engine:
35
+ """Build and return the engine based on the configuration."""
36
+ # Build main components
37
+ rng = Random()
38
+ dtstamp = DateTimeStamp()
39
+
40
+ # Build FileValidator
41
+ validator = build_file_validator(m)
42
+
43
+ # Build DiversityQuota
44
+ quota = DiversityQuota(
45
+ root=m.root,
46
+ is_unique=m.folder.is_unique,
47
+ max_per_dir=m.options.max_per_folder,
48
+ )
49
+
50
+ # Build EngineContext
51
+ folder = Folder.from_model(m.folder, dest=m.dest)
52
+ folder_size_limit = SizeLimit.from_model(m.folder_size_limit, mapping=SIZE_MAP)
53
+ total_size_limit = SizeLimit.from_model(m.total_size_limit, mapping=SIZE_MAP)
54
+ reporter = ReportWriter(
55
+ root=m.root,
56
+ exts_str=validator.extensions.as_string,
57
+ keys_str=validator.keywords.as_string,
58
+ dtstamp=dtstamp,
59
+ )
60
+ context = EngineContext(
61
+ folder=folder,
62
+ quota=quota,
63
+ folder_size_limit=folder_size_limit,
64
+ total_size_limit=total_size_limit,
65
+ reporter=reporter,
66
+ is_dry_run=m.options.is_dry_run,
67
+ dtstamp=dtstamp,
68
+ )
69
+
70
+ # Build Walker
71
+ walker = PachinkoFSWalker(
72
+ root=m.root,
73
+ quota=quota,
74
+ validator=validator,
75
+ rng=rng,
76
+ should_follow_symlink=m.options.should_follow_symlink,
77
+ )
78
+
79
+ # Build Engine
80
+ filecount = Filecount.from_model(m.filecount, rng=rng)
81
+ filename = Filename.from_model(m.filename, dtstamp=dtstamp)
82
+ do_transfer_strategy = fetch_transfer_strategy(m.transfermode.transfer_mode)
83
+ return Engine(
84
+ root=m.root,
85
+ walker=walker,
86
+ validator=validator,
87
+ filecount=filecount,
88
+ filename=filename,
89
+ do_transfer_strategy=do_transfer_strategy,
90
+ context=context,
91
+ folder_count=m.folder.count,
92
+ )
@@ -0,0 +1,129 @@
1
+ """Engine Module."""
2
+
3
+ import logging
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Callable, Iterator
10
+
11
+ from ..config import Filecount, Filename
12
+ from ..utils import Observer
13
+ from .state import Context
14
+ from .validator import FileValidator
15
+ from .walker import FSWalker
16
+
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class Engine:
23
+ """Core engine class."""
24
+
25
+ root: str
26
+ walker: FSWalker
27
+ validator: FileValidator
28
+ filecount: Filecount
29
+ filename: Filename
30
+ do_transfer_strategy: Callable[[os.PathLike, str], None]
31
+ context: Context
32
+ folder_count: int
33
+ observer: Observer = field(init=False)
34
+
35
+ def set_observer(self, observer: Observer) -> None:
36
+ """Set the observer for the engine."""
37
+ self.observer = observer
38
+
39
+ def request_stop(self) -> None:
40
+ """Request to stop the engine."""
41
+ self.context.is_stop_requested = True
42
+
43
+ def start(self) -> None:
44
+ """Run the main file copying process."""
45
+ self.observer.on_progress_total(self.folder_count)
46
+ for target, dest in self.get_transfer_parameters():
47
+ if not self.context.quota.is_unique:
48
+ self.walker.reset()
49
+ self.process_directory(target, dest)
50
+ self.observer.on_finished()
51
+
52
+ def get_transfer_parameters(self) -> Iterator[tuple[int, str]]:
53
+ """Get transfer parameters for all folders."""
54
+ for _ in range(self.folder_count):
55
+ yield (
56
+ self.filecount.get_file_count(),
57
+ self.context.folder.determine_dest_dirname(),
58
+ )
59
+
60
+ def process_directory(self, target: int, dest: str) -> None:
61
+ """Run processing for a single folder."""
62
+ os.mkdir(dest)
63
+ self.observer.on_progress(target)
64
+ self.context.prepare(dest)
65
+ self.transfer_directory(target, dest)
66
+ self.observer.on_count_total()
67
+ self.report(self.context.finalize(target, dest))
68
+
69
+ def transfer_directory(self, target: int, dest: str) -> None:
70
+ """Process a single folder for file copying."""
71
+ if self.context.should_stop(target):
72
+ self.report_state()
73
+ return
74
+
75
+ for entry in self.walker.walk():
76
+ if entry is None:
77
+ break
78
+
79
+ if self.context.should_stop(target):
80
+ self.report_state()
81
+ return
82
+
83
+ if not self.validator.is_valid_duration(entry):
84
+ continue
85
+
86
+ if not self.transfer_file(entry, dest):
87
+ continue
88
+
89
+ self.context.update_on_success(entry)
90
+ self.update_observer_on_entry()
91
+
92
+ def transfer_file(self, entry: os.PathLike, dest: str) -> bool:
93
+ """Attempt to copy a file and return success status."""
94
+ count = self.context.folderstats.count
95
+ chosen_rel = os.path.relpath(entry, self.root)
96
+ chosen_new = self.filename.determine_dest_filename(chosen_rel, dest, count)
97
+ if chosen_new is None:
98
+ return False
99
+
100
+ msg = f"{count + 1}: {chosen_rel} -> {os.path.relpath(chosen_new, dest)}"
101
+
102
+ if self.context.should_treat_as_dry_run(msg):
103
+ self.report_state()
104
+ return True
105
+
106
+ try:
107
+ self.do_transfer_strategy(entry, chosen_new)
108
+ except (PermissionError, OSError):
109
+ self.context.set_errored(msg)
110
+ self.report_state()
111
+ return False
112
+
113
+ self.context.set_transferred(msg)
114
+ self.report_state()
115
+ return True
116
+
117
+ def report_state(self) -> None:
118
+ """Report the current engine state."""
119
+ self.report(msg=self.context.state.message)
120
+
121
+ def report(self, msg: str) -> None:
122
+ """Report and log a message."""
123
+ self.observer.on_log(msg)
124
+ self.context.reporter.record(msg)
125
+
126
+ def update_observer_on_entry(self) -> None:
127
+ """Update observer with current entry statistics."""
128
+ self.observer.on_count(self.context.folderstats.count)
129
+ self.observer.on_time()
@@ -0,0 +1,46 @@
1
+ """Quota and State management."""
2
+
3
+ import logging
4
+ import os
5
+ from collections import Counter
6
+ from dataclasses import dataclass, field
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class DiversityQuota:
13
+ """Manages rules for diversity (weights) and uniqueness."""
14
+
15
+ root: str
16
+ is_unique: bool
17
+ max_per_dir: int
18
+ locked_dir: set[str] = field(default_factory=set)
19
+ dircount: Counter[str] = field(default_factory=Counter)
20
+
21
+ def reset(self) -> None:
22
+ """Reset batch-specific counters, optionally keeping file history."""
23
+ self.dircount.clear()
24
+ self.locked_dir.clear()
25
+
26
+ def is_all_locked(self) -> bool:
27
+ """Check if all files/folders are locked."""
28
+ return self.root in self.locked_dir
29
+
30
+ def is_dir_locked(self, directory: str) -> bool:
31
+ """Check if a folder is locked."""
32
+ return directory in self.locked_dir
33
+
34
+ def lock_dir(self, directory: str) -> None:
35
+ """Mark a folder as locked without registering a success."""
36
+ self.locked_dir.add(directory)
37
+
38
+ def register_success(self, entry: os.PathLike) -> None:
39
+ """Record a successful copy and apply locking rules."""
40
+ if self.max_per_dir <= 0:
41
+ return
42
+
43
+ leaf_dir = os.path.dirname(entry)
44
+ self.dircount[leaf_dir] += 1
45
+ if self.dircount[leaf_dir] >= self.max_per_dir:
46
+ self.lock_dir(leaf_dir)
@@ -0,0 +1,55 @@
1
+ """Reporter for process."""
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+
6
+ from ..utils import DateTimeStamp, convert_byte_to_human_readable_size
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class ReportWriter:
11
+ """ReportWriter class."""
12
+
13
+ root: str
14
+ exts_str: str
15
+ keys_str: str
16
+ dtstamp: DateTimeStamp
17
+ buffer: list[str] = field(default_factory=list)
18
+ report_path: str = field(init=False)
19
+ dest: str = field(init=False)
20
+
21
+ def reset(self, dest: str) -> None:
22
+ """Initialize reporter for a new run."""
23
+ self.buffer.clear()
24
+ self.report_path = os.path.join(dest, f"!_report_{os.path.basename(dest)}.txt")
25
+ self.dest = dest
26
+
27
+ def record(self, message: str) -> None:
28
+ """Add a message to the buffer."""
29
+ self.buffer.append(f"{message}\n")
30
+
31
+ def generate_report(self, status: str, runtime: float, size: int) -> str:
32
+ """Generate the header report string."""
33
+ return (
34
+ f"\n{status}"
35
+ "\n------------------------------------------------------------------------\n"
36
+ f"Date: {self.dtstamp.date_time_report_str}\n"
37
+ f"Root: {self.root}\n"
38
+ f"Destination: {self.dest}\n"
39
+ f"Extensions: {self.exts_str}\n"
40
+ f"Keywords: {self.keys_str}\n"
41
+ f"Total size: {convert_byte_to_human_readable_size(size)}\n"
42
+ f"Total runtime: {runtime}s\n"
43
+ "\n========================================================================\n"
44
+ "========================================================================\n"
45
+ "========================================================================\n"
46
+ "========================================================================\n"
47
+ "========================================================================\n"
48
+ )
49
+
50
+ def save(self) -> None:
51
+ """Save the report to file."""
52
+ mode = "a" if os.path.exists(self.report_path) else "w"
53
+ with open(self.report_path, mode=mode, encoding="utf-8") as f:
54
+ f.writelines(self.buffer)
55
+ f.write("\n\n")