A script I wrote to sort my ~/Downloads/ directory content into different file extensions category directories.

It’s constantly a work in process and a way I use to test different technologies and features of new python versions.

I’ve taken inspiration from so many different scripts and projects so it’s hard to write them all.

#!/usr/bin/env python3

import argparse
import logging
import sys
from pathlib import Path

categories = {
    "Images": {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".svg", ".webp", ".heic"},
    "3D Printing": {".stl", ".gcode", ".3mf", ".f3d", ".stp"},
    "Documents": {
        ".pdf",
        ".doc",
        ".docx",
        ".xls",
        ".xlsx",
        ".ppt",
        ".pptx",
        ".txt",
        ".csv",
        ".rtf",
        ".odt",
        ".pages",
        ".key",
        ".md",
        ".opml",
        ".tex",
        ".bib",
        ".odp",
    },
    "Executables": {
        ".exe",
        ".msi",
        ".bat",
        ".sh",
        ".dmg",
        ".app",
        ".deb",
        ".rpm",
        ".apk",
        ".jar",
        ".pkg",
        ".xpi",
        ".appimage",
    },
    "Compressed": {".zip", ".rar", ".tar", ".gz", ".7z"},
    "Videos": {".mp4", ".mov", ".wmv", ".flv", ".avi", ".mkv", ".webm", ".mpg", ".mpeg", ".3gp"},
    "Audio": {".mp3", ".wav", ".aac", ".ogg", ".flac", ".m4a", ".wma"},
    "Code": {
        ".py",
        ".ipynb",
        ".java",
        ".cpp",
        ".c",
        ".h",
        ".cs",
        ".xml",
        ".json",
        ".yaml",
        ".yml",
        ".sql",
        ".rb",
        ".pl",
        ".cmd",
        ".ps1",
        ".dockerfile",
        ".fig",
        ".js",
        ".html",
        ".css",
        ".php",
        ".sqlite",
        ".db",
    },
    "Others": {".kdbx"},
}


def get_config() -> argparse.Namespace:
    """Use argparse to get configuration."""
    parser = argparse.ArgumentParser(
        prog="directory-file-sorter.py",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        description="Sort directory files into category directories.",
    )
    parser.add_argument(
        "-v",
        "--verbose",
        action="count",
        dest="verbosity",
        default=0,
        help="verbose output (repeat for increased verbosity)",
    )
    parser.add_argument(
        "-q",
        "--quiet",
        action="store_const",
        const=-1,
        default=0,
        dest="verbosity",
        help="quiet output (show errors only)",
    )
    parser.add_argument(
        dest="directory",
        type=lambda p: Path(p).absolute(),
    )
    return parser.parse_args()


def setup_logging(verbosity: int) -> None:
    """Setups logging with the correct verbosity level."""
    base_loglevel = 20
    verbosity = min(verbosity, 2)
    loglevel = base_loglevel - (verbosity * 10)
    logging.basicConfig(level=loglevel, format="%(message)s")


def create_destination_directories(base_path: Path) -> None:
    """Create all the sort destination directories."""
    logger = logging.getLogger(__name__)

    for category in categories:
        category_directory = base_path / category
        if not category_directory.exists():
            category_directory.mkdir()
            logger.info("Created category directory %s.", category_directory)


def get_file_category(file_extension: str) -> str:
    """Return category for specified extension."""
    for category, extensions in categories.items():
        if file_extension.lower() in extensions:
            return category
    return "Others"


def move_file(src_path: Path, dest_directory: Path) -> None:
    """Move file into destination directory."""
    logger = logging.getLogger(__name__)

    dest_path = dest_directory / src_path.name
    counter = 1
    while dest_path.exists():
        dest_path = dest_directory / f"{src_path.stem}_{counter}{src_path.suffix}"
        counter += 1

    logger.debug("Moving to %s.", dest_path)
    src_path.rename(dest_path)


def main() -> None:
    """System main loop."""
    conf = get_config()

    setup_logging(conf.verbosity)

    logger = logging.getLogger(__name__)

    if not conf.directory.exists():
        logger.critical("Directory %s does not exist.", conf.directory)
        sys.exit(1)

    create_destination_directories(conf.directory)

    for file in conf.directory.iterdir():
        if file.is_file():
            logger.debug("Handling file %s.", file)
            category = get_file_category(file.suffix)
            logger.debug("Categoried as %s.", category)
            move_file(file, conf.directory / category)


if __name__ == "__main__":
    main()