PYTHONPython

helpers

real world projects / cli tool / devtools / utils

PYTHON
helpers.py🐍
"""
Utility helper functions for DevTools CLI.
"""

import os
import hashlib
from pathlib import Path
from typing import Iterator, Optional


def format_size(size: int) -> str:
    """
    Format file size to human readable string.
    
    Args:
        size: Size in bytes
        
    Returns:
        Formatted string like "1.5 MB"
    """
    for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
        if size < 1024:
            return f"{size:.1f} {unit}"
        size /= 1024
    return f"{size:.1f} PB"


def format_number(num: int) -> str:
    """
    Format number with thousand separators.
    
    Args:
        num: Number to format
        
    Returns:
        Formatted string like "1,234,567"
    """
    return f"{num:,}"


def get_file_hash(filepath: Path, algorithm: str = 'md5') -> str:
    """
    Calculate hash of a file.
    
    Args:
        filepath: Path to file
        algorithm: Hash algorithm ('md5', 'sha256', etc.)
        
    Returns:
        Hex digest of file hash
    """
    hash_func = hashlib.new(algorithm)
    with open(filepath, 'rb') as f:
        for chunk in iter(lambda: f.read(8192), b''):
            hash_func.update(chunk)
    return hash_func.hexdigest()


def walk_files(
    path: Path,
    pattern: str = '*',
    exclude_dirs: Optional[list[str]] = None
) -> Iterator[Path]:
    """
    Walk directory and yield matching files.
    
    Args:
        path: Root directory to walk
        pattern: Glob pattern to match
        exclude_dirs: Directory names to skip
        
    Yields:
        Path objects for matching files
    """
    if exclude_dirs is None:
        exclude_dirs = ['.git', '__pycache__', 'node_modules', '.venv', 'venv']
    
    for item in path.rglob(pattern):
        # Skip excluded directories
        if any(excluded in item.parts for excluded in exclude_dirs):
            continue
        
        # Skip hidden files/dirs
        if any(part.startswith('.') for part in item.parts[len(path.parts):]):
            continue
        
        if item.is_file():
            yield item


def count_lines(filepath: Path) -> dict:
    """
    Count lines in a file.
    
    Args:
        filepath: Path to file
        
    Returns:
        Dict with 'total', 'code', 'comment', 'blank' counts
    """
    try:
        content = filepath.read_text()
    except (UnicodeDecodeError, PermissionError):
        return {'total': 0, 'code': 0, 'comment': 0, 'blank': 0}
    
    lines = content.split('\n')
    total = len(lines)
    blank = 0
    comment = 0
    code = 0
    
    for line in lines:
        stripped = line.strip()
        if not stripped:
            blank += 1
        elif stripped.startswith('#') or stripped.startswith('//'):
            comment += 1
        else:
            code += 1
    
    return {
        'total': total,
        'code': code,
        'comment': comment,
        'blank': blank
    }


def is_binary(filepath: Path) -> bool:
    """
    Check if a file is binary.
    
    Args:
        filepath: Path to check
        
    Returns:
        True if file appears to be binary
    """
    try:
        with open(filepath, 'rb') as f:
            chunk = f.read(8192)
            return b'\x00' in chunk
    except (PermissionError, OSError):
        return True


def get_relative_path(path: Path, base: Path) -> str:
    """
    Get relative path from base.
    
    Args:
        path: Path to make relative
        base: Base path
        
    Returns:
        Relative path string
    """
    try:
        return str(path.relative_to(base))
    except ValueError:
        return str(path)


def truncate_string(text: str, max_length: int = 50, suffix: str = "...") -> str:
    """
    Truncate string to maximum length.
    
    Args:
        text: String to truncate
        max_length: Maximum length including suffix
        suffix: Suffix to add when truncated
        
    Returns:
        Truncated string
    """
    if len(text) <= max_length:
        return text
    return text[:max_length - len(suffix)] + suffix


def pluralize(count: int, singular: str, plural: Optional[str] = None) -> str:
    """
    Return singular or plural form based on count.
    
    Args:
        count: Number of items
        singular: Singular form
        plural: Plural form (default: singular + 's')
        
    Returns:
        Formatted string like "5 files" or "1 file"
    """
    if plural is None:
        plural = singular + 's'
    
    word = singular if count == 1 else plural
    return f"{count} {word}"
PreviousNext