import click

from app.utils import scanner, organizer


@click.group()
def cli():
    pass


cli.add_command(scanner.scan_files)
cli.add_command(scanner.scan_subdirs)
cli.add_command(scanner.build_catalog)
cli.add_command(scanner.build_catalog_recursively)
cli.add_command(scanner.build_tree)
cli.add_command(scanner.build_pretty_tree)
cli.add_command(scanner.search_by_name)
cli.add_command(scanner.search_by_name_recursively)

cli.add_command(organizer.organize_files)
cli.add_command(organizer.organize_files_recursively)

app/__main__.py
Mithras11/command-line-file-manager
LOG_FORMAT = "%(message)s"

DELIMITER = "\n=========================================\n"

NO_FILES = "The given directory '{dir_path}' contains no files:\n\n" + DELIMITER

LISTED_FILES = (
    "The given directory '{dir_path}' contains the following files:\n"
    + "\n{files_list}\n"
    + DELIMITER
)

NO_SUBDIRS = "The given directory '{dir_path}' contains no nested subdirectories:\n\n" + DELIMITER

NESTED_SUBDIRS = (
    "The given directory '{dir_path}' contains the following subdirectories:\n"
    + "\n{subdir_list}\n"
    + DELIMITER
)

NOT_FOUND = "Nothing found.\n"
FOUND_BY_NAME = "Inside directory '{dir_path}' the given keyword '{keyword}' was found\n"
FOUND_FILES_BY_NAME = "- in the following file names:\n" + "\t- {files_list}\n"
FOUND_DIRS_BY_NAME = "- in the following subdirectory names:\n" + "\t- {subdir_list}\n"

CREATE_FOLDER = "Creating folder {target_dir}"
MOVE_FILE = "Moving {entry} to {target_dir}"

INSIDE_DIR = "----- Inside {abs_dir_path} -----"

SKIP_FILE = "Skipping {entry}"
SKIP_DIR = "Skipping {entry} directory"
MOVE_FILE_TO_ROOT_DIR = "Moving {entry} to root directory"
SKIP_DIR_AND_MOVE = "Moving {entry} directory without stepping inside"

REMOVE_DIR = "Removing {abs_dir_path}"

app/logs/config/log_messages.py
Mithras11/command-line-file-manager
FILES = "/list.log"
CATALOG = "/catalog.log"
RECURSIVE_CATALOG = "/recursive_catalog.log"
TREE = "/tree.log"
SEARCH = "/search.log"
ORGANIZE = "/organize.log"
RECURSIVE_ORGANIZE_BASE = "organize_recursively.log"
RECURSIVE_ORGANIZE = "/" + RECURSIVE_ORGANIZE_BASE

app/logs/config/log_output.py
Mithras11/command-line-file-manager
BASIC = "basic_logger"
CATALOG = "catalog_logger"
RECURSIVE = "recursive_catalog_logger"
TREE = "tree_logger"
SEARCH = "search_logger"
ORGANIZE = "organize_logger"
RECURSIVE_ORGANIZE = "recursive_organize_logger"

app/logs/config/logger_types.py
Mithras11/command-line-file-manager
import logging

from app.logs.config import logger_types, log_output, log_messages


class LoggerFactory:
    @classmethod
    def get_logger(cls, logger_type: str, output_dir: str, save_output: bool) -> logging.Logger:
        match logger_type:
            case logger_types.BASIC:
                return cls._configure_logger(logger_types.BASIC, output_dir + log_output.FILES)
            case logger_types.CATALOG:
                return cls._configure_logger(logger_types.CATALOG, output_dir + log_output.CATALOG)
            case logger_types.RECURSIVE:
                return cls._configure_logger(
                    logger_types.RECURSIVE, output_dir + log_output.RECURSIVE_CATALOG
                )
            case logger_types.TREE:
                return cls._configure_logger(logger_types.TREE, output_dir + log_output.TREE)
            case logger_types.SEARCH:
                return cls._configure_logger(logger_types.SEARCH, output_dir + log_output.SEARCH)
            case logger_types.ORGANIZE:
                return cls._configure_organize_logger(
                    logger_types.ORGANIZE, save_output, output_dir + log_output.ORGANIZE
                )
            case logger_types.RECURSIVE_ORGANIZE:
                return cls._configure_organize_logger(
                    logger_types.ORGANIZE, save_output, output_dir + log_output.RECURSIVE_ORGANIZE
                )

    @staticmethod
    def _configure_logger(logger_name: str, output_file_name: str) -> logging.Logger:
        logger = logging.getLogger(logger_name)
        file_handler = logging.FileHandler(filename=output_file_name, mode="w")
        formatter = logging.Formatter(log_messages.LOG_FORMAT)
        file_handler.setFormatter(formatter)
        logger.addHandler(file_handler)
        logger.setLevel(logging.INFO)
        return logger

    @staticmethod
    def _configure_organize_logger(
        logger_name: str, save_output: bool, output_file_name: str
    ) -> logging.Logger:
        logger = logging.getLogger(logger_name)
        stream_handler = logging.StreamHandler()
        formatter = logging.Formatter(log_messages.LOG_FORMAT)
        stream_handler.setFormatter(formatter)
        logger.addHandler(stream_handler)

        if save_output:
            file_handler = logging.FileHandler(filename=output_file_name, mode="a")
            file_handler.setFormatter(formatter)
            logger.addHandler(file_handler)

        logger.setLevel(logging.INFO)
        return logger

app/logs/logger_factory.py
Mithras11/command-line-file-manager
from typing import Optional

from app.logs.logger_factory import LoggerFactory


def save_logs_to_file(
    output_dir: Optional[str], dir_path: str, message: str, logger_type: str
) -> None:
    if not output_dir:
        output_dir = dir_path
    LoggerFactory.get_logger(logger_type, output_dir, True).info(message)

