absfuyu 5.6.1__py3-none-any.whl → 6.1.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.
Potentially problematic release.
This version of absfuyu might be problematic. Click here for more details.
- absfuyu/__init__.py +5 -3
- absfuyu/__main__.py +2 -2
- absfuyu/cli/__init__.py +13 -2
- absfuyu/cli/audio_group.py +98 -0
- absfuyu/cli/color.py +2 -2
- absfuyu/cli/config_group.py +2 -2
- absfuyu/cli/do_group.py +2 -2
- absfuyu/cli/game_group.py +20 -2
- absfuyu/cli/tool_group.py +68 -4
- absfuyu/config/__init__.py +3 -3
- absfuyu/core/__init__.py +10 -6
- absfuyu/core/baseclass.py +104 -34
- absfuyu/core/baseclass2.py +43 -2
- absfuyu/core/decorator.py +2 -2
- absfuyu/core/docstring.py +4 -2
- absfuyu/core/dummy_cli.py +3 -3
- absfuyu/core/dummy_func.py +2 -2
- absfuyu/dxt/__init__.py +2 -2
- absfuyu/dxt/base_type.py +93 -0
- absfuyu/dxt/dictext.py +188 -6
- absfuyu/dxt/dxt_support.py +2 -2
- absfuyu/dxt/intext.py +72 -4
- absfuyu/dxt/listext.py +495 -23
- absfuyu/dxt/strext.py +2 -2
- absfuyu/extra/__init__.py +2 -2
- absfuyu/extra/audio/__init__.py +8 -0
- absfuyu/extra/audio/_util.py +57 -0
- absfuyu/extra/audio/convert.py +192 -0
- absfuyu/extra/audio/lossless.py +281 -0
- absfuyu/extra/beautiful.py +2 -2
- absfuyu/extra/da/__init__.py +39 -3
- absfuyu/extra/da/dadf.py +436 -29
- absfuyu/extra/da/dadf_base.py +2 -2
- absfuyu/extra/da/df_func.py +89 -5
- absfuyu/extra/da/mplt.py +2 -2
- absfuyu/extra/ggapi/__init__.py +8 -0
- absfuyu/extra/ggapi/gdrive.py +223 -0
- absfuyu/extra/ggapi/glicense.py +148 -0
- absfuyu/extra/ggapi/glicense_df.py +186 -0
- absfuyu/extra/ggapi/gsheet.py +88 -0
- absfuyu/extra/img/__init__.py +30 -0
- absfuyu/extra/img/converter.py +402 -0
- absfuyu/extra/img/dup_check.py +291 -0
- absfuyu/extra/pdf.py +4 -6
- absfuyu/extra/rclone.py +253 -0
- absfuyu/extra/xml.py +90 -0
- absfuyu/fun/__init__.py +2 -20
- absfuyu/fun/rubik.py +2 -2
- absfuyu/fun/tarot.py +2 -2
- absfuyu/game/__init__.py +2 -2
- absfuyu/game/game_stat.py +2 -2
- absfuyu/game/schulte.py +78 -0
- absfuyu/game/sudoku.py +2 -2
- absfuyu/game/tictactoe.py +2 -2
- absfuyu/game/wordle.py +6 -4
- absfuyu/general/__init__.py +2 -2
- absfuyu/general/content.py +2 -2
- absfuyu/general/human.py +2 -2
- absfuyu/general/resrel.py +213 -0
- absfuyu/general/shape.py +3 -8
- absfuyu/general/tax.py +344 -0
- absfuyu/logger.py +806 -59
- absfuyu/numbers/__init__.py +13 -0
- absfuyu/numbers/number_to_word.py +321 -0
- absfuyu/numbers/shorten_number.py +303 -0
- absfuyu/numbers/time_duration.py +217 -0
- absfuyu/pkg_data/__init__.py +2 -2
- absfuyu/pkg_data/deprecated.py +2 -2
- absfuyu/pkg_data/logo.py +1462 -0
- absfuyu/sort.py +4 -4
- absfuyu/tools/__init__.py +2 -2
- absfuyu/tools/checksum.py +119 -4
- absfuyu/tools/converter.py +2 -2
- absfuyu/tools/generator.py +24 -7
- absfuyu/tools/inspector.py +2 -2
- absfuyu/tools/keygen.py +2 -2
- absfuyu/tools/obfuscator.py +2 -2
- absfuyu/tools/passwordlib.py +2 -2
- absfuyu/tools/shutdownizer.py +3 -8
- absfuyu/tools/sw.py +213 -10
- absfuyu/tools/web.py +10 -13
- absfuyu/typings.py +5 -8
- absfuyu/util/__init__.py +31 -2
- absfuyu/util/api.py +7 -4
- absfuyu/util/cli.py +119 -0
- absfuyu/util/gui.py +91 -0
- absfuyu/util/json_method.py +2 -2
- absfuyu/util/lunar.py +2 -2
- absfuyu/util/package.py +124 -0
- absfuyu/util/path.py +313 -4
- absfuyu/util/performance.py +2 -2
- absfuyu/util/shorten_number.py +206 -13
- absfuyu/util/text_table.py +2 -2
- absfuyu/util/zipped.py +2 -2
- absfuyu/version.py +22 -19
- {absfuyu-5.6.1.dist-info → absfuyu-6.1.2.dist-info}/METADATA +37 -8
- absfuyu-6.1.2.dist-info/RECORD +105 -0
- {absfuyu-5.6.1.dist-info → absfuyu-6.1.2.dist-info}/WHEEL +1 -1
- absfuyu/extra/data_analysis.py +0 -21
- absfuyu-5.6.1.dist-info/RECORD +0 -79
- {absfuyu-5.6.1.dist-info → absfuyu-6.1.2.dist-info}/entry_points.txt +0 -0
- {absfuyu-5.6.1.dist-info → absfuyu-6.1.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Absfuyu: Image duplicate checker
|
|
3
|
+
--------------------------------
|
|
4
|
+
Image duplicate checker
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
Version: 6.1.1
|
|
8
|
+
Date updated: 30/12/2025 (dd/mm/yyyy)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# Module level
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
__all__ = ["DirectoryRemoveDuplicateImageMixin"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Library
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from enum import StrEnum
|
|
21
|
+
from functools import partial, total_ordering
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Literal, NamedTuple
|
|
24
|
+
|
|
25
|
+
from absfuyu.core.dummy_func import tqdm as tqdm_base
|
|
26
|
+
from absfuyu.tools.checksum import DirectoryRemoveDuplicateMixin, DuplicateSummary
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
import imagehash
|
|
30
|
+
from PIL import Image
|
|
31
|
+
except ImportError:
|
|
32
|
+
from subprocess import run
|
|
33
|
+
|
|
34
|
+
from absfuyu.config import ABSFUYU_CONFIG
|
|
35
|
+
|
|
36
|
+
if ABSFUYU_CONFIG._get_setting("auto-install-extra").value: # type: ignore
|
|
37
|
+
cmd = "python -m pip install -U absfuyu[pic]".split()
|
|
38
|
+
run(cmd)
|
|
39
|
+
else:
|
|
40
|
+
raise SystemExit("This feature is in absfuyu[pic] package") # noqa: B904
|
|
41
|
+
|
|
42
|
+
# Setup
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
tqdm = partial(tqdm_base, unit_scale=True, dynamic_ncols=True)
|
|
45
|
+
SupportedImageFormat = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Class
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
class HashMode(StrEnum):
|
|
51
|
+
PERCEPTUAL_HASH = "phash"
|
|
52
|
+
AVERAGE_HASH = "ahash"
|
|
53
|
+
DIFFERENCE_HASH = "dhash"
|
|
54
|
+
WAVELET_HASH = "whash"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DuplicateImgPair(NamedTuple):
|
|
58
|
+
"""
|
|
59
|
+
Duplicate image pair
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
original : Path
|
|
64
|
+
Original image path
|
|
65
|
+
|
|
66
|
+
duplicate : Path
|
|
67
|
+
Duplicate image path
|
|
68
|
+
|
|
69
|
+
distant : int
|
|
70
|
+
Similarity between image (0 is exact)
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
original: Path
|
|
74
|
+
duplicate: Path
|
|
75
|
+
distant: int
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@total_ordering
|
|
79
|
+
@dataclass
|
|
80
|
+
class ImageInfo:
|
|
81
|
+
"""
|
|
82
|
+
Quick image info
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
path : Path
|
|
87
|
+
Image path
|
|
88
|
+
|
|
89
|
+
file_size : int
|
|
90
|
+
File size
|
|
91
|
+
|
|
92
|
+
dimension : tuple[int, int]
|
|
93
|
+
Dimension (width, height)
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
path: Path
|
|
97
|
+
file_size: int
|
|
98
|
+
dimension: tuple[int, int]
|
|
99
|
+
|
|
100
|
+
def __eq__(self, other) -> bool:
|
|
101
|
+
if not isinstance(other, self.__class__):
|
|
102
|
+
raise NotImplementedError("Not implemented")
|
|
103
|
+
return self.dimension == other.dimension and self.file_size == other.file_size
|
|
104
|
+
|
|
105
|
+
def __lt__(self, other) -> bool:
|
|
106
|
+
if not isinstance(other, self.__class__):
|
|
107
|
+
raise NotImplementedError("Not implemented")
|
|
108
|
+
|
|
109
|
+
# prioritize dimension first, then size
|
|
110
|
+
if self.dimension != other.dimension:
|
|
111
|
+
return self.dimension < other.dimension
|
|
112
|
+
return self.file_size < other.file_size
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class DirectoryRemoveDuplicateImageMixin(DirectoryRemoveDuplicateMixin):
|
|
116
|
+
"""
|
|
117
|
+
Directory - Remove duplicate image
|
|
118
|
+
|
|
119
|
+
- remove_duplicate_images
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
Example:
|
|
123
|
+
--------
|
|
124
|
+
>>> DirectoryRemoveDuplicateImageMixin(".").remove_duplicate_images()
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, source_path, create_if_not_exist=False) -> None:
|
|
128
|
+
super().__init__(source_path, create_if_not_exist)
|
|
129
|
+
|
|
130
|
+
# Unused yet
|
|
131
|
+
self._duplicate_image_cache = None
|
|
132
|
+
|
|
133
|
+
# Hash
|
|
134
|
+
def _get_img_hash_mode(
|
|
135
|
+
self, hash_mode: HashMode = HashMode.PERCEPTUAL_HASH
|
|
136
|
+
) -> Callable[[Image, int], imagehash.ImageHash]:
|
|
137
|
+
"""
|
|
138
|
+
Get image hash mode
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
hash_mode : HashMode, optional
|
|
143
|
+
Hash mode, by default ``HashMode.PERCEPTUAL_HASH``
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
Callable[[Image, int], imagehash.ImageHash]
|
|
148
|
+
Hash function
|
|
149
|
+
"""
|
|
150
|
+
if hash_mode == HashMode.AVERAGE_HASH:
|
|
151
|
+
return imagehash.average_hash
|
|
152
|
+
elif hash_mode == HashMode.DIFFERENCE_HASH:
|
|
153
|
+
return imagehash.dhash
|
|
154
|
+
elif hash_mode == HashMode.WAVELET_HASH:
|
|
155
|
+
return imagehash.whash
|
|
156
|
+
else:
|
|
157
|
+
return imagehash.phash
|
|
158
|
+
|
|
159
|
+
def _gather_duplicate_image_cache(
|
|
160
|
+
self, recursive: bool = True, threshold: int = 5, hash_mode: HashMode = HashMode.PERCEPTUAL_HASH
|
|
161
|
+
) -> None:
|
|
162
|
+
"""
|
|
163
|
+
Gather duplicate image cache
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
recursive : bool, optional
|
|
168
|
+
Scan every file in the folder (including child folder), by default ``True``
|
|
169
|
+
|
|
170
|
+
threshold : int, optional
|
|
171
|
+
Maximum hamming distance between image hashes to consider them "similar", by default ``5``
|
|
172
|
+
- 0: Exact image
|
|
173
|
+
- [5,10]: Tolerant of light edits
|
|
174
|
+
|
|
175
|
+
hash_mode : HashMode, optional
|
|
176
|
+
Hash mode, by default ``HashMode.PERCEPTUAL_HASH``
|
|
177
|
+
"""
|
|
178
|
+
valid = [
|
|
179
|
+
x
|
|
180
|
+
for x in self.source_path.glob("**/*" if recursive else "*")
|
|
181
|
+
if x.is_file() and x.suffix.lower() in SupportedImageFormat
|
|
182
|
+
]
|
|
183
|
+
hash_cache: dict[imagehash.ImageHash, list[Path]] = {}
|
|
184
|
+
duplicates: list[DuplicateImgPair] = []
|
|
185
|
+
|
|
186
|
+
# Checksum
|
|
187
|
+
for x in tqdm(valid, desc="Hashing image..."):
|
|
188
|
+
try:
|
|
189
|
+
with Image.open(x) as img:
|
|
190
|
+
hash_func = self._get_img_hash_mode(hash_mode=hash_mode)
|
|
191
|
+
hash = hash_func(img) # perceptual hash
|
|
192
|
+
|
|
193
|
+
except Exception as err:
|
|
194
|
+
print(f"ERROR: {x} - {err}")
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
# Compare against all cached hashes
|
|
198
|
+
found = False
|
|
199
|
+
for existing_hash, paths in hash_cache.items():
|
|
200
|
+
distance = hash - existing_hash
|
|
201
|
+
if distance <= threshold:
|
|
202
|
+
duplicates.append(DuplicateImgPair(paths[0], x, distance))
|
|
203
|
+
if x not in paths:
|
|
204
|
+
paths.append(x)
|
|
205
|
+
found = True
|
|
206
|
+
break
|
|
207
|
+
|
|
208
|
+
if not found:
|
|
209
|
+
hash_cache[hash] = [x]
|
|
210
|
+
|
|
211
|
+
# Save to cache
|
|
212
|
+
self._duplicate_cache = DuplicateSummary({k: v for k, v in hash_cache.items() if len(v) > 1})
|
|
213
|
+
self._duplicate_image_cache = duplicates
|
|
214
|
+
|
|
215
|
+
# Remove
|
|
216
|
+
def _gather_img_info(self, image_path: Path) -> ImageInfo:
|
|
217
|
+
with Image.open(image_path) as img:
|
|
218
|
+
dim = img.size
|
|
219
|
+
return ImageInfo(image_path, image_path.stat().st_size, dim)
|
|
220
|
+
|
|
221
|
+
def _remove_duplicate_image_best(self, dry_run: bool = True, debug: bool = True) -> None:
|
|
222
|
+
"""This will take image with large size in dimension and storage"""
|
|
223
|
+
if self._duplicate_cache is None or self._duplicate_image_cache is None:
|
|
224
|
+
raise ValueError("No duplicates found")
|
|
225
|
+
|
|
226
|
+
del_list: list[ImageInfo] = []
|
|
227
|
+
for paths in self._duplicate_cache.values():
|
|
228
|
+
# Sort image by dimension then size ascending order then cut the last value
|
|
229
|
+
data = sorted([self._gather_img_info(img) for img in paths])[:-1]
|
|
230
|
+
# Extend to delete list
|
|
231
|
+
del_list.extend(data)
|
|
232
|
+
|
|
233
|
+
for i, x in enumerate(del_list, start=1):
|
|
234
|
+
if debug:
|
|
235
|
+
print(f"{i:02}. Deleting {x.path}")
|
|
236
|
+
if not dry_run:
|
|
237
|
+
x.path.unlink(missing_ok=True)
|
|
238
|
+
|
|
239
|
+
# Main
|
|
240
|
+
def remove_duplicate_images(
|
|
241
|
+
self,
|
|
242
|
+
dry_run: bool = True,
|
|
243
|
+
recursive: bool = True,
|
|
244
|
+
threshold: int = 5,
|
|
245
|
+
hash_mode: HashMode = HashMode.PERCEPTUAL_HASH,
|
|
246
|
+
keep_mode: Literal["first", "last", "best"] = "best",
|
|
247
|
+
debug: bool = True,
|
|
248
|
+
) -> None:
|
|
249
|
+
"""
|
|
250
|
+
Remove duplicate images in a directory
|
|
251
|
+
|
|
252
|
+
Parameters
|
|
253
|
+
----------
|
|
254
|
+
dry_run : bool, optional
|
|
255
|
+
Simulate only (no files deleted), by default ``True``
|
|
256
|
+
|
|
257
|
+
recursive : bool, optional
|
|
258
|
+
Scan every file in the folder (including child folder), by default ``True``
|
|
259
|
+
|
|
260
|
+
threshold : int, optional
|
|
261
|
+
Maximum hamming distance between image hashes to consider them "similar", by default ``5``
|
|
262
|
+
- 0: Exact image
|
|
263
|
+
- [5,10]: Tolerant of light edits
|
|
264
|
+
|
|
265
|
+
hash_mode : HashMode, optional
|
|
266
|
+
Hash mode, by default ``HashMode.PERCEPTUAL_HASH``
|
|
267
|
+
|
|
268
|
+
keep_mode : Literal["first", "last", "best"], optional
|
|
269
|
+
What to keep in duplicate images, by default ``"best"``
|
|
270
|
+
- "first": First item in delete list
|
|
271
|
+
- "last": Last item in delete list
|
|
272
|
+
- "best": Best item (largest dimension and size) in delete list
|
|
273
|
+
|
|
274
|
+
debug : bool, optional
|
|
275
|
+
Debug message, by default ``True``
|
|
276
|
+
"""
|
|
277
|
+
# Cache
|
|
278
|
+
self._gather_duplicate_image_cache(recursive=recursive, threshold=threshold, hash_mode=hash_mode)
|
|
279
|
+
|
|
280
|
+
# Remove
|
|
281
|
+
try:
|
|
282
|
+
if keep_mode in ["first", "last"]:
|
|
283
|
+
summary = self._duplicate_cache
|
|
284
|
+
print(f"Duplicate files: {summary.summary()}")
|
|
285
|
+
summary.remove_duplicates(dry_run=dry_run, keep_first=keep_mode == "first", debug=debug)
|
|
286
|
+
|
|
287
|
+
else: # best mode
|
|
288
|
+
self._remove_duplicate_image_best(dry_run=dry_run, debug=debug)
|
|
289
|
+
|
|
290
|
+
except Exception as err:
|
|
291
|
+
pass
|
absfuyu/extra/pdf.py
CHANGED
|
@@ -3,8 +3,8 @@ Absfuyu: PDF
|
|
|
3
3
|
------------
|
|
4
4
|
PDF Tool [W.I.P]
|
|
5
5
|
|
|
6
|
-
Version:
|
|
7
|
-
Date updated: 12/
|
|
6
|
+
Version: 6.1.1
|
|
7
|
+
Date updated: 30/12/2025 (dd/mm/yyyy)
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
# Module level
|
|
@@ -31,18 +31,16 @@ except ImportError:
|
|
|
31
31
|
from absfuyu.config import ABSFUYU_CONFIG
|
|
32
32
|
|
|
33
33
|
if ABSFUYU_CONFIG._get_setting("auto-install-extra").value: # type: ignore
|
|
34
|
-
cmd = "python -m pip install -U absfuyu[
|
|
34
|
+
cmd = "python -m pip install -U absfuyu[pdf]".split()
|
|
35
35
|
run(cmd)
|
|
36
36
|
else:
|
|
37
|
-
raise SystemExit("This feature is in absfuyu[
|
|
37
|
+
raise SystemExit("This feature is in absfuyu[pdf] package") # noqa: B904
|
|
38
38
|
else:
|
|
39
39
|
PDF_MODE = True
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
# Class
|
|
43
43
|
# ---------------------------------------------------------------------------
|
|
44
|
-
|
|
45
|
-
|
|
46
44
|
class PDFTool(BaseClass):
|
|
47
45
|
def __init__(self) -> None:
|
|
48
46
|
super().__init__()
|
absfuyu/extra/rclone.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Absfuyu: Rclone decrypt
|
|
3
|
+
-----------------------
|
|
4
|
+
Rclone decryptor
|
|
5
|
+
|
|
6
|
+
Version: 6.1.1
|
|
7
|
+
Date updated: 30/12/2025 (dd/mm/yyyy)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
# Module level
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
__all__ = ["RcloneEncryptDecrypt", "DirectoryRcloneDEMixin"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Library
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Literal, Self
|
|
21
|
+
|
|
22
|
+
from rclone import Crypt
|
|
23
|
+
|
|
24
|
+
from absfuyu.core.baseclass import AutoREPRMixin
|
|
25
|
+
from absfuyu.logger import LogLevel, logger
|
|
26
|
+
from absfuyu.util.path import DirectoryBase
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Class
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
class RcloneEncryptDecrypt(AutoREPRMixin):
|
|
32
|
+
"""
|
|
33
|
+
Rclone Decrypt/Encrypt Module
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, crypt: Crypt) -> None:
|
|
37
|
+
"""
|
|
38
|
+
This will encrypt/decrypt with rclone style
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
crypt : Crypt
|
|
43
|
+
``rclone.Crypt`` object | Encrypt/Decrypt engine
|
|
44
|
+
"""
|
|
45
|
+
self._crypt = crypt
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_passwd_salt(cls, passwd: str, salt: str | None = None) -> Self:
|
|
49
|
+
"""
|
|
50
|
+
Create Rclone Decrypt/Encrypt object from password and salt
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
passwd : str
|
|
55
|
+
Custom password
|
|
56
|
+
|
|
57
|
+
salt : str | None, optional
|
|
58
|
+
Custom salt, by default None
|
|
59
|
+
|
|
60
|
+
Returns
|
|
61
|
+
-------
|
|
62
|
+
Self
|
|
63
|
+
Rclone Decrypt/Encrypt object
|
|
64
|
+
"""
|
|
65
|
+
if salt is None:
|
|
66
|
+
crypt: Crypt = Crypt(passwd=passwd)
|
|
67
|
+
else:
|
|
68
|
+
crypt: Crypt = Crypt(passwd=passwd, salt=salt)
|
|
69
|
+
return cls(crypt)
|
|
70
|
+
|
|
71
|
+
# Support
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _directory_validator(path: Path) -> Path:
|
|
74
|
+
"""Validate if directory exists, then return the path"""
|
|
75
|
+
path = Path(path)
|
|
76
|
+
if not path.exists():
|
|
77
|
+
raise ValueError("Path does not exist")
|
|
78
|
+
return path
|
|
79
|
+
|
|
80
|
+
def _modify_name(self, name_to_modify: str, mode: Literal["encrypt", "decrypt"]) -> str:
|
|
81
|
+
if mode == "decrypt":
|
|
82
|
+
return self._crypt.Name.standard_decrypt(name_to_modify)
|
|
83
|
+
if mode == "encrypt":
|
|
84
|
+
return self._crypt.Name.standard_encrypt(name_to_modify)
|
|
85
|
+
|
|
86
|
+
def _decrypt_encrypt_operation(
|
|
87
|
+
self,
|
|
88
|
+
mode: Literal["encrypt", "decrypt"],
|
|
89
|
+
root_dir: Path | str, # type: ignore
|
|
90
|
+
delete_when_complete: bool = False,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Base operation to decrypt, encrypt
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
mode : Literal["encrypt", "decrypt"]
|
|
98
|
+
Encrypt or decrypt
|
|
99
|
+
|
|
100
|
+
root_dir : Path | str
|
|
101
|
+
Directory location
|
|
102
|
+
|
|
103
|
+
delete_when_complete : bool
|
|
104
|
+
Delete directory when completed, defaults to False
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
logger.info(f"Begin operation: {mode.title()}")
|
|
108
|
+
root_dir = Path(root_dir)
|
|
109
|
+
FILE_MODE = False
|
|
110
|
+
|
|
111
|
+
# Make Base folder
|
|
112
|
+
if root_dir.is_file():
|
|
113
|
+
# Get name of parent dir and make name
|
|
114
|
+
temp = self._modify_name(root_dir.parent.name, "encrypt" if mode == "decrypt" else "decrypt")
|
|
115
|
+
# Create sub dir
|
|
116
|
+
base_dir = root_dir.parent.joinpath(temp)
|
|
117
|
+
base_dir.mkdir(exist_ok=True, parents=True)
|
|
118
|
+
# Move file to that dir
|
|
119
|
+
new_path = root_dir.rename(base_dir.joinpath(root_dir.name))
|
|
120
|
+
root_dir = base_dir # Make root dir
|
|
121
|
+
FILE_MODE = True
|
|
122
|
+
else:
|
|
123
|
+
root_dir: Path = self._directory_validator(root_dir)
|
|
124
|
+
base_name: str = self._modify_name(root_dir.name, mode=mode)
|
|
125
|
+
base_dir: Path = root_dir.parent.joinpath(base_name)
|
|
126
|
+
base_dir.mkdir(exist_ok=True, parents=True)
|
|
127
|
+
|
|
128
|
+
_ljust: int = 17 # Logger ljust value
|
|
129
|
+
|
|
130
|
+
# Decrypt / Encrypt
|
|
131
|
+
for path in root_dir.glob("**/*"):
|
|
132
|
+
rel_path: Path = path.relative_to(root_dir)
|
|
133
|
+
rel_path_splited: list[str] = str(rel_path).split(os.sep)
|
|
134
|
+
rel_path_modified: list[str] = [self._modify_name(x, mode=mode) for x in rel_path_splited]
|
|
135
|
+
new_path: Path = base_dir.joinpath(*rel_path_modified)
|
|
136
|
+
|
|
137
|
+
if path.is_dir():
|
|
138
|
+
logger.debug(f"{mode.title()}ing Dir:".ljust(_ljust) + f"{path}")
|
|
139
|
+
new_path.mkdir(exist_ok=True, parents=True)
|
|
140
|
+
logger.debug(f"{mode.title()}ed Dir:".ljust(_ljust) + f"{new_path}")
|
|
141
|
+
else:
|
|
142
|
+
logger.debug(f"{mode.title()}ing File:".ljust(_ljust) + f"{path}")
|
|
143
|
+
if mode == "decrypt":
|
|
144
|
+
self._crypt.File.file_decrypt(path, new_path) # type: ignore
|
|
145
|
+
if mode == "encrypt":
|
|
146
|
+
self._crypt.File.file_encrypt(path, new_path) # type: ignore
|
|
147
|
+
logger.debug(f"{mode.title()}ed File:".ljust(_ljust) + f"{new_path}")
|
|
148
|
+
logger.info(f"Operation: {mode.title()} COMPLETED")
|
|
149
|
+
|
|
150
|
+
# File mode
|
|
151
|
+
if FILE_MODE:
|
|
152
|
+
new_path.rename(root_dir.parent.joinpath(new_path.name)) # type: ignore
|
|
153
|
+
shutil.rmtree(root_dir)
|
|
154
|
+
|
|
155
|
+
# Delete when completed
|
|
156
|
+
if delete_when_complete:
|
|
157
|
+
logger.debug(f"Deleting {root_dir}")
|
|
158
|
+
shutil.rmtree(root_dir)
|
|
159
|
+
logger.debug(f"Deleting {root_dir}...DONE")
|
|
160
|
+
|
|
161
|
+
# Decrypt/Encrypt
|
|
162
|
+
def decrypt(self, root_dir: Path, delete_when_complete: bool = False) -> None:
|
|
163
|
+
"""
|
|
164
|
+
Decrypt encrypted directory.
|
|
165
|
+
|
|
166
|
+
This will decrypt the directory itself and
|
|
167
|
+
create a decrypted directory next to the original
|
|
168
|
+
|
|
169
|
+
Parameters
|
|
170
|
+
----------
|
|
171
|
+
root_dir : Path
|
|
172
|
+
Directory location
|
|
173
|
+
|
|
174
|
+
delete_when_complete : bool, optional
|
|
175
|
+
Delete directory when completed, by default False
|
|
176
|
+
"""
|
|
177
|
+
self._decrypt_encrypt_operation(mode="decrypt", root_dir=root_dir, delete_when_complete=delete_when_complete)
|
|
178
|
+
|
|
179
|
+
def encrypt(self, root_dir: Path, delete_when_complete: bool = False) -> None:
|
|
180
|
+
"""
|
|
181
|
+
Encrypt entire directory.
|
|
182
|
+
|
|
183
|
+
This will encrypt the directory itself and
|
|
184
|
+
create an encrypted directory next to the original
|
|
185
|
+
|
|
186
|
+
Parameters
|
|
187
|
+
----------
|
|
188
|
+
root_dir : Path
|
|
189
|
+
Directory location
|
|
190
|
+
|
|
191
|
+
delete_when_complete : bool, optional
|
|
192
|
+
Delete directory when completed, by default False
|
|
193
|
+
"""
|
|
194
|
+
self._decrypt_encrypt_operation(mode="encrypt", root_dir=root_dir, delete_when_complete=delete_when_complete)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class DirectoryRcloneDEMixin(DirectoryBase):
|
|
198
|
+
"""
|
|
199
|
+
Directory - Rclone encrypt/decrypt
|
|
200
|
+
|
|
201
|
+
Extension for ``absfuyu.util.path.Directory``
|
|
202
|
+
|
|
203
|
+
- Decrypt
|
|
204
|
+
- Encrypt
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
def rclone_encrypt(self, passwd: str, salt: str | None = None, delete_when_complete: bool = False) -> None:
|
|
208
|
+
"""
|
|
209
|
+
Encrypt entire directory.
|
|
210
|
+
|
|
211
|
+
This will encrypt the directory itself and
|
|
212
|
+
create an encrypted directory next to the original
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
passwd : str
|
|
217
|
+
Custom password
|
|
218
|
+
|
|
219
|
+
salt : str | None, optional
|
|
220
|
+
Custom salt, by default None
|
|
221
|
+
|
|
222
|
+
delete_when_complete : bool, optional
|
|
223
|
+
Delete directory when completed, by default False
|
|
224
|
+
"""
|
|
225
|
+
engine = RcloneEncryptDecrypt.from_passwd_salt(passwd=passwd, salt=salt)
|
|
226
|
+
engine.encrypt(self.source_path, delete_when_complete=delete_when_complete)
|
|
227
|
+
|
|
228
|
+
def rclone_decrypt(self, passwd: str, salt: str | None = None, delete_when_complete: bool = False) -> None:
|
|
229
|
+
"""
|
|
230
|
+
Decrypt encrypted directory.
|
|
231
|
+
|
|
232
|
+
This will decrypt the directory itself and
|
|
233
|
+
create a decrypted directory next to the original
|
|
234
|
+
|
|
235
|
+
Parameters
|
|
236
|
+
----------
|
|
237
|
+
passwd : str
|
|
238
|
+
Custom password
|
|
239
|
+
|
|
240
|
+
salt : str | None, optional
|
|
241
|
+
Custom salt, by default None
|
|
242
|
+
|
|
243
|
+
delete_when_complete : bool, optional
|
|
244
|
+
Delete directory when completed, by default False
|
|
245
|
+
"""
|
|
246
|
+
engine = RcloneEncryptDecrypt.from_passwd_salt(passwd=passwd, salt=salt)
|
|
247
|
+
engine.decrypt(self.source_path, delete_when_complete=delete_when_complete)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# Run
|
|
251
|
+
# -----------------------------------------------------------------------------------------------
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
logger.setLevel(LogLevel.DEBUG)
|
absfuyu/extra/xml.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Absfuyu: XML
|
|
3
|
+
------------
|
|
4
|
+
XML Tool [W.I.P]
|
|
5
|
+
|
|
6
|
+
Version: 6.1.1
|
|
7
|
+
Date updated: 30/12/2025 (dd/mm/yyyy)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
# Module level
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
__all__ = ["XML2Dict"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Library
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Self
|
|
19
|
+
|
|
20
|
+
from absfuyu.core.baseclass import GetClassMembersMixin
|
|
21
|
+
|
|
22
|
+
XML_MODE = False
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import xmltodict
|
|
26
|
+
except ImportError:
|
|
27
|
+
from subprocess import run
|
|
28
|
+
|
|
29
|
+
from absfuyu.config import ABSFUYU_CONFIG
|
|
30
|
+
|
|
31
|
+
if ABSFUYU_CONFIG._get_setting("auto-install-extra").value: # type: ignore
|
|
32
|
+
cmd = "python -m pip install -U absfuyu[full]".split()
|
|
33
|
+
run(cmd)
|
|
34
|
+
else:
|
|
35
|
+
raise SystemExit("This feature is in absfuyu[full] package") # noqa: B904
|
|
36
|
+
else:
|
|
37
|
+
XML_MODE = True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Class
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
class XML2Dict(GetClassMembersMixin):
|
|
43
|
+
"""
|
|
44
|
+
Automatically convert .xml file to dict
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
Usage
|
|
48
|
+
-----
|
|
49
|
+
>>> XML2Dict(<path>)
|
|
50
|
+
>>> XML2Dict.parsed_data
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, text: str) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Automatically convert .xml file to dict
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
text : str
|
|
60
|
+
``.xml`` format text
|
|
61
|
+
"""
|
|
62
|
+
self._text = text
|
|
63
|
+
|
|
64
|
+
self.parsed_data: dict[str, Any] = self._parse_xml()
|
|
65
|
+
|
|
66
|
+
def __repr__(self) -> str:
|
|
67
|
+
return f"{self.__class__.__name__}()"
|
|
68
|
+
|
|
69
|
+
def _parse_xml(self) -> dict[str, Any]:
|
|
70
|
+
"""Convert xml to dict"""
|
|
71
|
+
return xmltodict.parse(self._text, encoding="utf-8") # type: ignore
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_path(cls, xml_path: str | Path) -> Self:
|
|
75
|
+
"""
|
|
76
|
+
Convert from .xml file path
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
xml_path : str | Path
|
|
81
|
+
Path to .xml file
|
|
82
|
+
|
|
83
|
+
Returns
|
|
84
|
+
-------
|
|
85
|
+
Self
|
|
86
|
+
xml to dict
|
|
87
|
+
"""
|
|
88
|
+
with Path(xml_path).open("r", encoding="utf-8") as f:
|
|
89
|
+
dat = "\n".join(f.readlines())
|
|
90
|
+
return cls(dat)
|
absfuyu/fun/__init__.py
CHANGED
|
@@ -3,8 +3,8 @@ Absfuyu: Fun
|
|
|
3
3
|
------------
|
|
4
4
|
Some fun or weird stuff
|
|
5
5
|
|
|
6
|
-
Version:
|
|
7
|
-
Date updated: 12/
|
|
6
|
+
Version: 6.1.1
|
|
7
|
+
Date updated: 30/12/2025 (dd/mm/yyyy)
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
# Module level
|
|
@@ -95,24 +95,6 @@ def zodiac_sign(day: int, month: int, zodiac13: bool = False) -> str:
|
|
|
95
95
|
return result
|
|
96
96
|
|
|
97
97
|
|
|
98
|
-
@deprecated("5.0.0", reason="API shutdown, will be removed later")
|
|
99
|
-
def im_bored() -> str:
|
|
100
|
-
"""
|
|
101
|
-
Get random activity from ``boredapi`` website
|
|
102
|
-
|
|
103
|
-
Returns
|
|
104
|
-
-------
|
|
105
|
-
str
|
|
106
|
-
Random activity
|
|
107
|
-
"""
|
|
108
|
-
raise SystemExit("API shuted down")
|
|
109
|
-
# try:
|
|
110
|
-
# api = APIRequest("https://www.boredapi.com/api/activity")
|
|
111
|
-
# return api.fetch_data_only().json()["activity"] # type: ignore
|
|
112
|
-
# except Exception:
|
|
113
|
-
# return "FAILED"
|
|
114
|
-
|
|
115
|
-
|
|
116
98
|
# For new year only
|
|
117
99
|
def happy_new_year(forced: bool = False, include_lunar: bool = False) -> None:
|
|
118
100
|
"""
|