diff --git a/sorted-copy/setup.cfg b/sorted-copy/setup.cfg new file mode 100644 index 0000000..94b2dca --- /dev/null +++ b/sorted-copy/setup.cfg @@ -0,0 +1,15 @@ +[metadata] +name = sorted-copy +version = 0.1 +description = Copy files and directories recursively sorted and one by one to the destination. Mainly useful for USB sticks/MP3 players. + +[options] +scripts = sorted-co.py +install_requires = + click + + +[flake8] +max-line-length = 120 +exclude = .git,__pycache__,*.egg-info,*lib/python* +ignore = E241,E741,W503,W504 diff --git a/sorted-copy/setup.py b/sorted-copy/setup.py new file mode 100644 index 0000000..056ba45 --- /dev/null +++ b/sorted-copy/setup.py @@ -0,0 +1,4 @@ +import setuptools + + +setuptools.setup() diff --git a/sorted-copy/sorted-co.py b/sorted-copy/sorted-co.py new file mode 100755 index 0000000..95cc758 --- /dev/null +++ b/sorted-copy/sorted-co.py @@ -0,0 +1,111 @@ +#!/usr/bin/python3 +from pathlib import Path +import shutil +from typing import Any, Generator, Tuple, Union + +import click + + +def _to_pathlib_path(ctx: click.Context, param: Any, value: str) \ + -> Union[Path, Tuple[Path]]: + if isinstance(value, tuple): + return tuple(Path(v) for v in value) + else: + return Path(value) + + +def get_directories(directory: Path) -> Generator[Path, None, None]: + """Return all directories below given directory in sorted order + + This returns first all directories of a directory before returning + subdirectories of the sorted subdirectories. + """ + directories = [directory] + for d in directories: + for p in sorted(d.iterdir()): + if p.is_dir(): + directories.append(p) + yield p + + +def get_files(directory: Path) -> Generator[Path, None, None]: + """Traverse the given directory and its sorted subdirectory and yield files + + See `get_directories()` for a hint to the order of directories traversed. + """ + if not directory.is_dir(): + msg = f"get_files() needs a directory but got {directory}" + raise RuntimeError(msg) + + directories = [directory] + for d in directories: + for p in sorted(d.iterdir()): + if p.is_dir(): + directories.append(p) + elif p.is_file(): + yield p + + +def relative_path(base: Path, p: Path) -> Path: + """Return the part of `p` relative to `base` as relative Path""" + for a, b in zip(base.parts, p.parts): + if a != b: + raise ValueError('Base {} is not the base of {}'.format(a, b)) + + return Path('/'.join(p.parts[len(base.parts):])) + + +@click.command() +@click.argument('srces', nargs=-1, type=click.Path(exists=True, file_okay=True, + dir_okay=True, resolve_path=True), callback=_to_pathlib_path, + required=True) +@click.argument('dest', type=click.Path(exists=True, dir_okay=True, + file_okay=False, resolve_path=True), callback=_to_pathlib_path, + required=True) +@click.option('--verbose', is_flag=True) +def main(srces: Tuple[Path], dest: Path, verbose: bool) -> None: + """Copy `srces` recursively and sorted one by one to `dest + + This is mainly useful for USB sticks and MP3 players that take the order of + files created in the filesystem instead of the order of the sorted names to + play files. + """ + for src in srces: + if src.is_file(): + # copy this file over directly + rel_path = Path(src.name) + dest_path = dest / rel_path + if verbose: + click.echo('copy {} -> {}'.format(src, dest_path)) + shutil.copy(src, dest_path) + continue + + if src.is_dir(): + dest_path = dest / src.name + if verbose: + click.echo('mkdir {}'.format(dest_path)) + dest_path.mkdir() + + # create all directories first + for p in get_directories(src): + rel_path = Path(src.name) / relative_path(src, p) + dest_path = dest / rel_path + if verbose: + click.echo('mkdir {}'.format(dest_path)) + dest_path.mkdir() + + # copy all files over + for p in get_files(src): + rel_path = Path(src.name) / relative_path(src, p) + dest_path = dest / rel_path + if verbose: + click.echo('copy {} -> {}'.format(p, dest_path)) + shutil.copy(p, dest_path) + continue + + click.echo(f"Warning: Not copying {src}. Neither file nor directory") + + +if __name__ == '__main__': + import sys + sys.exit(main())