app/utils/common.py
Mithras11/command-line-file-manager
TARGET_MAP = {
    # images
    ".jpg": "images",
    ".jpeg": "images",
    ".png": "images",
    ".gif": "images",
    ".svg": "images",
    ".heic": "images",
    ".tif": "images",
    ".tiff": "images",
    # videos
    ".mp4": "videos",
    ".mpeg-4": "videos",
    ".mov": "videos",
    ".avi": "videos",
    ".webm": "videos",
    # music
    ".mp3": "music",
    ".m4a": "music",
    ".wav": "music",
    ".flac": "music",
    ".ogg": "music",
    # books
    ".pdf": "books",
    ".djvu": "books",
    ".epub": "books",
    ".fb2": "books",
    # docs
    ".xlsx": "docs",
    ".csv": "docs",
    ".docx": "docs",
    ".doc": "docs",
    ".rtf": "docs",
    ".txt": "docs",
    ".pptx": "docs",
    ".ppt": "docs",
    ".key": "docs",
    # archive
    ".rar": "archive",
    ".zip": "archive",
    ".gz": "archive",
    ".tar": "archive",
    # torrent
    ".torrent": "torrent",
    ####
    "default": "other",
    "hidden": ".hidden",
}

BACKUP_FILE_NAME = "backup"
SKIPPED_BACKUP_FILES = ["backup.tar", "backup.zip"]

app/utils/config/constants.py
Mithras11/command-line-file-manager
import logging
import os
import shutil
from typing import List

import click

import app.utils.config.constants
from app.logs.config import log_messages, logger_types, log_output
from app.logs.logger_factory import LoggerFactory
from app.utils.config.constants import TARGET_MAP


@click.command()
@click.argument("dir_path", type=click.STRING)
@click.option(
    "-x",
    "--exclude",
    type=click.STRING,
    default=None,
    show_default=True,
    help="Single or multiple file extensions to be skipped separated by comma. "
    "E.g. --exclude .pdf,.mp3",
)
@click.option(
    "-h",
    "--hidden",
    is_flag=True,
    help="Include hidden files.",
)
@click.option(
    "-b",
    "--backup",
    is_flag=True,
    help="Create an archived file of the given directory before re-organizing it.",
)
@click.option(
    "-f",
    "--archive-format",
    type=click.Choice(["tar", "zip"], case_sensitive=False),
    default=None,
    show_default=True,
    help="Archive format for backup file.",
)
@click.option(
    "-s",
    "--save",
    is_flag=True,
    help="Save log message to file.",
)
@click.option(
    "-o",
    "--output",
    type=click.STRING,
    default=None,
    show_default=True,
    help="Path to output directory for the saved log file",
)
def organize_files(
    dir_path: str,
    exclude: str,
    hidden: bool,
    backup: bool,
    archive_format: str,
    save: bool,
    output: str,
) -> None:
    """
    Search recursively by NAME inside DIR_PATH
    """

    exclude_list = exclude.split(",") if exclude else []

    dir_list = os.listdir(dir_path)
    abs_dir_path = os.path.abspath(dir_path)

    if not output:
        output = dir_path
    logger = LoggerFactory.get_logger(logger_types.ORGANIZE, output, save)

    if backup:
        shutil.make_archive(
            base_name=os.path.join(abs_dir_path, app.utils.config.constants.BACKUP_FILE_NAME),
            format=archive_format,
            root_dir=os.path.dirname(abs_dir_path),
            base_dir=os.path.basename(abs_dir_path),
            verbose=True,
        )

    for entry in dir_list:
        abs_entry_path = os.path.join(abs_dir_path, entry)
        if os.path.isfile(abs_entry_path):
            file_extension = os.path.splitext(entry)[1]
            if (not hidden and entry.startswith(".")) or file_extension in exclude_list:
                logger.info(log_messages.SKIP_FILE.format(entry=entry))
                continue

            if entry.startswith("."):
                target_dir_name = TARGET_MAP["hidden"]
            else:
                target_dir_name = TARGET_MAP.get(file_extension, TARGET_MAP["default"])

            target_dir = os.path.join(abs_dir_path, target_dir_name)
            if not os.path.exists(target_dir):
                logger.info(log_messages.CREATE_FOLDER.format(target_dir=target_dir))
                os.makedirs(target_dir)
            logger.info(log_messages.MOVE_FILE.format(entry=entry, target_dir=target_dir))
            shutil.move(abs_entry_path, os.path.join(target_dir, entry))


@click.command()
@click.argument("dir_path", type=click.STRING)
@click.option(
    "-x",
    "--exclude",
    type=click.STRING,
    default=None,
    show_default=True,
    help="Single or multiple file extensions to be skipped separated by comma. "
    "E.g. --exclude .pdf,.mp3",
)
@click.option(
    "--exclude-dir",
    type=click.STRING,
    default=None,
    show_default=True,
    help="Single or multiple directory names to be skipped separated by comma. "
    "E.g. --exclude-dir audio,video",
)
@click.option(
    "--flat",
    is_flag=True,
    help="Move all files to target directories inside parent folder.",
)
@click.option(
    "-h",
    "--hidden",
    is_flag=True,
    help="Include hidden files and folders.",
)
@click.option(
    "-b",
    "--backup",
    is_flag=True,
    help="Create an archived file of the given directory before re-organizing it.",
)
@click.option(
    "-f",
    "--archive-format",
    type=click.Choice(["tar", "zip"], case_sensitive=False),
    default=None,
    show_default=True,
    help="Archive format for backup file.",
)
@click.option(
    "-s",
    "--save",
    is_flag=True,
    help="Save log message to file.",
)
@click.option(
    "-o",
    "--output",
    type=click.STRING,
    default=None,
    show_default=True,
    help="Path to output directory for the saved log file",
)
def organize_files_recursively(
    dir_path: str,
    exclude: str,
    exclude_dir: str,
    flat: bool,
    hidden: bool,
    backup: bool,
    archive_format: str,
    save: bool,
    output: str,
) -> None:
    """
    Search recursively by NAME inside DIR_PATH
    """

    abs_dir_path = os.path.abspath(dir_path)
    exclude_list = exclude.split(",") if exclude else []
    exclude_dir_list = exclude_dir.split(",") if exclude_dir else []

    if not output:
        output = dir_path
    logger = LoggerFactory.get_logger(logger_types.RECURSIVE_ORGANIZE, output, save)

    if backup:
        shutil.make_archive(
            base_name=os.path.join(abs_dir_path, app.utils.config.constants.BACKUP_FILE_NAME),
            format=archive_format,
            root_dir=os.path.dirname(abs_dir_path),
            base_dir=os.path.basename(abs_dir_path),
            verbose=True,
        )

    if flat:
        root_dir = os.path.join(abs_dir_path, "")
        _handle_files_by_flattening_subdirs(
            abs_dir_path, "", root_dir, exclude_list, exclude_dir_list, hidden, logger
        )
    else:
        _handle_files(abs_dir_path, "", exclude_list, exclude_dir_list, hidden, logger)


