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.
- fspachinko/__init__.py +6 -0
- fspachinko/_data/configs/fspachinko.json +60 -0
- fspachinko/_data/configs/logging.json +36 -0
- fspachinko/_data/icons/add_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
- fspachinko/_data/icons/close_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
- fspachinko/_data/icons/file_open_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
- fspachinko/_data/icons/folder_open_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
- fspachinko/_data/icons/icon.icns +0 -0
- fspachinko/_data/icons/icon.ico +0 -0
- fspachinko/_data/icons/play_arrow_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
- fspachinko/_data/icons/remove_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
- fspachinko/_data/icons/save_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
- fspachinko/_data/icons/save_as_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
- fspachinko/_data/icons/stop_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
- fspachinko/_data/icons/sync_24dp_E3E3E3_FILL0_wght400_GRAD0_opsz24.svg +1 -0
- fspachinko/_data/icons/windowIcon.png +0 -0
- fspachinko/cli/__init__.py +1 -0
- fspachinko/cli/__main__.py +19 -0
- fspachinko/cli/app.py +62 -0
- fspachinko/cli/observer.py +37 -0
- fspachinko/config/__init__.py +39 -0
- fspachinko/config/config.py +213 -0
- fspachinko/config/converter.py +163 -0
- fspachinko/config/schemas.py +96 -0
- fspachinko/core/__init__.py +20 -0
- fspachinko/core/builder.py +92 -0
- fspachinko/core/engine.py +129 -0
- fspachinko/core/quota.py +46 -0
- fspachinko/core/reporter.py +55 -0
- fspachinko/core/state.py +300 -0
- fspachinko/core/transfer.py +100 -0
- fspachinko/core/validator.py +70 -0
- fspachinko/core/walker.py +184 -0
- fspachinko/gui/__init__.py +1 -0
- fspachinko/gui/__main__.py +43 -0
- fspachinko/gui/actions.py +68 -0
- fspachinko/gui/centralwidget.py +70 -0
- fspachinko/gui/components.py +581 -0
- fspachinko/gui/mainwindow.py +153 -0
- fspachinko/gui/observer.py +54 -0
- fspachinko/gui/qthelpers.py +102 -0
- fspachinko/gui/settings.py +53 -0
- fspachinko/gui/uibuilder.py +127 -0
- fspachinko/gui/workers.py +56 -0
- fspachinko/utils/__init__.py +89 -0
- fspachinko/utils/constants.py +212 -0
- fspachinko/utils/helpers.py +143 -0
- fspachinko/utils/interfaces.py +35 -0
- fspachinko/utils/loggers.py +16 -0
- fspachinko/utils/paths.py +33 -0
- fspachinko/utils/timestamp.py +29 -0
- fspachinko-0.0.2.dist-info/METADATA +322 -0
- fspachinko-0.0.2.dist-info/RECORD +56 -0
- fspachinko-0.0.2.dist-info/WHEEL +4 -0
- fspachinko-0.0.2.dist-info/entry_points.txt +5 -0
- 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()
|
fspachinko/core/quota.py
ADDED
|
@@ -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")
|