pymover 0.3.0.dev3__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.
pymover/__init__.py ADDED
File without changes
pymover/__version__.py ADDED
@@ -0,0 +1,7 @@
1
+ __version__ = "0.3.0.dev3"
2
+ __license__ = "n.a."
3
+ __copyright__ = "2023, Thorbjörn Mumme"
4
+ __description__ = "Custom move command with progress bar"
5
+ __maintainer__ = "Thorbjoern Mumme"
6
+ __dev_url__ = "https://gitlab.com/thomumme/file-mover"
7
+ __doc_url__ = ""
pymover/cli.py ADDED
@@ -0,0 +1,23 @@
1
+ import click
2
+
3
+ from .pymover import move_with_progress, copy_with_progress
4
+
5
+
6
+ @click.command()
7
+ @click.argument('src', type=click.Path(), nargs=-1)
8
+ @click.argument('dst', type=click.Path())
9
+ @click.option("--simulate", is_flag=True, default=False)
10
+ def move(**kwargs):
11
+ move_with_progress(src=kwargs['src'], dst=kwargs['dst'], simulate=kwargs['simulate'])
12
+
13
+
14
+ @click.command()
15
+ @click.argument('src', type=click.Path(), nargs=-1)
16
+ @click.argument('dst', type=click.Path())
17
+ @click.option("--simulate", is_flag=True, default=False)
18
+ def copy(**kwargs):
19
+ copy_with_progress(src=kwargs['src'], dst=kwargs['dst'])
20
+
21
+
22
+ if __name__ == '__main__':
23
+ move()
pymover/pymover.py ADDED
@@ -0,0 +1,362 @@
1
+ import glob
2
+ import os
3
+ import platform
4
+ import shutil
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import List
8
+ from typing import Union, Tuple
9
+
10
+ from loguru import logger
11
+ from tqdm import tqdm
12
+
13
+ from .utils import to_list
14
+
15
+
16
+ class FileMover:
17
+ def __init__(self, interactive_mode: bool, conflict_resolution: str = "ya", create_missing_parents: bool = False):
18
+ self._is_interactive: bool = interactive_mode
19
+
20
+ # if we are in interactive mode we set the conflict resolution to undefined to prompt the options
21
+ # otherwise we just set what has been passed
22
+ self._conflict_mode: str = "" if interactive_mode else conflict_resolution
23
+
24
+ # in non interactive mode the option "y" or "n" does not really make sense, because we have to apply it to all copy/move calls
25
+ # so we simply change it to "ya" or "na" and it gets handled as expected in the copy/move functions
26
+ if not interactive_mode:
27
+ self._conflict_mode += "a" if self._conflict_mode in {"y", "n"} else ""
28
+
29
+ self._create_missing_parents = create_missing_parents
30
+ pass
31
+
32
+ def prompt_overwrite_conflict(self, target_file: Path):
33
+ """
34
+ Prompts the user to handle a file overwrite conflict.
35
+ Returns the key (e.g., 'ya') selected by the user.
36
+ """
37
+ options = {
38
+ "y": "Yes",
39
+ "n": "No",
40
+ "ya": "Yes to all",
41
+ "na": "No to all",
42
+ "s": "Skip Conflict",
43
+ "sa": "Skip All Conflicts"
44
+ }
45
+
46
+ # Signal the conflict
47
+ logger.warning(f"Conflict Detected: Do you want to overwrite the existing file: '{target_file}'")
48
+
49
+ # Print the clean menu
50
+ logger.opt(raw=True).info(f"{'-' * 40}\n")
51
+ for key, description in options.items():
52
+ logger.opt(raw=True).info(f" [{key.ljust(2)}] : {description}\n")
53
+ logger.opt(raw=True).info(f"{'-' * 40}\n")
54
+
55
+ while True:
56
+ try:
57
+ choice = input("Select an option: ").strip().lower()
58
+
59
+ if choice in options:
60
+ # Return the key instead of the descriptive value
61
+ logger.success(f"Action confirmed: {choice}")
62
+ return choice
63
+
64
+ logger.error(f"Invalid input '{choice}'. Valid: {', '.join(options.keys())}")
65
+ except KeyboardInterrupt:
66
+ logger.critical("\nOperation aborted by user.")
67
+ sys.exit(1)
68
+
69
+ def _get_source_size(self, src: List[Union[Path, str]]) -> int:
70
+ size: int = 0
71
+ for s in src:
72
+ if s.is_dir():
73
+ size += self._get_source_size(s.glob('*'))
74
+ else:
75
+ size += s.stat().st_size
76
+ return size
77
+
78
+ def _copy_file(self, src: Path, dst: Path, progres_bar: tqdm, simulate: bool = False):
79
+ assert isinstance(src, Path)
80
+ assert isinstance(dst, Path)
81
+ if dst.is_dir():
82
+ dst = dst / Path(src.name)
83
+
84
+ if simulate:
85
+ logger.info(f"Copying {src} to {dst}")
86
+ return
87
+
88
+ if dst.exists():
89
+
90
+ if not self._conflict_mode:
91
+ # no conflict mode is set, so we ask what to do
92
+ self._conflict_mode = self.prompt_overwrite_conflict(dst)
93
+
94
+ # now we need to evaluate the conflict mode
95
+ if self._conflict_mode in {"sa", "s", "na", "n"}:
96
+ logger.info(f"Skipping file {src}, because it already exists at target destination")
97
+ return
98
+ elif self._conflict_mode in {"ya", "y"}:
99
+ pass
100
+ else:
101
+ raise RuntimeError(f"Unknown conflict mode '{self._conflict_mode}'")
102
+
103
+ # we need to cleanup the conflict mode, in case it is not a "* to all" option
104
+ if len(self._conflict_mode) < 2:
105
+ self._conflict_mode = ""
106
+
107
+ # create missing parent directories
108
+ if self._create_missing_parents:
109
+ dst.parent.mkdir(parents=True, exist_ok=True)
110
+
111
+ # progres_bar.set_description(desc=src.name)
112
+ inner_bar = tqdm(total=src.stat().st_size, unit='B', unit_scale=True, position=1, leave=False, desc=src.name)
113
+ with open(src, 'rb') as fsrc:
114
+ with open(dst, 'wb') as fdst:
115
+ # with tqdm(total=size, unit='B', unit_scale=True, desc=f'Copying {src} to {dst}') as pbar:
116
+ while True:
117
+ chunk = fsrc.read(4096)
118
+ if not chunk:
119
+ break
120
+ fdst.write(chunk)
121
+ fdst.flush()
122
+ progres_bar.update(len(chunk))
123
+ inner_bar.update(len(chunk))
124
+
125
+ def _copy_directory(self, src: Path, dst: Path, progres_bar: tqdm, simulate: bool = False):
126
+ # check if the target directory exists, if not create it
127
+ if not dst.exists():
128
+ if simulate:
129
+ logger.info(f"Creating directory {dst}")
130
+ else:
131
+ dst.mkdir()
132
+ # get the content of the directory
133
+ content = src.glob('*')
134
+ for c in content:
135
+ if c.is_dir():
136
+ self._copy_directory(c, dst / c.name, progres_bar, simulate=simulate)
137
+ elif c.is_file():
138
+ self._copy_file(c, dst / c.name, progres_bar, simulate=simulate)
139
+ else:
140
+ raise NotImplementedError("This case is not handled yet")
141
+
142
+ def copy_with_progress(self, src: Tuple[Union[Path, str]], dst: Union[Path, str], simulate: bool = False, recursive: bool = False,
143
+ progress_bar=None):
144
+
145
+ assert isinstance(dst, (list, Path, str))
146
+
147
+ src = to_list(src)
148
+ src = [Path(x) for x in src]
149
+ dst = to_list(dst)
150
+ dst = [Path(x) for x in dst]
151
+
152
+ if all(s.is_dir() for s in src) and not recursive:
153
+ raise RuntimeError("Recursive mode must be enabled when copying a directory")
154
+
155
+ if len(dst) == 1 and len(src) > 1:
156
+ if not dst[0].is_dir():
157
+ raise RuntimeError("Target must be a directory when a copying multiple sources to the same destination")
158
+
159
+ # with the previous check we made sure that the destination is a directory
160
+ # and here we create a list of targets, which are all the same, one element for each source
161
+ dst = [dst[0] for s in src]
162
+
163
+ assert isinstance(src, List)
164
+ assert len(src) == len(dst)
165
+ for s, d in zip(src, dst):
166
+ if s.is_dir() and d.is_file():
167
+ raise RuntimeError("Target must be a directory when a copying directory")
168
+
169
+ size = self._get_source_size(src)
170
+
171
+ if progress_bar is None:
172
+ progress_bar = tqdm(total=size, unit='B', unit_scale=True)
173
+
174
+ for s, d in zip(src, dst):
175
+ if s.is_file():
176
+ if d.is_dir():
177
+ new_destination = d / s.name
178
+ else:
179
+ new_destination = d
180
+ self._copy_file(s, new_destination, progress_bar, simulate=simulate)
181
+ else:
182
+ # in case the target dir already exists, we want to copy the source into the existing directory
183
+ # otherwise we copy the source directory to the destination (and rename it)
184
+ _target = d / s.name if d.is_dir() else d
185
+ self._copy_directory(s, _target, progress_bar, simulate=simulate)
186
+
187
+ def find_mount_point(self, path):
188
+ path = os.path.abspath(path)
189
+ while not os.path.ismount(path):
190
+ path = os.path.dirname(path)
191
+ return path
192
+
193
+ def use_system_move(self, src, dst) -> bool:
194
+ if platform.system() == 'Windows':
195
+ if src.absolute().anchor == dst.absolute().anchor:
196
+ # when moving files on the same drive we simply edit the file system entry
197
+ # and do not need to copy the file
198
+ return True
199
+ else:
200
+ return False
201
+ elif platform.system() == 'Linux':
202
+ src_mount = self.find_mount_point(src)
203
+ dst_mount = self.find_mount_point(dst)
204
+ if src_mount == dst_mount:
205
+ return True
206
+ else:
207
+ return False
208
+
209
+ else:
210
+ logger.warning(f"Unsupported operating system '{platform.system()}'. Using working directory...")
211
+
212
+ def _move_file(self, src: Path, dst: Path, progres_bar: tqdm, simulate: bool = False,
213
+ force_custom_move: bool = False):
214
+ if self.use_system_move(src, dst) and not force_custom_move:
215
+ # when moving files on the same drive we simply edit the file system entry
216
+ # and do not need to copy the file
217
+ progres_bar.set_description(desc=src.name)
218
+
219
+ if dst.exists():
220
+
221
+ if not self._conflict_mode:
222
+ # no conflict mode is set, so we ask what to do
223
+ self._conflict_mode = self.prompt_overwrite_conflict(dst)
224
+
225
+ # now we need to evaluate the conflict mode
226
+ if self._conflict_mode in {"sa", "s", "na", "n"}:
227
+ logger.info(f"Skipping file {src}, because it already exists at target destination")
228
+ return
229
+ elif self._conflict_mode in {"ya", "y"}:
230
+ pass
231
+ else:
232
+ raise RuntimeError(f"Unknown conflict mode '{self._conflict_mode}'")
233
+
234
+ # we need to cleanup the conflict mode, in case it is not a "* to all" option
235
+ if len(self._conflict_mode) < 2:
236
+ self._conflict_mode = ""
237
+
238
+ # create missing parent directories
239
+ if self._create_missing_parents:
240
+ dst.parent.mkdir(parents=True, exist_ok=True)
241
+
242
+ shutil.move(src, dst)
243
+ progres_bar.update(dst.stat().st_size)
244
+ else:
245
+ self.copy_with_progress([src], dst, simulate=simulate, progress_bar=progres_bar)
246
+ os.remove(src)
247
+
248
+ def _move_directory(self, src: Path,
249
+ dst: Path,
250
+ progres_bar: tqdm,
251
+ simulate: bool = False,
252
+ force_custom_move: bool = False):
253
+ if not dst.exists():
254
+ dst.mkdir()
255
+ content = src.glob('*')
256
+ for c in content:
257
+ if c.is_dir():
258
+ self._move_directory(c, dst / c.name, progres_bar=progres_bar, simulate=simulate,
259
+ force_custom_move=force_custom_move)
260
+ # c.rmdir()
261
+ elif c.is_file():
262
+ self._move_file(c, dst / c.name, progres_bar=progres_bar, simulate=simulate,
263
+ force_custom_move=force_custom_move)
264
+ # The root directory gets deleted after it has been moved
265
+ src.rmdir()
266
+
267
+ def move_with_progress(self, src: Tuple[Union[Path, str]], dst: Union[Path, str], force_custom_move: bool = False, simulate: bool = False):
268
+ assert isinstance(dst, (list, Path, str))
269
+
270
+ src = to_list(src)
271
+ src = [Path(x) for x in src]
272
+ dst = to_list(dst)
273
+ dst = [Path(x) for x in dst]
274
+
275
+ if len(dst) == 1 and len(src) > 1:
276
+ if not dst[0].is_dir():
277
+ raise FileNotFoundError("Target must be a directory when a copying multiple sources to the same destination")
278
+
279
+ if len(dst) == 1 and len(src) > 1:
280
+ if not dst[0].is_dir():
281
+ raise RuntimeError("Target must be a directory when a moving multiple objects to the same destination")
282
+
283
+ # with the previous check we made sure that the destination is a directory
284
+ # and here we create a list of targets, which are all the same, one element for each source
285
+ dst = [dst[0] for s in src]
286
+
287
+ assert len(src) == len(dst)
288
+ for s, d in zip(src, dst):
289
+ if s.is_dir() and d.is_file():
290
+ raise RuntimeError("Target must be a directory when a copying directory")
291
+
292
+ size = self._get_source_size(src)
293
+ progress_bar = tqdm(total=size, unit='B', unit_scale=True, position=0)
294
+
295
+ for s, d in zip(src, dst):
296
+ if s.is_file():
297
+ if d.is_dir():
298
+ new_destination = d / s.name
299
+ else:
300
+ new_destination = d
301
+ self._move_file(s, new_destination, progress_bar, simulate=simulate)
302
+ else:
303
+ # in case the target dir already exists, we want to copy the source into the existing directory
304
+ # otherwise we copy the source directory to the destination (and rename it)
305
+ _target = d / s.name if d.is_dir() else d
306
+ self._move_directory(s, _target, progress_bar, simulate=simulate)
307
+
308
+
309
+ def copy_with_progress(src: Tuple[Union[Path, str]], dst: Union[Path, str],
310
+ interactive: bool = False, conflict_resoluation: str = "ya", create_missing_parents: bool = False, recursive: bool = False, simulate: bool = False,
311
+ progress_bar=None):
312
+ FileMover(interactive_mode=interactive,
313
+ conflict_resolution=conflict_resoluation,
314
+ create_missing_parents=create_missing_parents).copy_with_progress(src,
315
+ dst,
316
+ recursive=recursive,
317
+ simulate=simulate,
318
+ progress_bar=progress_bar)
319
+
320
+
321
+ def move_with_progress(src: Tuple[Union[Path, str]], dst: Union[Path, str], force_custom_move: bool = False, interactive: bool = False, conflict_resoluation: str = "ya",
322
+ simulate: bool = False, create_missing_parents: bool = False, ):
323
+ FileMover(interactive_mode=interactive,
324
+ conflict_resolution=conflict_resoluation,
325
+ create_missing_parents=create_missing_parents).move_with_progress(src, dst,
326
+ force_custom_move=force_custom_move,
327
+ simulate=simulate)
328
+
329
+
330
+ def start(
331
+ source_glob: List[str] = ("*/*",),
332
+ target: str = "new",
333
+ amount: int = 50,
334
+ selector_func=None,
335
+ dry_run: bool = False) -> List[Path]:
336
+ target_directory = Path(target)
337
+ amount = amount
338
+
339
+ source_files = []
340
+ for g in source_glob:
341
+ source_files.extend(glob.glob(g))
342
+ # source_files.extend(list(Path().glob(g)))
343
+ source_files = [Path(x) for x in source_files]
344
+
345
+ logger.info(f"Total amount of files: {len(source_files)}")
346
+
347
+ source_files = selector_func(source_files, amount)
348
+
349
+ logger.debug("Selected files")
350
+ for i, f in enumerate(source_files):
351
+ logger.debug(f"[{i}] {f}")
352
+
353
+ if dry_run:
354
+ for f in source_files:
355
+ destination = Path(target) / f.name
356
+ logger.info(f"'{f}' would be moved to '{destination}'")
357
+
358
+ else:
359
+
360
+ move_with_progress(source_files, target_directory)
361
+
362
+ return source_files
pymover/utils.py ADDED
@@ -0,0 +1,9 @@
1
+ def to_list(val):
2
+ if isinstance(val, list):
3
+ return val
4
+ if isinstance(val, (str, bytes)):
5
+ return [val]
6
+ try:
7
+ return list(val)
8
+ except TypeError:
9
+ return [val]
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: pymover
3
+ Version: 0.3.0.dev3
4
+ Summary: Custom move command with progress bar
5
+ Author: Thorbjoern Mumme
6
+ License: n.a.
7
+ Project-URL: Homepage, https://gitlab.com/thomumme/file-mover
8
+ Project-URL: Repository, https://gitlab.com/thomumme/file-mover
9
+ Keywords: file,move,copy,progress
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.7
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: tqdm
17
+ Requires-Dist: loguru
18
+ Requires-Dist: click
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest; extra == "dev"
21
+ Requires-Dist: pytest-cov; extra == "dev"
22
+ Requires-Dist: randomfiletree; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ # FileMover
26
+
27
+ FileMover is a Python package that provides command-line tools for moving and copying files with progress bars. It supports both single file operations and batch operations on multiple files.
28
+
29
+ ![PyPI](https://img.shields.io/pypi/v/pymover)
30
+ ![Python Version](https://img.shields.io/pypi/pyversions/pymover)
31
+ ![License](https://img.shields.io/pypi/l/pymover)
32
+ ![CI](https://gitlab.com/thomumme/file-mover/badges/main/pipeline.svg)
33
+
34
+ ## Installation
35
+
36
+
37
+ You can install FileMover using pip:
38
+
39
+ ```bash
40
+ pip install pymover
41
+ ```
42
+
43
+ Or install the latest development version from GitLab:
44
+
45
+ ```bash
46
+ pip install git+https://gitlab.com/thomumme/file-mover.git
47
+ ```
48
+
49
+ ## Changelog
50
+
51
+ See [CHANGELOG.md](CHANGELOG.md) for a history of changes.
52
+
53
+
54
+
@@ -0,0 +1,10 @@
1
+ pymover/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pymover/__version__.py,sha256=a-eastSKvU-8zkoKXkCwiOxysx2fnIaq6eoi6Rr9df0,255
3
+ pymover/cli.py,sha256=7fXx5cCZVcL26hqQgL2FrnyV83L4n9I0DcjjknSuHRc,645
4
+ pymover/pymover.py,sha256=R3cgWWN1GkgS2U2Lw4fWRMpG0mKvgdLDxWPNm-HYLWk,15572
5
+ pymover/utils.py,sha256=h5yQRBAf0-o8DpHQ4RR3g6oxwpY0BzPlXR6Z6ObdFgM,203
6
+ pymover-0.3.0.dev3.dist-info/licenses/LICENSE,sha256=2bm9uFabQZ3Ykb_SaSU_uUbAj2-htc6WJQmS_65qD00,1073
7
+ pymover-0.3.0.dev3.dist-info/METADATA,sha256=8UyzlhCkyg0_zeYxscFRFLgbfxnPvfSmT-QtBxIX6Vg,1499
8
+ pymover-0.3.0.dev3.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
9
+ pymover-0.3.0.dev3.dist-info/top_level.txt,sha256=Bp9FNt2aW8AaK-auBTffK9DYHcwL3bvMFBhyC7aIl04,8
10
+ pymover-0.3.0.dev3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2018 The Python Packaging Authority
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pymover