def _handle_files(
    parent_dir: str,
    subdir_path: str,
    exclude_list: List[str],
    exclude_dir_list: List[str],
    hidden: bool,
    logger: logging.Logger,
) -> None:
    abs_dir_path = os.path.join(parent_dir, subdir_path)
    dir_list = os.listdir(abs_dir_path)

    logger.info(log_messages.INSIDE_DIR.format(abs_dir_path=abs_dir_path))
    nested_dirs = []
    for entry in dir_list:
        abs_entry_path = os.path.join(abs_dir_path, entry)
        if os.path.isfile(abs_entry_path):
            if (
                entry == log_output.RECURSIVE_ORGANIZE_BASE
                or entry in app.utils.config.constants.SKIPPED_BACKUP_FILES
            ):
                continue

            file_extension = os.path.splitext(entry)[1]
            if not hidden and entry.startswith(".") or file_extension in exclude_list:
                logger.info(log_messages.SKIP_FILE.format(entry=entry))
                continue

            if entry.startswith("."):
                target_dir_name = TARGET_MAP["hidden"]
            else:
                target_dir_name = TARGET_MAP.get(file_extension, TARGET_MAP["default"])

            target_dir = os.path.join(abs_dir_path, target_dir_name)
            if not os.path.exists(target_dir):
                logger.info(log_messages.CREATE_FOLDER.format(target_dir=target_dir))
                os.makedirs(target_dir)

            logger.info(log_messages.MOVE_FILE.format(entry=entry, target_dir=target_dir))
            shutil.move(abs_entry_path, os.path.join(target_dir, entry))
        elif os.path.isdir(abs_entry_path):
            if entry.startswith(".") or entry in exclude_dir_list:
                logger.info(log_messages.SKIP_DIR.format(entry=entry))
                continue
            nested_dirs.append(abs_entry_path)

    for nested_dir in nested_dirs:
        _handle_files(abs_dir_path, nested_dir, exclude_list, exclude_dir_list, hidden, logger)


def _handle_files_by_flattening_subdirs(
    parent_dir: str,
    subdir_path: str,
    root_dir: str,
    exclude_list: List[str],
    exclude_dir_list: List[str],
    hidden: bool,
    logger: logging.Logger,
) -> None:
    abs_dir_path = os.path.join(parent_dir, subdir_path)
    dir_list = os.listdir(abs_dir_path)

    logger.info(log_messages.INSIDE_DIR.format(abs_dir_path=abs_dir_path))
    nested_dirs = []
    for entry in dir_list:
        abs_entry_path = os.path.join(abs_dir_path, entry)
        if os.path.isfile(abs_entry_path):
            if (
                entry == log_output.RECURSIVE_ORGANIZE_BASE
                or entry in app.utils.config.constants.SKIPPED_BACKUP_FILES
            ):
                continue

            file_extension = os.path.splitext(entry)[1]
            if not hidden and entry.startswith(".") or file_extension in exclude_list:
                logger.info(log_messages.MOVE_FILE_TO_ROOT_DIR.format(entry=entry))
                shutil.move(abs_entry_path, os.path.join(root_dir, entry))
                continue

            if entry.startswith("."):
                target_dir_name = TARGET_MAP["hidden"]
            else:
                target_dir_name = TARGET_MAP.get(file_extension, TARGET_MAP["default"])

            target_dir = os.path.join(root_dir, target_dir_name)
            if not os.path.exists(target_dir):
                logger.info(log_messages.CREATE_FOLDER.format(target_dir=target_dir))
                os.makedirs(target_dir)

            logger.info(log_messages.MOVE_FILE.format(entry=entry, target_dir=target_dir))
            shutil.move(abs_entry_path, os.path.join(target_dir, entry))
        elif os.path.isdir(abs_entry_path):
            if entry.startswith(".") or entry in exclude_dir_list:
                logger.info(log_messages.SKIP_DIR_AND_MOVE.format(entry=entry))
                shutil.move(abs_entry_path, os.path.join(root_dir, entry))
                continue
            nested_dirs.append(abs_entry_path)

    for nested_dir in nested_dirs:
        _handle_files_by_flattening_subdirs(
            abs_dir_path, nested_dir, root_dir, exclude_list, exclude_dir_list, hidden, logger
        )

    is_not_root_dir = abs_dir_path != root_dir
    is_not_one_level_nested_dir = not os.path.join(os.path.dirname(abs_dir_path), "") == root_dir
    is_not_target_dir = not os.path.basename(subdir_path) in TARGET_MAP.values()

    if is_not_root_dir and (is_not_one_level_nested_dir or is_not_target_dir):
        logger.info(log_messages.REMOVE_DIR.format(abs_dir_path=abs_dir_path))
        os.rmdir(abs_dir_path)

app/utils/organizer.py
Mithras11/command-line-file-manager
import os
from typing import Optional, List

import click
import emoji
from directory_tree import display_tree

from app.logs.config import log_messages, logger_types
from app.utils.common import save_logs_to_file


@click.command()
@click.argument("dir_path", type=click.STRING)
@click.option(
    "--sort",
    type=click.Choice(["name", "size", "date", "modified", "type"], case_sensitive=False),
    default="name",
    show_default=True,
    help="Sorting criteria.",
)
@click.option(
    "--desc",
    is_flag=True,
    help="Display result in descending order.",
)
@click.option(
    "-s",
    "--save",
    is_flag=True,
    help="Save log message to file.",
)
@click.option(
    "-o",
    "--output",
    type=click.STRING,
    default=None,
    show_default=True,
    help="Path to output directory for the saved log file",
)
def scan_files(dir_path: str, sort: str, desc: bool, save: bool, output: str) -> None:
    """DIR_PATH: Path to directory to be scanned"""

    dir_list = os.listdir(dir_path)
    files_list = [entry for entry in dir_list if os.path.isfile(os.path.join(dir_path, entry))]

    if not files_list:
        message = log_messages.NO_FILES.format(dir_path=dir_path)
    else:
        _sort_entries_list(dir_path, files_list, sort, desc)
        message = log_messages.LISTED_FILES.format(
            dir_path=dir_path, files_list="\n".join(files_list)
        )
    click.echo(message)
    if save:
        save_logs_to_file(output, dir_path, message, logger_types.BASIC)


