├── .gitignore ├── slcp ├── __init__.py ├── __main__.py ├── log.py ├── cli.py └── process.py ├── pyproject.toml ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | dist/ 3 | .idea/ 4 | /examples.sh 5 | -------------------------------------------------------------------------------- /slcp/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | slcp - command line application that copies all files with given extensions 3 | from a directory and its subfolders to another directory. 4 | Allows to preserve a source folder structure, to process only files without given extensions, 5 | to move files instead of copying, to exclude certain files from processing and to create a log if necessary. 6 | Opens a filedialog if source and/or destination are not given in the command line. 7 | Creates folders in destination if they don't exist. 8 | This project is licensed under the MIT License. 9 | Copyright (c) 2019 Kirill Plotnikov 10 | GitHub: https://github.com/pltnk/selective_copy 11 | PyPi: https://pypi.org/project/slcp/ 12 | """ 13 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "slcp" 3 | version = "0.3.0" 4 | description = "Copy all files with given extensions from a directory and its subfolders to another directory." 5 | authors = ["Kirill Plotnikov "] 6 | license = "MIT" 7 | readme = 'README.md' 8 | repository = "https://github.com/pltnk/selective_copy" 9 | homepage = "https://github.com/pltnk/selective_copy" 10 | keywords = ['cli', 'copy', 'selective copy', 'slcp'] 11 | 12 | [tool.poetry.dependencies] 13 | python = "^3.7" 14 | 15 | [tool.poetry.dev-dependencies] 16 | python = "^3.7" 17 | 18 | [build-system] 19 | requires = ["poetry>=0.12"] 20 | build-backend = "poetry.masonry.api" 21 | 22 | [tool.poetry.scripts] 23 | slcp = 'slcp.__main__:main' 24 | -------------------------------------------------------------------------------- /slcp/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is a part of slcp command line application 3 | and is licensed under the MIT License. 4 | Copyright (c) 2019 Kirill Plotnikov 5 | GitHub: https://github.com/pltnk/selective_copy 6 | PyPi: https://pypi.org/project/slcp/ 7 | """ 8 | 9 | 10 | from slcp.cli import ArgParser 11 | from slcp.process import Handler 12 | 13 | 14 | def main(): 15 | """Process files according to the command line arguments.""" 16 | parser = ArgParser() 17 | handler = Handler(parser.args) 18 | print(handler.message) 19 | handler.log.logger.info(handler.message) 20 | handler.process() 21 | handler.log.logger.info(f"Process finished\n") 22 | handler.log.close() 23 | 24 | 25 | if __name__ == "__main__": 26 | main() 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Kirill Plotnikov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /slcp/log.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is a part of slcp command line application 3 | and is licensed under the MIT License. 4 | Copyright (c) 2019 Kirill Plotnikov 5 | GitHub: https://github.com/pltnk/selective_copy 6 | PyPi: https://pypi.org/project/slcp/ 7 | """ 8 | 9 | 10 | import logging 11 | import os 12 | 13 | 14 | class Log: 15 | """Logging handler.""" 16 | 17 | def __init__(self, args, destination): 18 | """ 19 | Create logger instance and slcp.log file 20 | in the destination folder if logging is turned on 21 | in command line arguments. If not create only logger instance. 22 | :param args: argparse.Namespace. Command line arguments. 23 | :param destination: str. Destination folder path. 24 | """ 25 | self.logger = logging.getLogger("slcp") 26 | self.logger.setLevel(logging.CRITICAL) 27 | self.log = args.log 28 | if self.log: 29 | self.logger.setLevel(logging.INFO) 30 | fh = logging.FileHandler( 31 | os.path.join(destination, "slcp.log"), encoding="utf-8" 32 | ) 33 | formatter = logging.Formatter( 34 | "%(asctime)s %(message)s", "%d.%m.%Y %H:%M:%S" 35 | ) 36 | fh.setFormatter(formatter) 37 | self.logger.addHandler(fh) 38 | 39 | def close(self): 40 | """ 41 | Close selective_copy.log file and remove it from logger 42 | file handlers if logging is turned on in command line arguments. 43 | :return: NoneType. 44 | """ 45 | if self.log: 46 | self.logger.handlers[0].close() 47 | self.logger.handlers = [] 48 | -------------------------------------------------------------------------------- /slcp/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is a part of slcp command line application 3 | and is licensed under the MIT License. 4 | Copyright (c) 2019 Kirill Plotnikov 5 | GitHub: https://github.com/pltnk/selective_copy 6 | PyPi: https://pypi.org/project/slcp/ 7 | """ 8 | 9 | 10 | import os 11 | from argparse import ArgumentParser 12 | 13 | 14 | class ArgParser: 15 | """Command line arguments parser.""" 16 | 17 | def __init__(self): 18 | """Parse command line arguments, format given extensions and arguments containing paths.""" 19 | self.parser = ArgumentParser( 20 | usage="slcp ext [ext ...] [-s SRC] [-d DST] [-sc | -dc] " 21 | "[-p] [-i] [-m] [-e FILE [FILE ...]] [-l] [-h]", 22 | description="Copy all files with given extensions from a directory and its subfolders " 23 | "to another directory. " 24 | "A destination folder must be outside of a source folder.", 25 | ) 26 | self.parser.add_argument( 27 | "ext", 28 | nargs="+", 29 | help="one or more extensions, enter without a dot, separate by spaces", 30 | ) 31 | self.parser.add_argument( 32 | "-s", "--source", help="source folder path", metavar="SRC" 33 | ) 34 | self.parser.add_argument( 35 | "-d", "--dest", help="destination folder path", metavar="DST" 36 | ) 37 | group = self.parser.add_mutually_exclusive_group() 38 | group.add_argument( 39 | "-sc", 40 | "--srccwd", 41 | action="store_true", 42 | help="use current working directory as a source", 43 | ) 44 | group.add_argument( 45 | "-dc", 46 | "--dstcwd", 47 | action="store_true", 48 | help="use current working directory as a destination", 49 | ) 50 | self.parser.add_argument( 51 | "-p", 52 | "--preserve", 53 | action="store_true", 54 | help="preserve source folder structure", 55 | ) 56 | self.parser.add_argument( 57 | "-i", 58 | "--invert", 59 | action="store_true", 60 | help="process only files without given extensions", 61 | ) 62 | self.parser.add_argument( 63 | "-m", "--move", action="store_true", help="move files instead of copying" 64 | ) 65 | self.parser.add_argument( 66 | "-e", 67 | "--exclude", 68 | nargs="+", 69 | help="exclude certain files from processing", 70 | metavar="FILE", 71 | ) 72 | self.parser.add_argument( 73 | "-l", 74 | "--log", 75 | action="store_true", 76 | help="create and save log to the destination folder", 77 | ) 78 | self.args = self.parser.parse_args() 79 | self.args.ext = tuple(set(f".{item}" for item in self.args.ext)) 80 | if self.args.source: 81 | self.args.source = os.path.normpath(self.args.source.strip()) 82 | if self.args.dest: 83 | self.args.dest = os.path.normpath(self.args.dest.strip()) 84 | self.args.exclude = ( 85 | tuple(set(self.args.exclude)) if self.args.exclude else tuple() 86 | ) 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Selective Copy v0.3.0 2 | [![Python Version](https://img.shields.io/pypi/pyversions/slcp.svg)](https://www.python.org/downloads/release/python-370/) 3 | [![PyPi Version](https://img.shields.io/pypi/v/slcp.svg)](https://pypi.org/project/slcp/) 4 | [![Downloads](https://pepy.tech/badge/slcp)](https://pepy.tech/project/slcp) 5 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/bdde9d33956642129d82d219328ad5cc)](https://www.codacy.com/app/pltnk/selective_copy?utm_source=github.com&utm_medium=referral&utm_content=pltnk/selective_copy&utm_campaign=Badge_Grade) 6 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 7 | [![License](https://img.shields.io/github/license/pltnk/selective_copy.svg)](https://choosealicense.com/licenses/mit/) 8 | 9 | Simple command line application that copies all files with given extensions from a directory and its subfolders to another directory showing progress bar and remaining files counter.\ 10 | Allows to preserve a source folder structure, to process only files without given extensions, to move files instead of copying, to exclude certain files from processing and to create a log if necessary.\ 11 | Opens a filedialog if source and/or destination are not given in the command line.\ 12 | Creates folders in a destination path if they don't exist. 13 | 14 | ## Installing 15 | 16 | ```pip install slcp``` 17 | 18 | ## Usage 19 | 20 |
21 | slcp ext [ext ...] [-s SRC] [-d DST] [-sc | -dc] [-p] [-i] [-m] [-e FILE [FILE ...]] [-l] [-h]
22 | 
23 | Positional arguments:
24 | ext                         One or more extensions of the files to copy. 
25 |                             Enter extensions without a dot and separate by spaces.
26 | 
27 | Optional arguments:
28 | -s SRC, --source SRC        Source folder path.
29 | -d DST, --dest DST          Destination folder path.
30 | -sc, --srccwd               Use current working directory as a source folder.
31 | -dc, --dstcwd               Use current working directory as a destination folder.
32 | -p, --preserve              Preserve source folder structure.
33 | -i, --invert                Process only files without given extensions.
34 | -m, --move                  Move files instead of copying, be careful with this option.
35 | -e FILE [FILE ...],         Exclude one or more files from processing.
36 | --exclude FILE [FILE ...]   Enter filenames with extensions and separate by spaces.
37 | -l, --log                   Create and save log to the destination folder.
38 | -h, --help                  Show this help message and exit.
39 | 
40 | 41 | ### Examples 42 | 43 | [![asciicast](https://asciinema.org/a/263359.svg)](https://asciinema.org/a/263359?t=2) 44 | 45 | ## Changelog 46 | 47 | ### [v0.3.0](https://github.com/pltnk/selective_copy/releases/tag/v0.3.0) - 2019-08-22 48 | #### Added 49 | - [Black](https://github.com/psf/black) code style 50 | 51 | #### Changed 52 | - The code is now divided into separate modules 53 | - Dots that come with extensions are removed from output folder name. 54 | The reason is that folders with a name starting with a dot are considered as hidden on Linux. 55 | - Log saving indication 56 | - Name of the logfile 57 | 58 | #### Fixed 59 | - Issue when paths like /home/user/test and /home/user/test2 were considered as nested which lead to an error. 60 | 61 | [Compare with v0.2.1](https://github.com/pltnk/selective_copy/compare/v0.2.1...v0.3.0) 62 | 63 | ### [v0.2.1](https://github.com/pltnk/selective_copy/releases/tag/v0.2.1) - 2019-07-16 64 | #### Added 65 | - Changelog 66 | 67 | #### Fixed 68 | - Readme of the project on [PyPI](https://pypi.org/project/slcp/) and [GitHub](https://github.com/pltnk/selective_copy) 69 | 70 | [Compare with v0.2.0](https://github.com/pltnk/selective_copy/compare/v0.2.0...v0.2.1) 71 | 72 | ### [v0.2.0](https://github.com/pltnk/selective_copy/releases/tag/v0.2.0) - 2019-07-15 73 | #### Added 74 | - Support of processing several extensions at once 75 | - --invert option 76 | - --move option 77 | - --exclude option 78 | 79 | [Compare with v0.1.0](https://github.com/pltnk/selective_copy/compare/v0.1.0...v0.2.0) 80 | 81 | ## License 82 | 83 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details 84 | 85 | ## Acknowledgments 86 | 87 | Inspired by the task from [Chapter 9 of Automate the Boring Stuff](https://automatetheboringstuff.com/chapter9/). 88 | -------------------------------------------------------------------------------- /slcp/process.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is a part of slcp command line application 3 | and is licensed under the MIT License. 4 | Copyright (c) 2019 Kirill Plotnikov 5 | GitHub: https://github.com/pltnk/selective_copy 6 | PyPi: https://pypi.org/project/slcp/ 7 | """ 8 | 9 | 10 | import os 11 | import shutil 12 | import sys 13 | from tkinter.filedialog import askdirectory 14 | 15 | from slcp.log import Log 16 | 17 | 18 | class Handler: 19 | """File processing handler.""" 20 | 21 | def __init__(self, args): 22 | """ 23 | Initialize Handler object according to arguments given in the command line. 24 | :param args: argparse.Namespace. Command line arguments. 25 | """ 26 | self.args = args 27 | self.source = self.select_source() 28 | self.destination = self.select_destination() 29 | self.log = Log(args, self.destination) 30 | self.todo = self.get_todo() 31 | self.total = len(self.todo) 32 | self.action = shutil.move if args.move else shutil.copy 33 | self.excluded = ", ".join(args.exclude) 34 | self.processed = 0 35 | self.message = ( 36 | f'{"Moving" if args.move else "Copying"} ' 37 | f'{self.total} file{"s" if self.total > 1 else ""} ' 38 | f'{"without" if args.invert else "with"} ' 39 | f'{", ".join(args.ext)} extension{"s" if len(args.ext) > 1 else ""} ' 40 | f"from {self.source} to {self.destination}" 41 | f'{" preserving source folder structure" if args.preserve else ""}' 42 | f'{f", excluding {self.excluded}" if len(args.exclude) > 0 else ""}' 43 | ) 44 | try: 45 | self.check_for_errors() 46 | except Exception as e: 47 | self.log.logger.error(f"{e}\n") 48 | self.log.close() 49 | sys.exit(e) 50 | 51 | def select_source(self): 52 | """ 53 | Check if the source path is in the command line arguments, 54 | if not ask user for input using filedialog. 55 | :return: str. Source folder path. 56 | """ 57 | if self.args.srccwd: 58 | source = os.getcwd() 59 | else: 60 | if self.args.source is None: 61 | print("Choose a source path.") 62 | source = os.path.normpath(askdirectory()) 63 | print(f"Source path: {source}") 64 | else: 65 | source = self.args.source 66 | return source 67 | 68 | def select_destination(self): 69 | """ 70 | Check if the destination path is in the command line arguments, 71 | if not ask user for input using filedialog. 72 | If the destination path in arguments does not exist create it. 73 | :return: str. Destination folder path. 74 | """ 75 | if self.args.dstcwd: 76 | destination = os.getcwd() 77 | else: 78 | if self.args.dest is None: 79 | print("Choose a destination path.") 80 | destination = os.path.normpath(askdirectory()) 81 | print(f"Destination path: {destination}") 82 | else: 83 | destination = self.args.dest 84 | if not os.path.exists(destination): 85 | os.makedirs(destination) 86 | return destination 87 | 88 | def get_todo(self): 89 | """ 90 | Create a to-do list where each sublist represents one file and contains 91 | source and destination paths for this file. 92 | :return: list of list of str. To-do list. 93 | """ 94 | todo_list = [] 95 | try: 96 | os.chdir(self.source) 97 | for foldername, _, filenames in os.walk(self.source): 98 | if self.args.preserve: 99 | path = os.path.join( 100 | self.destination, 101 | f'{"not_" if self.args.invert else ""}' 102 | f'{"_".join(self.args.ext).replace(".", "")}' 103 | f"_{os.path.basename(self.source)}", 104 | os.path.relpath(foldername), 105 | ) 106 | for filename in filenames: 107 | if ( 108 | filename.endswith(self.args.ext) ^ self.args.invert 109 | ) and filename not in self.args.exclude: 110 | if self.args.preserve: 111 | todo_list.append( 112 | [ 113 | os.path.join(foldername, filename), 114 | os.path.join(path, filename), 115 | ] 116 | ) 117 | else: 118 | todo_list.append( 119 | [ 120 | os.path.join(foldername, filename), 121 | os.path.join(self.destination, filename), 122 | ] 123 | ) 124 | except FileNotFoundError: 125 | pass 126 | return todo_list 127 | 128 | def check_for_errors(self): 129 | """Check for errors, raise corresponding Exception if any errors occurred.""" 130 | if not os.path.exists(self.source): 131 | raise Exception(f"Error: Source path {self.source} does not exist.") 132 | elif self.total == 0: 133 | raise Exception( 134 | f'Error: There are no {", ".join(self.args.ext)} files in {self.source}.' 135 | ) 136 | elif os.path.commonpath([self.source, self.destination]) == self.source: 137 | raise Exception( 138 | f"Error: A destination folder must be outside of source folder. " 139 | f"Paths given: source - {self.source} | destination - {self.destination}." 140 | ) 141 | else: 142 | pass 143 | 144 | def process(self): 145 | """ 146 | Copy or move files according to source and destination paths 147 | given in self.todo_list. Each item in this list represents one file. 148 | item[0] - source, item[1] - destination. 149 | :return: NoneType. 150 | """ 151 | self._show_progress_bar() 152 | for item in self.todo: 153 | if not os.path.exists(os.path.dirname(item[1])): 154 | os.makedirs(os.path.dirname(item[1])) 155 | try: 156 | if not os.path.exists(item[1]): 157 | self.log.logger.info(f"{item[0]}") 158 | self.action(item[0], item[1]) 159 | else: 160 | new_filename = f"{os.path.basename(os.path.dirname(item[0]))}_{os.path.basename(item[1])}" 161 | self.log.logger.info(f"*{item[0]} saving it as {new_filename}") 162 | self.action( 163 | item[0], os.path.join(os.path.dirname(item[1]), new_filename) 164 | ) 165 | except Exception as e: 166 | self.log.logger.error( 167 | f"*Unable to process {item[0]}, an error occurred: {e}" 168 | ) 169 | self.processed += 1 170 | self._show_progress_bar() 171 | 172 | def _show_progress_bar(self): 173 | """ 174 | Print progress bar. 175 | :return: NoneType. 176 | """ 177 | try: 178 | term_width = os.get_terminal_size(0)[0] 179 | except OSError: 180 | term_width = 80 181 | length = term_width - (len(str(self.total)) + 33) 182 | percent = round(100 * (self.processed / self.total)) 183 | filled = int(length * self.processed // self.total) 184 | bar = f'|{"=" * filled}{"-" * (length - filled)}|' if term_width > 50 else "" 185 | suffix = ( 186 | f"Files left: {self.total - self.processed} " 187 | if self.processed < self.total 188 | else "Done, log saved" 189 | if self.args.log 190 | else "Done " 191 | ) 192 | print(f"\rProgress: {bar} {percent}% {suffix}", end="\r", flush=True) 193 | if self.processed == self.total: 194 | print() 195 | --------------------------------------------------------------------------------