PYTHON
helpers.py🐍python
"""
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}"