@click.command()
@click.argument("dir_path", type=click.STRING)
@click.option(
    "--sort",
    type=click.Choice(["name", "size", "date", "modified"], case_sensitive=False),
    default="name",
    show_default=True,
    help="Sorting criteria.",
)
@click.option(
    "--desc",
    is_flag=True,
    help="Display result in descending order.",
)
@click.option(
    "-s",
    "--save",
    is_flag=True,
    help="Save log message to file.",
)
@click.option(
    "-o",
    "--output",
    type=click.STRING,
    default=None,
    show_default=True,
    help="Path to output directory for the saved log file",
)
def scan_subdirs(dir_path: str, sort: str, desc: bool, save: bool, output: str) -> None:
    """DIR_PATH: Path to directory to be scanned"""

    dir_list = os.listdir(dir_path)
    subdir_list = [entry for entry in dir_list if os.path.isdir(os.path.join(dir_path, entry))]

    if not subdir_list:
        message = log_messages.NO_SUBDIRS.format(dir_path=dir_path)
    else:
        _sort_entries_list(dir_path, subdir_list, sort, desc)
        message = log_messages.NESTED_SUBDIRS.format(
            dir_path=dir_path, subdir_list="\n".join(subdir_list)
        )
    click.echo(message)
    if save:
        save_logs_to_file(output, dir_path, message, logger_types.BASIC)


def _sort_entries_list(dir_path: str, entries_list: List[str], criteria: str, desc: bool) -> None:
    match criteria:
        case "name":
            sort_func = None
        case "size":
            sort_func = lambda file: os.path.getsize(os.path.join(dir_path, file))
        case "date":
            sort_func = lambda file: os.path.getctime(os.path.join(dir_path, file))
        case "modified":
            sort_func = lambda file: os.path.getmtime(os.path.join(dir_path, file))
        case "type":
            sort_func = lambda file: os.path.splitext(os.path.join(dir_path, file))[1]
        case _:
            sort_func = None
    entries_list.sort(key=sort_func, reverse=desc)


#############################################################
@click.command()
@click.argument("dir_path", type=click.STRING)
@click.option(
    "-s",
    "--save",
    is_flag=True,
    help="Save log message to file.",
)
@click.option(
    "-o",
    "--output",
    type=click.STRING,
    default=None,
    show_default=True,
    help="Path to output directory for the saved log file",
)
def build_catalog(dir_path: str, save: bool, output: str):
    """DIR_PATH: Path to directory to be scanned"""

    dir_list = os.listdir(dir_path)
    files_list = []
    nested_dirs = []
    for entry in dir_list:
        if os.path.isfile(os.path.join(dir_path, entry)):
            files_list.append(entry)
        else:
            nested_dirs.append(entry)

    message = _get_catalog_messages(dir_path, files_list, nested_dirs)
    click.echo(message)
    if save:
        save_logs_to_file(output, dir_path, message, logger_types.CATALOG)


@click.command()
@click.argument("dir_path", type=click.STRING)
@click.option(
    "-s",
    "--save",
    is_flag=True,
    help="Save log message to file.",
)
@click.option(
    "-o",
    "--output",
    type=click.STRING,
    default=None,
    show_default=True,
    help="Path to output directory for the saved log file",
)
def build_catalog_recursively(dir_path: str, save: bool, output: str) -> None:
    """DIR_PATH: Path to directory to be scanned"""

    message = _get_recursive_catalog(dir_path, None)
    click.echo(message)
    if save:
        save_logs_to_file(output, dir_path, message, logger_types.RECURSIVE)


def _get_recursive_catalog(root_dir: str, subdir_path: Optional[str]) -> str:
    if subdir_path is None:
        subdir_path = root_dir
    subdir_list = os.listdir(subdir_path)

    files_list = []
    nested_dirs = []
    inner_msg = ""
    for entry in subdir_list:
        entry_path = os.path.join(subdir_path, entry)
        if os.path.isfile(entry_path):
            files_list.append(entry)
        else:
            nested_dirs.append(entry)
            inner_msg += _get_recursive_catalog(root_dir, entry_path)

    message = _get_catalog_messages(subdir_path, files_list, nested_dirs)
    return message + inner_msg


def _get_catalog_messages(dir_path: str, files_list: List[str], nested_dirs: List[str]) -> str:
    if not files_list:
        files_msg = log_messages.NO_FILES.format(dir_path=dir_path)
    else:
        files_msg = log_messages.LISTED_FILES.format(
            dir_path=dir_path, files_list="\n".join(files_list)
        )

    if not nested_dirs:
        nested_dirs_msg = log_messages.NO_SUBDIRS.format(dir_path=dir_path)
    else:
        nested_dirs_msg = log_messages.NESTED_SUBDIRS.format(
            dir_path=dir_path, subdir_list="\n".join(nested_dirs)
        )
    return files_msg + nested_dirs_msg


#############################################################
@click.command()
@click.argument("dir_path", type=click.STRING)
@click.option(
    "-s",
    "--save",
    is_flag=True,
    help="Save log message to file.",
)
@click.option(
    "-o",
    "--output",
    type=click.STRING,
    default=None,
    show_default=True,
    help="Path to output directory for the saved log file",
)
def build_tree(dir_path: str, save: bool, output: str) -> None:
    """DIR_PATH: Path to directory to be scanned"""

    folder_emoji = emoji.emojize(":file_folder:")
    file_emoji = emoji.emojize(":page_with_curl:")

    tree_msg = ""
    for root, _, files in os.walk(dir_path):
        level = root.count(os.sep) - 1
        indent = " " * 4 * level
        tree_msg += "{}{} {}/\n".format(indent, folder_emoji, os.path.abspath(root))
        sub_indent = " " * 4 * (level + 1)
        for file in files:
            tree_msg += "{}{} {}\n".format(sub_indent, file_emoji, file)

    click.echo(tree_msg)
    if save:
        save_logs_to_file(output, dir_path, tree_msg, logger_types.TREE)


@click.command()
@click.argument("dir_path", type=click.STRING)
@click.option(
    "-h",
    "--hidden",
    is_flag=True,
    help="Include hidden files and folders.",
)
@click.option(
    "-s",
    "--save",
    is_flag=True,
    help="Save log message to file.",
)
@click.option(
    "-o",
    "--output",
    type=click.STRING,
    default=None,
    show_default=True,
    help="Path to output directory for the saved log file",
)
def build_pretty_tree(dir_path: str, hidden: bool, save: bool, output: str) -> None:
    """DIR_PATH: Path to directory to be scanned"""

    tree_msg = display_tree(dir_path, string_rep=True, show_hidden=hidden)
    click.echo(tree_msg)
    if save:
        save_logs_to_file(output, dir_path, tree_msg, logger_types.TREE)


#############################################################
@click.command()
@click.argument("dir_path", type=click.STRING)
@click.argument("name", type=click.STRING)
@click.option(
    "-s",
    "--save",
    is_flag=True,
    help="Save log message to file.",
)
@click.option(
    "-o",
    "--output",
    type=click.STRING,
    default=None,
    show_default=True,
    help="Path to output directory for the saved log file",
)
def search_by_name(dir_path: str, name: str, save: bool, output: str) -> None:
    """
    Search by NAME inside DIR_PATH
    """

    dir_list = os.listdir(dir_path)
    files_list = []
    nested_dirs = []
    for entry in dir_list:
        if name in entry:
            if os.path.isfile(os.path.join(dir_path, entry)):
                files_list.append(entry)
            else:
                nested_dirs.append(entry)

    if not files_list and not nested_dirs:
        log_msg = log_messages.NOT_FOUND
    else:
        log_msg = log_messages.FOUND_BY_NAME.format(dir_path=dir_path, keyword=name)
        if files_list:
            log_msg += log_messages.FOUND_FILES_BY_NAME.format(
                files_list="\n\t- ".join(files_list),
            )
        if nested_dirs:
            log_msg += log_messages.FOUND_DIRS_BY_NAME.format(
                subdir_list="\n\t- ".join(nested_dirs),
            )
    click.echo(log_msg)
    if save:
        save_logs_to_file(output, dir_path, log_msg, logger_types.SEARCH)


@click.command()
@click.argument("dir_path", type=click.STRING)
@click.argument("name", type=click.STRING)
@click.option(
    "-s",
    "--save",
    is_flag=True,
    help="Save log message to file.",
)
@click.option(
    "-o",
    "--output",
    type=click.STRING,
    default=None,
    show_default=True,
    help="Path to output directory for the saved log file",
)
def search_by_name_recursively(dir_path: str, name: str, save: bool, output: str) -> None:
    """
    Search recursively by NAME inside DIR_PATH
    """

    log_msg = _get_search_result(dir_path, None, name)
    if not log_msg:
        log_msg = log_messages.NOT_FOUND
    click.echo(log_msg)
    if save:
        save_logs_to_file(output, dir_path, log_msg, logger_types.SEARCH)


def _get_search_result(root_dir: str, subdir_path: Optional[str], name: str) -> str:
    if subdir_path is None:
        subdir_path = root_dir
    subdir_list = os.listdir(subdir_path)

    files_list = []
    nested_dirs = []
    valid_dirs = []
    inner_msg = ""
    for entry in subdir_list:
        entry_path = os.path.join(subdir_path, entry)
        if os.path.isfile(entry_path) and name in entry:
            files_list.append(entry)
        elif os.path.isdir(entry_path):
            if name in entry:
                valid_dirs.append(entry)
            nested_dirs.append(entry)
            inner_msg += _get_search_result(root_dir, entry_path, name)

    if not files_list and not valid_dirs:
        return ""
    log_msg = log_messages.FOUND_BY_NAME.format(dir_path=subdir_path, keyword=name)
    if files_list:
        log_msg += log_messages.FOUND_FILES_BY_NAME.format(
            files_list="\n\t- ".join(files_list),
        )
    if valid_dirs:
        log_msg += log_messages.FOUND_DIRS_BY_NAME.format(
            subdir_list="\n\t- ".join(valid_dirs),
        )
    return log_msg + log_messages.DELIMITER + inner_msg

app/utils/scanner.py
Mithras11/command-line-file-manager
import pytest

from click.testing import CliRunner

RESOURCE_DIR = "tests/resources"


@pytest.fixture(scope="function")
def runner(request):
    return CliRunner()


@pytest.fixture(scope="function")
def mock_organizer(monkeypatch):
    monkeypatch.setattr("shutil.move", lambda src, target: None)
    monkeypatch.setattr("os.makedirs", lambda target: None)
    monkeypatch.setattr("os.rmdir", lambda target: None)

tests/conftest.py
Mithras11/command-line-file-manager
import os

from app.utils import organizer
from tests.conftest import RESOURCE_DIR


def test_organize_files(runner, mock_organizer):
    result = runner.invoke(organizer.organize_files, [RESOURCE_DIR])
    assert result.exit_code == 0

    log_message = _cleanup_abs_paths(result)
    assert log_message == (
        "Creating folder tests/resources/images\n"
        "Moving pic1.jpg to tests/resources/images\n"
        "Creating folder tests/resources/images\n"
        "Moving pic.png to tests/resources/images\n"
        "Skipping .hidden_file.md\n"
        "Creating folder tests/resources/music\n"
        "Moving audio1.wav to tests/resources/music\n"
        "Creating folder tests/resources/other\n"
        "Moving smth.md to tests/resources/other\n"
        "Creating folder tests/resources/videos\n"
        "Moving video.mp4 to tests/resources/videos\n"
        "Creating folder tests/resources/docs\n"
        "Moving file.txt to tests/resources/docs\n"
        "Creating folder tests/resources/books\n"
        "Moving file1.pdf to tests/resources/books\n"
    )


def test_organize_files_recursively(runner, mock_organizer):
    result = runner.invoke(organizer.organize_files_recursively, [RESOURCE_DIR])
    assert result.exit_code == 0

    log_message = _cleanup_abs_paths(result)
    assert log_message == (
        "----- Inside tests/resources/ -----\n"
        "Creating folder tests/resources/images\n"
        "Moving pic1.jpg to tests/resources/images\n"
        "Creating folder tests/resources/images\n"
        "Moving pic.png to tests/resources/images\n"
        "Skipping .hidden_file.md\n"
        "Skipping .hidden_other directory\n"
        "Creating folder tests/resources/music\n"
        "Moving audio1.wav to tests/resources/music\n"
        "Creating folder tests/resources/other\n"
        "Moving smth.md to tests/resources/other\n"
        "Creating folder tests/resources/videos\n"
        "Moving video.mp4 to tests/resources/videos\n"
        "Creating folder tests/resources/docs\n"
        "Moving file.txt to tests/resources/docs\n"
        "Creating folder tests/resources/books\n"
        "Moving file1.pdf to tests/resources/books\n"
        "----- Inside tests/resources/inner_test -----\n"
        "Creating folder tests/resources/inner_test/books\n"
        "Moving inner_file1.pdf to tests/resources/inner_test/books\n"
        "Creating folder tests/resources/inner_test/images\n"
        "Moving inner_pic.png to tests/resources/inner_test/images\n"
        "Creating folder tests/resources/inner_test/docs\n"
        "Moving inner_file.txt to tests/resources/inner_test/docs\n"
        "Creating folder tests/resources/inner_test/other\n"
        "Moving inner_smth.md to tests/resources/inner_test/other\n"
        "Skipping .inner_hidden_alone.wav\n"
        "Skipping .inner_hidden directory\n"
        "Creating folder tests/resources/inner_test/music\n"
        "Moving inner_audio1.wav to tests/resources/inner_test/music\n"
        "Creating folder tests/resources/inner_test/videos\n"
        "Moving inner_audio.mp4 to tests/resources/inner_test/videos\n"
        "Creating folder tests/resources/inner_test/images\n"
        "Moving inner_pic1.jpg to tests/resources/inner_test/images\n"
        "----- Inside tests/resources/inner_test/most_inner_test -----\n"
        "Creating folder tests/resources/inner_test/most_inner_test/books\n"
        "Moving most_inner_file1.pdf to tests/resources/inner_test/most_inner_test/books\n"
        "Creating folder tests/resources/inner_test/most_inner_test/docs\n"
        "Moving most_inner_file.txt to tests/resources/inner_test/most_inner_test/docs\n"
        "Creating folder tests/resources/inner_test/most_inner_test/images\n"
        "Moving most_inner_pic1.jpg to tests/resources/inner_test/most_inner_test/images\n"
        "Creating folder tests/resources/inner_test/most_inner_test/videos\n"
        "Moving most_inner_audio.mp4 to tests/resources/inner_test/most_inner_test/videos\n"
        "Moving most_inner_audio1.wav to tests/resources/inner_test/most_inner_test/music\n"
        "Creating folder tests/resources/inner_test/most_inner_test/images\n"
        "Moving most_inner_pic.png to tests/resources/inner_test/most_inner_test/images\n"
        "Creating folder tests/resources/inner_test/most_inner_test/other\n"
        "Moving most_inner_smth.md to tests/resources/inner_test/most_inner_test/other\n"
        "----- Inside tests/resources/inner_test/most_inner_test/music -----\n"
        "Creating folder tests/resources/inner_test/most_inner_test/music/music\n"
        "Moving inside_most_inner_audio.wav to tests/resources/inner_test/most_inner_test/music/music\n"
    )


def test_organize_files_recursively_and_flatten_folder(runner, mock_organizer):
    result = runner.invoke(organizer.organize_files_recursively, [RESOURCE_DIR, "--flat"])
    assert result.exit_code == 0

    log_message = _cleanup_abs_paths(result)
    assert log_message == (
        "----- Inside tests/resources/ -----\n"
        "Creating folder tests/resources/images\n"
        "Moving pic1.jpg to tests/resources/images\n"
        "Creating folder tests/resources/images\n"
        "Moving pic.png to tests/resources/images\n"
        "Moving .hidden_file.md to root directory\n"
        "Moving .hidden_other directory without stepping inside\n"
        "Creating folder tests/resources/music\n"
        "Moving audio1.wav to tests/resources/music\n"
        "Creating folder tests/resources/other\n"
        "Moving smth.md to tests/resources/other\n"
        "Creating folder tests/resources/videos\n"
        "Moving video.mp4 to tests/resources/videos\n"
        "Creating folder tests/resources/docs\n"
        "Moving file.txt to tests/resources/docs\n"
        "Creating folder tests/resources/books\n"
        "Moving file1.pdf to tests/resources/books\n"
        "----- Inside tests/resources/inner_test -----\n"
        "Creating folder tests/resources/books\n"
        "Moving inner_file1.pdf to tests/resources/books\n"
        "Creating folder tests/resources/images\n"
        "Moving inner_pic.png to tests/resources/images\n"
        "Creating folder tests/resources/docs\n"
        "Moving inner_file.txt to tests/resources/docs\n"
        "Creating folder tests/resources/other\n"
        "Moving inner_smth.md to tests/resources/other\n"
        "Moving .inner_hidden_alone.wav to root directory\n"
        "Moving .inner_hidden directory without stepping inside\n"
        "Creating folder tests/resources/music\n"
        "Moving inner_audio1.wav to tests/resources/music\n"
        "Creating folder tests/resources/videos\n"
        "Moving inner_audio.mp4 to tests/resources/videos\n"
        "Creating folder tests/resources/images\n"
        "Moving inner_pic1.jpg to tests/resources/images\n"
        "----- Inside tests/resources/inner_test/most_inner_test -----\n"
        "Creating folder tests/resources/books\n"
        "Moving most_inner_file1.pdf to tests/resources/books\n"
        "Creating folder tests/resources/docs\n"
        "Moving most_inner_file.txt to tests/resources/docs\n"
        "Creating folder tests/resources/images\n"
        "Moving most_inner_pic1.jpg to tests/resources/images\n"
        "Creating folder tests/resources/videos\n"
        "Moving most_inner_audio.mp4 to tests/resources/videos\n"
        "Creating folder tests/resources/music\n"
        "Moving most_inner_audio1.wav to tests/resources/music\n"
        "Creating folder tests/resources/images\n"
        "Moving most_inner_pic.png to tests/resources/images\n"
        "Creating folder tests/resources/other\n"
        "Moving most_inner_smth.md to tests/resources/other\n"
        "----- Inside tests/resources/inner_test/most_inner_test/music -----\n"
        "Creating folder tests/resources/music\nMoving inside_most_inner_audio.wav to tests/resources/music\n"
        "Removing tests/resources/inner_test/most_inner_test/music\n"
        "Removing tests/resources/inner_test/most_inner_test\n"
        "Removing tests/resources/inner_test\n"
    )


def _cleanup_abs_paths(result):
    resources_full_path = os.path.abspath(RESOURCE_DIR)
    log_message = result.output.replace(resources_full_path, RESOURCE_DIR)
    return log_message

tests/test_organizer.py
Mithras11/command-line-file-manager
from app.utils import scanner
from tests.conftest import RESOURCE_DIR


def test_scan_files(runner):
    # case 0: test with default option values
    result = runner.invoke(scanner.scan_files, [RESOURCE_DIR])
    assert result.exit_code == 0
    assert result.output == (
        "The given directory 'tests/resources' contains the following files:\n"
        "\n"
        ".hidden_file.md\n"
        "audio1.wav\n"
        "file.txt\n"
        "file1.pdf\n"
        "pic.png\n"
        "pic1.jpg\n"
        "smth.md\n"
        "video.mp4\n"
        "\n"
        "=========================================\n"
        "\n"
    )

    # case 1: test with sort option
    result = runner.invoke(scanner.scan_files, [RESOURCE_DIR, "--sort=Name", "--desc"])
    assert result.exit_code == 0
    assert result.output == (
        "The given directory 'tests/resources' contains the following files:\n"
        "\n"
        "video.mp4\n"
        "smth.md\n"
        "pic1.jpg\n"
        "pic.png\n"
        "file1.pdf\n"
        "file.txt\n"
        "audio1.wav\n"
        ".hidden_file.md\n"
        "\n=========================================\n"
        "\n"
    )


def test_scan_subdirs(runner):
    # case 0: test with default option values
    result = runner.invoke(scanner.scan_subdirs, [RESOURCE_DIR])
    assert result.exit_code == 0
    assert result.output == (
        "The given directory 'tests/resources' contains the following subdirectories:\n"
        "\n"
        ".hidden_other\n"
        "inner_test\n"
        "\n"
        "=========================================\n"
        "\n"
    )

    # case 1: test with sort option
    result = runner.invoke(scanner.scan_subdirs, [RESOURCE_DIR, "--sort=date", "--desc"])
    assert result.exit_code == 0
    assert result.output == (
        "The given directory 'tests/resources' contains the following subdirectories:\n"
        "\n"
        "inner_test\n"
        ".hidden_other\n"
        "\n=========================================\n"
        "\n"
    )


def test_build_catalog(runner):
    result = runner.invoke(scanner.build_catalog, [RESOURCE_DIR])
    assert result.exit_code == 0
    assert result.output == (
        "The given directory 'tests/resources' contains the following files:\n"
        "\n"
        "pic1.jpg\n"
        "pic.png\n"
        ".hidden_file.md\n"
        "audio1.wav\n"
        "smth.md\n"
        "video.mp4\n"
        "file.txt\n"
        "file1.pdf\n"
        "\n"
        "=========================================\n"
        "The given directory 'tests/resources' contains the following subdirectories:\n"
        "\n"
        "inner_test\n"
        ".hidden_other\n"
        "\n"
        "=========================================\n"
        "\n"
    )


def test_build_catalog_recursively(runner):
    result = runner.invoke(scanner.build_catalog_recursively, [RESOURCE_DIR])
    assert result.exit_code == 0
    assert result.output == (
        "The given directory 'tests/resources' contains the following files:\n"
        "\n"
        "pic1.jpg\n"
        "pic.png\n"
        ".hidden_file.md\n"
        "audio1.wav\n"
        "smth.md\n"
        "video.mp4\n"
        "file.txt\n"
        "file1.pdf\n"
        "\n"
        "=========================================\n"
        "The given directory 'tests/resources' contains the following "
        "subdirectories:\n"
        "\n"
        "inner_test\n"
        ".hidden_other\n"
        "\n"
        "=========================================\n"
        "The given directory 'tests/resources/inner_test' contains the following "
        "files:\n"
        "\n"
        "inner_file1.pdf\n"
        "inner_pic.png\n"
        "inner_file.txt\n"
        "inner_smth.md\n"
        ".inner_hidden_alone.wav\n"
        "inner_audio1.wav\n"
        "inner_audio.mp4\n"
        "inner_pic1.jpg\n"
        "\n"
        "=========================================\n"
        "The given directory 'tests/resources/inner_test' contains the following "
        "subdirectories:\n"
        "\n"
        "most_inner_test\n"
        ".inner_hidden\n"
        "\n"
        "=========================================\n"
        "The given directory 'tests/resources/inner_test/most_inner_test' contains "
        "the following files:\n"
        "\n"
        "most_inner_file1.pdf\n"
        "most_inner_file.txt\n"
        "most_inner_pic1.jpg\n"
        "most_inner_audio.mp4\n"
        "most_inner_audio1.wav\n"
        "most_inner_pic.png\n"
        "most_inner_smth.md\n"
        "\n"
        "=========================================\n"
        "The given directory 'tests/resources/inner_test/most_inner_test' contains "
        "the following subdirectories:\n"
        "\n"
        "music\n"
        "\n"
        "=========================================\n"
        "The given directory 'tests/resources/inner_test/most_inner_test/music' "
        "contains the following files:\n"
        "\n"
        "inside_most_inner_audio.wav\n"
        "\n"
        "=========================================\n"
        "The given directory 'tests/resources/inner_test/most_inner_test/music' "
        "contains no nested subdirectories:\n"
        "\n"
        "\n"
        "=========================================\n"
        "The given directory 'tests/resources/inner_test/.inner_hidden' contains the "
        "following files:\n"
        "\n"
        ".inner_hidden_music.mp3\n"
        "\n"
        "=========================================\n"
        "The given directory 'tests/resources/inner_test/.inner_hidden' contains no "
        "nested subdirectories:\n"
        "\n"
        "\n"
        "=========================================\n"
        "The given directory 'tests/resources/.hidden_other' contains the following "
        "files:\n"
        "\n"
        ".inside_hidden_other.md\n"
        "\n"
        "=========================================\n"
        "The given directory 'tests/resources/.hidden_other' contains no nested "
        "subdirectories:\n"
        "\n"
        "\n"
        "=========================================\n"
        "\n"
    )


def test_build_tree(runner):
    result = runner.invoke(scanner.build_tree, [RESOURCE_DIR])
    assert result.exit_code == 0
    assert result.output == (
        "📁 /home/kaliv0/PycharmProjects/Advanced_File_Manager/tests/resources/\n"
        "    📃 pic1.jpg\n"
        "    📃 pic.png\n"
        "    📃 .hidden_file.md\n"
        "    📃 audio1.wav\n"
        "    📃 smth.md\n"
        "    📃 video.mp4\n"
        "    📃 file.txt\n"
        "    📃 file1.pdf\n"
        "    📁 "
        "/home/kaliv0/PycharmProjects/Advanced_File_Manager/tests/resources/inner_test/\n"
        "        📃 inner_file1.pdf\n"
        "        📃 inner_pic.png\n"
        "        📃 inner_file.txt\n"
        "        📃 inner_smth.md\n"
        "        📃 .inner_hidden_alone.wav\n"
        "        📃 inner_audio1.wav\n"
        "        📃 inner_audio.mp4\n"
        "        📃 inner_pic1.jpg\n"
        "        📁 "
        "/home/kaliv0/PycharmProjects/Advanced_File_Manager/tests/resources/inner_test/most_inner_test/\n"
        "            📃 most_inner_file1.pdf\n"
        "            📃 most_inner_file.txt\n"
        "            📃 most_inner_pic1.jpg\n"
        "            📃 most_inner_audio.mp4\n"
        "            📃 most_inner_audio1.wav\n"
        "            📃 most_inner_pic.png\n"
        "            📃 most_inner_smth.md\n"
        "            📁 "
        "/home/kaliv0/PycharmProjects/Advanced_File_Manager/tests/resources/inner_test/most_inner_test/music/\n"
        "                📃 inside_most_inner_audio.wav\n"
        "        📁 "
        "/home/kaliv0/PycharmProjects/Advanced_File_Manager/tests/resources/inner_test/.inner_hidden/\n"
        "            📃 .inner_hidden_music.mp3\n"
        "    📁 "
        "/home/kaliv0/PycharmProjects/Advanced_File_Manager/tests/resources/.hidden_other/\n"
        "        📃 .inside_hidden_other.md\n"
        "\n"
    )


def test_build_pretty_tree(runner):
    result = runner.invoke(scanner.build_pretty_tree, [RESOURCE_DIR])
    assert result.exit_code == 0
    assert result.output == (
        "resources/\n"
        "├── audio1.wav\n"
        "├── file.txt\n"
        "├── file1.pdf\n"
        "├── inner_test/\n"
        "│   ├── inner_audio.mp4\n"
        "│   ├── inner_audio1.wav\n"
        "│   ├── inner_file.txt\n"
        "│   ├── inner_file1.pdf\n"
        "│   ├── inner_pic.png\n"
        "│   ├── inner_pic1.jpg\n"
        "│   ├── inner_smth.md\n"
        "│   └── most_inner_test/\n"
        "│       ├── most_inner_audio.mp4\n"
        "│       ├── most_inner_audio1.wav\n"
        "│       ├── most_inner_file.txt\n"
        "│       ├── most_inner_file1.pdf\n"
        "│       ├── most_inner_pic.png\n"
        "│       ├── most_inner_pic1.jpg\n"
        "│       ├── most_inner_smth.md\n"
        "│       └── music/\n"
        "│           └── inside_most_inner_audio.wav\n"
        "├── pic.png\n"
        "├── pic1.jpg\n"
        "├── smth.md\n"
        "└── video.mp4\n"
        "\n"
    )


def test_search_by_name(runner):
    keyword = "hidden"
    result = runner.invoke(scanner.search_by_name, [RESOURCE_DIR, keyword])
    assert result.exit_code == 0
    assert result.output == (
        "Inside directory 'tests/resources' the given keyword 'hidden' was found\n"
        "- in the following file names:\n"
        "\t- .hidden_file.md\n"
        "- in the following subdirectory names:\n"
        "\t- .hidden_other\n"
        "\n"
    )


def test_search_by_name_recursively(runner):
    keyword = "hidden"
    result = runner.invoke(scanner.search_by_name_recursively, [RESOURCE_DIR, keyword])
    assert result.exit_code == 0
    assert result.output == (
        "Inside directory 'tests/resources' the given keyword 'hidden' was found\n"
        "- in the following file names:\n"
        "\t- .hidden_file.md\n"
        "- in the following subdirectory names:\n"
        "\t- .hidden_other\n"
        "\n=========================================\n"
        "Inside directory 'tests/resources/inner_test' the given keyword 'hidden' was found\n"
        "- in the following file names:\n\t- .inner_hidden_alone.wav\n- in the following subdirectory names:\n"
        "\t- .inner_hidden\n"
        "\n=========================================\n"
        "Inside directory 'tests/resources/inner_test/.inner_hidden' the given keyword 'hidden' was found\n"
        "- in the following file names:\n"
        "\t- .inner_hidden_music.mp3\n"
        "\n=========================================\n"
        "Inside directory 'tests/resources/.hidden_other' the given keyword 'hidden' was found\n"
        "- in the following file names:\n"
        "\t- .inside_hidden_other.md\n"
        "\n=========================================\n"
        "\n"
    )

tests/test_scanner.py
Mithras11/command-line-file-manager