#!/usr/bin/env python3
# File: pathlib3/__init__.py
# Author: Hadi Cahyadi <cumulus13@gmail.com>
# Date: 2025-12-30
# Description: Extended pathlib with additional utility methods
# License: MIT
"""
pathlib3 - Extended pathlib with additional utility methods
This module re-exports everything from pathlib and adds Path class
with extended functionality.
Usage:
from pathlib3 import Path
# or
from pathlib3 import *
"""
# ===================================================================
# IMPORT EVERYTHING from pathlib
# ===================================================================
from pathlib import (
# Main classes
Path as _PathBase,
PurePath,
PosixPath,
WindowsPath,
PurePosixPath,
PureWindowsPath,
)
# Import modules for extended functionality
import pathlib
import os
import sys
import shutil
import hashlib
import json
import pickle
from typing import Union, Tuple, List, Optional, Callable, Any, Iterator
from datetime import datetime
import traceback
RICH_AVAILABLE = False
MUTAGEN_AVAILABLE = False
YAML_AVAILABLE = False
TOML_AVAILABLE = False
INI_AVAILABLE = False
PIL_AVAILABLE = False
PYPDF2_AVAILABLE = False
PYTHON_DOCX_AVAILABLE = False
OPENPYXL_AVAILABLE = False
EMAIL_AVAILABLE = False
# Check for optional dependencies
try:
from rich.table import Table
from rich.console import Console
console = Console(width=os.get_terminal_size().columns)
RICH_AVAILABLE = True
except:
Table = None
try:
from mutagen import File as MutagenFile # type: ignore
from mutagen.id3 import ID3, APIC, error as ID3Error # type: ignore
MUTAGEN_AVAILABLE = True
except:
pass
try:
import yaml
YAML_AVAILABLE = True
except ImportError:
pass
try:
if sys.version_info < (3, 11):
import tomli
else:
import tomllib
TOML_AVAILABLE = True
except ImportError:
try:
import tomllib
TOML_AVAILABLE = True
except ImportError:
pass
try:
import configparser
INI_AVAILABLE = True # Built-in, always available
except ImportError:
pass
try:
from PIL import Image
from PIL.ExifTags import TAGS
PIL_AVAILABLE = True
except ImportError:
pass
try:
import PyPDF2
PYPDF2_AVAILABLE = True
except ImportError:
pass
try:
import docx
PYTHON_DOCX_AVAILABLE = True
except ImportError:
pass
try:
from openpyxl import load_workbook
OPENPYXL_AVAILABLE = True
except ImportError:
pass
# Check for email functionality
try:
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
from email.mime.image import MIMEImage
from email.mime.audio import MIMEAudio
from email.mime.application import MIMEApplication
EMAIL_AVAILABLE = True # Built-in, always available
except ImportError:
pass
# ===================================================================
# Email Configuration Helper
# ===================================================================
class EmailConfig:
"""
Email configuration helper for SMTP settings.
Common SMTP servers:
Gmail: smtp.gmail.com:587 (TLS) or :465 (SSL)
Outlook: smtp-mail.outlook.com:587
Yahoo: smtp.mail.yahoo.com:587
Office365: smtp.office365.com:587
Example:
>>> config = EmailConfig(
... smtp_server='smtp.gmail.com',
... smtp_port=587,
... username='your.email@gmail.com',
... password='your_app_password',
... use_tls=True
... )
"""
def __init__(
self,
smtp_server: str,
smtp_port: int = 587,
username: str = '',
password: str = '',
use_tls: bool = True,
use_ssl: bool = False,
timeout: int = 30
):
"""
Initialize email configuration.
Args:
smtp_server: SMTP server address
smtp_port: SMTP port (587 for TLS, 465 for SSL, 25 for no encryption)
username: Email username/address
password: Email password or app-specific password
use_tls: Use TLS encryption (STARTTLS)
use_ssl: Use SSL encryption
timeout: Connection timeout in seconds
"""
self.smtp_server = smtp_server
self.smtp_port = smtp_port
self.username = username
self.password = password
self.use_tls = use_tls
self.use_ssl = use_ssl
self.timeout = timeout
@classmethod
def gmail(cls, username: str, password: str) -> 'EmailConfig':
"""Quick config for Gmail"""
return cls(
smtp_server='smtp.gmail.com',
smtp_port=587,
username=username,
password=password,
use_tls=True
)
@classmethod
def outlook(cls, username: str, password: str) -> 'EmailConfig':
"""Quick config for Outlook/Hotmail"""
return cls(
smtp_server='smtp-mail.outlook.com',
smtp_port=587,
username=username,
password=password,
use_tls=True
)
@classmethod
def office365(cls, username: str, password: str) -> 'EmailConfig':
"""Quick config for Office 365"""
return cls(
smtp_server='smtp.office365.com',
smtp_port=587,
username=username,
password=password,
use_tls=True
)
@classmethod
def yahoo(cls, username: str, password: str) -> 'EmailConfig':
"""Quick config for Yahoo"""
return cls(
smtp_server='smtp.mail.yahoo.com',
smtp_port=587,
username=username,
password=password,
use_tls=True
)
# ===================================================================
# Path - Extended Path Class
# ===================================================================
[docs]
class Path(type(_PathBase())):
"""
Extended Path class with 40+ additional utility methods.
Inherits ALL functionality from pathlib.Path and adds more.
All original pathlib.Path methods are available:
- .exists(), .is_file(), .is_dir(), .is_symlink()
- .stat(), .chmod(), .rename(), .replace()
- .mkdir(), .rmdir(), .unlink(), .touch()
- .read_text(), .read_bytes(), .write_text(), .write_bytes()
- .glob(), .rglob(), .iterdir()
- .resolve(), .absolute(), .relative_to()
- .with_name(), .with_suffix(), .with_stem()
- .parent, .parents, .name, .stem, .suffix, .suffixes
- .anchor, .parts, .drive, .root
- .as_posix(), .as_uri()
- .expanduser(), .home(), .cwd()
- .match(), .is_relative_to(), .is_absolute()
- .joinpath(), .samefile()
- and many more...
Additional method categories:
BASIC UTILITIES:
- .ext() - Get extension without dot
- .basename() - Get filename with extension
- .base() - Get filename without extension
- .dirname() - Get directory path as string
- .abspath() - Get absolute path as string
NONE HANDLING:
- Path(None) - Creates Path('.') instead of error
- .safe() - Class method to create Path safely from optional
- .from_optional() - Class method to create Path or return None
PATH MANIPULATION:
- .normpath() - Normalize path
- .join() - Join path components
- .split_ext() - Split into base and extension
- .split_path() - Split path into components
- .change_ext() - Change file extension
DIRECTORY OPERATIONS:
- .ensure_dir() - Create directory if doesn't exist
- .ensure_parent() - Create parent directory
- .touch_parent() - Create parent dirs and touch file
- .ls() - List directory contents
- .tree() - Show directory tree
- .find() - Find files recursively
FILE OPERATIONS:
- .rm() - Remove file or directory
- .copy_to() - Copy file to destination
- .move_to() - Move file to destination
- .append_text() - Append text to file
- .append_bytes() - Append bytes to file
- .backup() - Create backup of file
FILE INFO:
- .size() - Get file size
- .size_human() - Get human-readable size
- .mtime() - Get modification time
- .ctime() - Get creation time
- .atime() - Get access time
- .age() - Get file age in seconds
- .is_empty() - Check if empty
- .is_newer_than() - Compare modification times
- .is_older_than() - Compare modification times
CONTENT OPERATIONS:
- .lines() - Read lines as list
- .read_json() - Read JSON file
- .write_json() - Write JSON file
- .read_pickle() - Read pickle file
- .write_pickle() - Write pickle file
- .hash() - Calculate file hash
- .checksum() - Alias for hash
- .count_lines() - Count lines in file
- .validate() - Validate file format
- .metadata() - Extract file metadata (images, PDFs, audio, video, docs)
- .metadata_simple() - Get simple metadata summary
SEARCH & FILTER:
- .find_files() - Find files by pattern
- .find_dirs() - Find directories by pattern
- .walk() - Walk directory tree
MUSIC TAGS:
- .music_tag() - Get music tag
- .show_info() - Display music tags
COMPARISON:
- .same_content() - Check if files have same content
EMAIL OPERATIONS:
- .email_as_attachment() - Send file as email attachment
- .send_email() - Send email with multiple attachments (static method)
IMAGE OPERATIONS (requires Pillow):
- .to_ico() - Convert image to ICO format (single or multi-size)
- .resize() - Resize image with aspect ratio preservation
- .thumbnail() - Create thumbnail
- .convert_format() - Convert image format (PNG, JPEG, WebP, etc.)
- .metadata() - Extract image metadata (EXIF, dimensions, etc.)
"""
# ===============================================================
# BASIC UTILITY METHODS
# ===============================================================
[docs]
def __new__(cls, *args, **kwargs):
"""Create new Path instance with None handling."""
# Handle None case
if len(args) == 1 and args[0] is None:
args = ('.',)
elif len(args) > 1:
# Filter out None values from args
args = tuple(arg if arg is not None else '.' for arg in args)
# Handle empty args
if len(args) == 0:
args = ('.',)
return super().__new__(cls, *args, **kwargs)
[docs]
@classmethod
def safe(cls, path: Optional[Union[str, _PathBase, 'Path']], default: str = '.') -> 'Path':
"""
Create Path safely, handling None values.
More explicit alternative to Path(None).
Args:
path: Path string or Path object (can be None)
default: Default path if input is None (default: '.')
Returns:
Path: Path instance
Example:
>>> Path.safe(None)
Path('.')
>>> Path.safe(None, '/tmp')
Path('/tmp')
>>> Path.safe("file.txt")
Path('file.txt')
"""
if path is None:
return cls(default)
return cls(path)
[docs]
@classmethod
def from_optional(cls, path: Optional[Union[str, _PathBase, 'Path']]) -> Optional['Path']:
"""
Create Path from optional value, returns None if input is None.
Args:
path: Path string or Path object (can be None)
Returns:
Path or None: Path instance or None
Example:
>>> Path.from_optional(None)
None
>>> Path.from_optional("file.txt")
Path('file.txt')
"""
if path is None:
return None
return cls(path)
[docs]
def ext(self) -> str:
"""
Get file extension without the dot.
Returns:
str: File extension (e.g., 'txt', 'py', 'tar.gz')
Example:
>>> Path('file.txt').ext()
'txt'
>>> Path('archive.tar.gz').ext()
'gz'
"""
return self.suffix.lstrip('.')
[docs]
def basename(self) -> str:
"""
Get the base name (filename with extension).
Alias for .name property.
Returns:
str: Basename (e.g., 'file.txt')
Example:
>>> Path('/home/user/file.txt').basename()
'file.txt'
"""
return self.name
[docs]
def base(self) -> str:
"""
Get the base name without extension.
Alias for .stem property.
Returns:
str: Filename without extension (e.g., 'file')
Example:
>>> Path('file.txt').base()
'file'
"""
return self.stem
[docs]
def dirname(self) -> str:
"""
Get the directory name as string.
Returns:
str: Directory path
Example:
>>> Path('/home/user/file.txt').dirname()
'/home/user'
"""
return str(self.parent)
[docs]
def abspath(self) -> str:
"""
Get absolute path as string.
Returns:
str: Absolute path
Example:
>>> Path('file.txt').abspath()
'/current/working/dir/file.txt'
"""
return str(self.absolute())
# ===============================================================
# PATH MANIPULATION
# ===============================================================
[docs]
def normpath(self) -> 'Path':
"""
Normalize path (remove redundant separators and up-level references).
Returns:
Path: Normalized path
Example:
>>> Path('/home//user/../user/file.txt').normpath()
Path('/home/user/file.txt')
"""
return Path(os.path.normpath(self))
[docs]
def join(self, *args) -> 'Path':
"""
Join path components.
Args:
*args: Path components to join
Returns:
Path: Joined path
Example:
>>> Path('/home').join('user', 'documents', 'file.txt')
Path('/home/user/documents/file.txt')
"""
result = self
for arg in args:
result = result / arg
return Path(result)
[docs]
def split_ext(self) -> Tuple[str, str]:
"""
Split path into base and extension.
Returns:
tuple: (base_path, extension)
Example:
>>> Path('/home/user/file.txt').split_ext()
('/home/user/file', '.txt')
"""
return (str(self.with_suffix('')), self.suffix)
[docs]
def split_path(self) -> List[str]:
"""
Split path into list of components.
Returns:
list: List of path components
Example:
>>> Path('/home/user/file.txt').split_path()
['/', 'home', 'user', 'file.txt']
"""
return list(self.parts)
[docs]
def change_ext(self, new_ext: str) -> 'Path':
"""
Change file extension.
Args:
new_ext: New extension (with or without dot)
Returns:
Path: Path with new extension
Example:
>>> Path('file.txt').change_ext('md')
Path('file.md')
>>> Path('file.txt').change_ext('.json')
Path('file.json')
"""
if not new_ext.startswith('.'):
new_ext = '.' + new_ext
return Path(self.with_suffix(new_ext))
# ===============================================================
# DIRECTORY OPERATIONS
# ===============================================================
[docs]
def ensure_dir(self, mode: int = 0o777) -> 'Path':
"""
Create directory if it doesn't exist (for directories).
Args:
mode: Directory permissions (default: 0o777)
Returns:
Path: self (for chaining)
Example:
>>> Path('/tmp/new_folder').ensure_dir()
Path('/tmp/new_folder')
"""
self.mkdir(mode=mode, parents=True, exist_ok=True)
return self
[docs]
def ensure_parent(self, mode: int = 0o777) -> 'Path':
"""
Create parent directory if it doesn't exist.
Args:
mode: Directory permissions (default: 0o777)
Returns:
Path: self (for chaining)
Example:
>>> Path('/tmp/new_folder/file.txt').ensure_parent()
Path('/tmp/new_folder/file.txt')
"""
self.parent.mkdir(mode=mode, parents=True, exist_ok=True)
return self
[docs]
def touch_parent(self) -> 'Path':
"""
Create parent directories and touch file.
Returns:
Path: self (for chaining)
Example:
>>> Path('/tmp/new_folder/file.txt').touch_parent()
Path('/tmp/new_folder/file.txt')
"""
self.parent.mkdir(parents=True, exist_ok=True)
self.touch()
return self
[docs]
def ls(self, pattern: str = "*", only_files: bool = False, only_dirs: bool = False) -> List['Path']:
"""
List directory contents as Path objects.
Args:
pattern: Glob pattern (default: "*")
only_files: Only return files
only_dirs: Only return directories
Returns:
list: List of Path objects
Example:
>>> Path('/tmp').ls()
[Path('/tmp/file1.txt'), Path('/tmp/file2.txt')]
>>> Path('/tmp').ls('*.txt')
[Path('/tmp/file1.txt'), Path('/tmp/file2.txt')]
>>> Path('/tmp').ls(only_files=True)
[Path('/tmp/file1.txt')]
"""
results = [Path(p) for p in self.glob(pattern)]
if only_files:
results = [p for p in results if p.is_file()]
elif only_dirs:
results = [p for p in results if p.is_dir()]
return results
[docs]
def tree(self, max_depth: Optional[int] = None, prefix: str = "") -> str:
"""
Generate directory tree structure as string.
Args:
max_depth: Maximum depth to traverse
prefix: Prefix for formatting (internal use)
Returns:
str: Tree structure
Example:
>>> print(Path('/tmp').tree(max_depth=2))
/tmp
├── file1.txt
├── folder1/
│ ├── file2.txt
│ └── file3.txt
"""
if not self.is_dir():
return str(self)
lines = [str(self)]
if max_depth is not None and max_depth <= 0:
return lines[0]
try:
entries = sorted(self.iterdir(), key=lambda x: (not x.is_dir(), x.name))
for i, entry in enumerate(entries):
is_last = i == len(entries) - 1
current_prefix = "└── " if is_last else "├── "
child_prefix = " " if is_last else "│ "
if entry.is_dir():
lines.append(f"{prefix}{current_prefix}{entry.name}/")
if max_depth is None or max_depth > 1:
child_tree = Path(entry).tree(
max_depth=max_depth - 1 if max_depth else None,
prefix=prefix + child_prefix
)
child_lines = child_tree.split('\n')[1:] # Skip first line
lines.extend(child_lines)
else:
lines.append(f"{prefix}{current_prefix}{entry.name}")
except PermissionError:
lines.append(f"{prefix}[Permission Denied]")
return '\n'.join(lines)
[docs]
def find(self, pattern: str = "*", recursive: bool = True) -> List['Path']:
"""
Find files matching pattern.
Args:
pattern: Glob pattern
recursive: Search recursively
Returns:
list: List of matching Path objects
Example:
>>> Path('/tmp').find('*.txt')
[Path('/tmp/file1.txt'), Path('/tmp/sub/file2.txt')]
"""
if recursive:
return [Path(p) for p in self.rglob(pattern)]
return [Path(p) for p in self.glob(pattern)]
# ===============================================================
# FILE OPERATIONS
# ===============================================================
[docs]
def rm(self, recursive: bool = False, missing_ok: bool = False) -> None:
"""
Remove file or directory.
Args:
recursive: If True, remove directory recursively
missing_ok: If True, don't raise error if path doesn't exist
Example:
>>> Path('file.txt').rm()
>>> Path('folder').rm(recursive=True)
"""
if not self.exists():
if missing_ok:
return
raise FileNotFoundError(f"{self} does not exist")
if self.is_file() or self.is_symlink():
self.unlink()
elif self.is_dir():
if recursive:
shutil.rmtree(self)
else:
self.rmdir()
[docs]
def copy_to(self, dest: Union[str, _PathBase, 'Path'], overwrite: bool = False) -> 'Path':
"""
Copy file or directory to destination.
Args:
dest: Destination path
overwrite: Overwrite if exists
Returns:
Path: Destination path
Example:
>>> Path('source.txt').copy_to('dest.txt')
Path('dest.txt')
"""
dest = Path(dest)
if dest.exists() and not overwrite:
raise FileExistsError(f"{dest} already exists")
if self.is_file():
dest.ensure_parent()
shutil.copy2(self, dest)
elif self.is_dir():
if dest.exists():
shutil.rmtree(dest)
shutil.copytree(self, dest)
return dest
[docs]
def move_to(self, dest: Union[str, _PathBase, 'Path']) -> 'Path':
"""
Move file or directory to destination.
Args:
dest: Destination path
Returns:
Path: Destination path
Example:
>>> Path('old.txt').move_to('new.txt')
Path('new.txt')
"""
dest = Path(dest)
dest.ensure_parent()
shutil.move(str(self), str(dest))
return dest
[docs]
def append_text(self, text: str, encoding: str = 'utf-8', newline: bool = False) -> 'Path':
"""
Append text to file.
Args:
text: Text to append
encoding: Text encoding (default: 'utf-8')
newline: Add newline before text
Returns:
Path: self (for chaining)
Example:
>>> Path('log.txt').append_text('New log entry')
"""
mode = 'a'
try:
with self.open(mode, encoding=encoding) as f:
if newline and self.exists() and self.stat().st_size > 0:
f.write('\n')
f.write(text)
except PermissionError:
raise PermissionError(f"Permission denied: '{self}'. File may be open in another program.")
return self
[docs]
def append_bytes(self, data: bytes) -> 'Path':
"""
Append bytes to file.
Args:
data: Bytes to append
Returns:
Path: self (for chaining)
Example:
>>> Path('data.bin').append_bytes(b'\\x00\\x01\\x02')
"""
try:
with self.open('ab') as f:
f.write(data)
except PermissionError:
raise PermissionError(f"Permission denied: '{self}'. File may be open in another program.")
return self
[docs]
def backup(self, suffix: str = '.bak') -> 'Path':
"""
Create backup of file.
Args:
suffix: Backup suffix (default: '.bak')
Returns:
Path: Backup file path
Example:
>>> Path('important.txt').backup()
Path('important.txt.bak')
"""
backup_path = Path(str(self) + suffix)
return self.copy_to(backup_path, overwrite=True)
# ===============================================================
# FILE INFOsd
# ===============================================================
[docs]
def size(self) -> int:
"""
Get file size in bytes. For directories, returns total size.
Returns:
int: Size in bytes
Example:
>>> Path('file.txt').size()
1024
"""
if not self.exists():
return 0
if self.is_file():
return self.stat().st_size
elif self.is_dir():
total = 0
try:
for entry in self.rglob('*'):
if entry.is_file():
try:
total += entry.stat().st_size
except (OSError, PermissionError):
continue
except (OSError, PermissionError):
pass
return total
return 0
[docs]
def size_human(self) -> str:
"""
Get human-readable file size.
Returns:
str: Human-readable size (e.g., '1.5 MB')
Example:
>>> Path('file.txt').size_human()
'1.0 KB'
"""
size = self.size()
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size < 1024.0:
return f"{size:.1f} {unit}"
size /= 1024.0
return f"{size:.1f} PB"
[docs]
def mtime(self) -> float:
"""
Get modification time as timestamp.
Returns:
float: Modification timestamp
Example:
>>> Path('file.txt').mtime()
1704067200.0
"""
return self.stat().st_mtime
[docs]
def ctime(self) -> float:
"""
Get creation/metadata change time as timestamp.
Returns:
float: Creation timestamp
Example:
>>> Path('file.txt').ctime()
1704067200.0
"""
return self.stat().st_ctime
[docs]
def atime(self) -> float:
"""
Get access time as timestamp.
Returns:
float: Access timestamp
Example:
>>> Path('file.txt').atime()
1704067200.0
"""
return self.stat().st_atime
[docs]
def age(self) -> float:
"""
Get file age in seconds since last modification.
Returns:
float: Age in seconds
Example:
>>> Path('file.txt').age()
3600.5
"""
return datetime.now().timestamp() - self.mtime()
[docs]
def is_empty(self) -> bool:
"""
Check if file or directory is empty.
Returns:
bool: True if empty, False otherwise
Example:
>>> Path('empty.txt').is_empty()
True
"""
if not self.exists():
return True
if self.is_file():
return self.stat().st_size == 0
elif self.is_dir():
try:
return not any(self.iterdir())
except PermissionError:
return False
return False
[docs]
def is_newer_than(self, other: Union[str, _PathBase, 'Path']) -> bool:
"""
Check if this file is newer than another.
Args:
other: Other file path
Returns:
bool: True if this file is newer
Example:
>>> Path('new.txt').is_newer_than('old.txt')
True
"""
other = Path(other)
if not self.exists() or not other.exists():
return False
return self.mtime() > other.mtime()
[docs]
def is_older_than(self, other: Union[str, _PathBase, 'Path']) -> bool:
"""
Check if this file is older than another.
Args:
other: Other file path
Returns:
bool: True if this file is older
Example:
>>> Path('old.txt').is_older_than('new.txt')
True
"""
other = Path(other)
if not self.exists() or not other.exists():
return False
return self.mtime() < other.mtime()
# ===============================================================
# CONTENT OPERATIONS
# ===============================================================
[docs]
def lines(self, encoding: str = 'utf-8', strip: bool = True, skip_empty: bool = False) -> List[str]:
"""
Read file lines as list.
Args:
encoding: Text encoding (default: 'utf-8')
strip: Strip whitespace from lines
skip_empty: Skip empty lines
Returns:
list: List of lines
Example:
>>> Path('file.txt').lines()
['line 1', 'line 2', 'line 3']
"""
try:
lines = self.read_text(encoding=encoding).splitlines()
if strip:
lines = [line.strip() for line in lines]
if skip_empty:
lines = [line for line in lines if line]
return lines
except PermissionError:
raise PermissionError(f"Permission denied: '{self}'. File may be open in another program.")
except Exception as e:
raise IOError(f"Error reading file '{self}': {e}")
[docs]
def read_json(self, encoding: str = 'utf-8', **kwargs) -> Any:
"""
Read JSON file.
Args:
encoding: Text encoding (default: 'utf-8')
**kwargs: Additional arguments for json.load()
Returns:
Parsed JSON data
Example:
>>> Path('config.json').read_json()
{'key': 'value'}
"""
try:
return json.loads(self.read_text(encoding=encoding), **kwargs)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in file '{self}': {e}")
except PermissionError:
raise PermissionError(f"Permission denied: '{self}'. File may be open in another program.")
[docs]
def write_json(self, data: Any, encoding: str = 'utf-8', indent: int = 2, ensure_ascii: bool = False, **kwargs) -> 'Path':
"""
Write data as JSON file.
Args:
data: Data to write
encoding: Text encoding (default: 'utf-8')
indent: JSON indentation (default: 2)
ensure_ascii: Ensure ASCII encoding (default: False)
**kwargs: Additional arguments for json.dump()
Returns:
Path: self (for chaining)
Example:
>>> Path('config.json').write_json({'key': 'value'})
"""
try:
self.write_text(
json.dumps(data, indent=indent, ensure_ascii=ensure_ascii, **kwargs),
encoding=encoding
)
except PermissionError:
raise PermissionError(f"Permission denied: '{self}'. File may be open in another program.")
return self
[docs]
def read_pickle(self) -> Any:
"""
Read pickle file.
Returns:
Unpickled data
Example:
>>> Path('data.pkl').read_pickle()
{'key': 'value'}
"""
try:
return pickle.loads(self.read_bytes())
except PermissionError:
raise PermissionError(f"Permission denied: '{self}'. File may be open in another program.")
except pickle.UnpicklingError as e:
raise ValueError(f"Invalid pickle data in file '{self}': {e}")
[docs]
def write_pickle(self, data: Any, protocol: int = pickle.HIGHEST_PROTOCOL) -> 'Path':
"""
Write data as pickle file.
Args:
data: Data to pickle
protocol: Pickle protocol version
Returns:
Path: self (for chaining)
Example:
>>> Path('data.pkl').write_pickle({'key': 'value'})
"""
try:
self.write_bytes(pickle.dumps(data, protocol=protocol))
except PermissionError:
raise PermissionError(f"Permission denied: '{self}'. File may be open in another program.")
return self
[docs]
def hash(self, algorithm: str = 'sha256', chunk_size: int = 8192) -> str:
"""
Calculate file hash.
Args:
algorithm: Hash algorithm (md5, sha1, sha256, etc.)
chunk_size: Size of chunks to read (default: 8192 bytes)
Returns:
str: Hexadecimal hash
Example:
>>> Path('file.txt').hash()
'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e'
"""
if not self.is_file():
raise ValueError(f"Cannot hash non-file: '{self}'")
try:
h = hashlib.new(algorithm)
with self.open('rb') as f:
for chunk in iter(lambda: f.read(chunk_size), b''):
h.update(chunk)
return h.hexdigest()
except PermissionError:
raise PermissionError(f"Permission denied: '{self}'. File may be open in another program.")
except ValueError as e:
raise ValueError(f"Invalid hash algorithm '{algorithm}': {e}")
[docs]
def checksum(self, algorithm: str = 'sha256') -> str:
"""
Alias for hash().
Args:
algorithm: Hash algorithm (default: 'sha256')
Returns:
str: Hexadecimal hash
Example:
>>> Path('file.txt').checksum()
'a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e'
"""
return self.hash(algorithm)
[docs]
def count_lines(self, encoding: str = 'utf-8') -> int:
"""
Count lines in file.
Args:
encoding: Text encoding (default: 'utf-8')
Returns:
int: Number of lines
Example:
>>> Path('file.txt').count_lines()
42
"""
if not self.is_file():
raise ValueError(f"Cannot count lines in non-file: '{self}'")
try:
with self.open('r', encoding=encoding) as f:
return sum(1 for _ in f)
except PermissionError:
raise PermissionError(f"Permission denied: '{self}'. File may be open in another program.")
except UnicodeDecodeError as e:
raise ValueError(f"Cannot decode file '{self}' with encoding '{encoding}': {e}")
[docs]
def validate(self, file_type: Optional[str] = None, strict: bool = True) -> Tuple[bool, Optional[str]]:
"""
Validate file format (JSON, YAML, TOML, INI).
Args:
file_type: File type to validate ('json', 'yaml', 'yml', 'toml', 'ini')
If None, auto-detect from extension
strict: If True, raise error for missing libraries. If False, return (False, error_msg)
Returns:
tuple: (is_valid, error_message)
(True, None) if valid
(False, "error message") if invalid
Raises:
ImportError: If required library is not installed (when strict=True)
ValueError: If file type is not supported
Example:
>>> Path('config.json').validate()
(True, None)
>>> Path('config.yaml').validate()
(False, "PyYAML library not installed. Install with: pip install pyyaml")
>>> is_valid, error = Path('config.toml').validate(strict=False)
>>> if not is_valid:
... print(f"Invalid: {error}")
Supported formats:
- JSON (built-in, always available)
- YAML (requires PyYAML: pip install pyyaml)
- TOML (built-in Python 3.11+, or requires tomli: pip install tomli)
- INI (built-in, always available)
"""
# Auto-detect file type from extension
if file_type is None:
ext = self.ext().lower()
if ext in ('yaml', 'yml'):
file_type = 'yaml'
elif ext == 'toml':
file_type = 'toml'
elif ext == 'ini':
file_type = 'ini'
elif ext == 'json':
file_type = 'json'
else:
return (False, f"Unsupported file extension: .{ext}")
file_type = file_type.lower()
# Check if file exists
if not self.exists():
return (False, f"File does not exist: {self}")
if not self.is_file():
return (False, f"Not a file: {self}")
# Validate JSON (built-in)
if file_type == 'json':
try:
json.loads(self.read_text(encoding='utf-8'))
return (True, None)
except json.JSONDecodeError as e:
return (False, f"Invalid JSON: {e}")
except Exception as e:
return (False, f"Error reading file: {e}")
# Validate YAML (requires PyYAML)
elif file_type in ('yaml', 'yml'):
if not YAML_AVAILABLE:
error_msg = "PyYAML library not installed. Install with: pip install pyyaml"
if strict:
raise ImportError(error_msg)
return (False, error_msg)
try:
yaml.safe_load(self.read_text(encoding='utf-8')) # type: ignore
return (True, None)
except yaml.YAMLError as e: # type: ignore
return (False, f"Invalid YAML: {e}")
except Exception as e:
return (False, f"Error reading file: {e}")
# Validate TOML (built-in Python 3.11+ or requires tomli)
elif file_type == 'toml':
if not TOML_AVAILABLE:
if sys.version_info >= (3, 11):
error_msg = "TOML support error (this shouldn't happen on Python 3.11+)"
else:
error_msg = "tomli library not installed. Install with: pip install tomli"
if strict:
raise ImportError(error_msg)
return (False, error_msg)
try:
if sys.version_info >= (3, 11):
import tomllib
tomllib.loads(self.read_text(encoding='utf-8'))
else:
import tomli
tomli.loads(self.read_text(encoding='utf-8'))
return (True, None)
except Exception as e:
return (False, f"Invalid TOML: {e}")
# Validate INI (built-in)
elif file_type == 'ini':
try:
config = configparser.ConfigParser() # type: ignore
config.read_string(self.read_text(encoding='utf-8'))
return (True, None)
except configparser.Error as e: # type: ignore
return (False, f"Invalid INI: {e}")
except Exception as e:
return (False, f"Error reading file: {e}")
else:
return (False, f"Unsupported file type: {file_type}. Supported: json, yaml, yml, toml, ini")
# ===============================================================
# SEARCH & FILTER
# ===============================================================
[docs]
def find_files(self, pattern: str = "*") -> List['Path']:
"""
Find files matching pattern recursively.
Args:
pattern: Glob pattern
Returns:
list: List of matching file paths
Example:
>>> Path('/tmp').find_files('*.txt')
[Path('/tmp/file1.txt'), Path('/tmp/sub/file2.txt')]
"""
try:
return [Path(p) for p in self.rglob(pattern) if p.is_file()]
except PermissionError:
return []
[docs]
def find_dirs(self, pattern: str = "*") -> List['Path']:
"""
Find directories matching pattern recursively.
Args:
pattern: Glob pattern
Returns:
list: List of matching directory paths
Example:
>>> Path('/tmp').find_dirs('test*')
[Path('/tmp/test1'), Path('/tmp/sub/test2')]
"""
try:
return [Path(p) for p in self.rglob(pattern) if p.is_dir()]
except PermissionError:
return []
[docs]
def walk(self) -> Iterator[Tuple['Path', List[str], List[str]]]:
"""
Walk directory tree (similar to os.walk).
Yields:
tuple: (dirpath, dirnames, filenames)
Example:
>>> for dirpath, dirs, files in Path('/tmp').walk():
... print(f"Directory: {dirpath}")
... print(f"Subdirs: {dirs}")
... print(f"Files: {files}")
"""
for root, dirs, files in os.walk(self):
yield (Path(root), dirs, files)
# ===============================================================
# COMPARISON
# ===============================================================
[docs]
def same_content(self, other: Union[str, _PathBase, 'Path'], chunk_size: int = 8192) -> bool:
"""
Check if two files have the same content.
Args:
other: Other file path
chunk_size: Size of chunks for comparison (default: 8192)
Returns:
bool: True if contents are identical
Example:
>>> Path('file1.txt').same_content('file2.txt')
False
"""
other = Path(other)
if not self.is_file() or not other.is_file():
return False
# Quick check: different sizes = different content
if self.size() != other.size():
return False
# For small files, compare directly
if self.size() < 1024 * 1024: # Less than 1MB
try:
return self.read_bytes() == other.read_bytes()
except (PermissionError, OSError):
return False
# For large files, use hash comparison
try:
return self.hash() == other.hash()
except (PermissionError, OSError):
return False
# ===============================================================
# show music tag, require 'mutagen' package
# ===============================================================
[docs]
def create_table(self) -> Optional[Table]: # type: ignore
"""
Create a Rich Table for displaying music tags.
Returns:
Table: Rich Table object or None if Rich is not available
"""
if RICH_AVAILABLE:
table = Table(title=f"ID3 Tags - {self.basename()}") # type: ignore
table.add_column("Tag", style="cyan", no_wrap=True)
table.add_column("Type", style="magenta")
table.add_column("Value", style="yellow")
table.add_column("Size", style="green")
return table
return None
[docs]
def show_info(self, table: Optional[Table] = None, exts: Optional[List] = ['mp3', 'mp4', 'm4a', 'flac', 'ogg', 'wav', 'wma', 'aac'], no_rich = False) -> None: # type: ignore
"""
Show music file tags (requires 'mutagen' package).
Args:
table: Rich Table object to populate (if None, a new one is created)
exts: List of file extensions to consider as music files
Returns:
None
Example:
>>> Path('song.mp3').show_info()
>>> Path('music_dir').show_info()
"""
if not MUTAGEN_AVAILABLE:
print("mutagen package is not installed. Please install it before.")
return None
if self.is_file():
if not self.ext().lower() in [i.lower() for i in exts]: # type: ignore
return None
try:
audio = ID3(self) # type: ignore
except Exception:
return None
if RICH_AVAILABLE and not no_rich:
table = table or self.create_table() # type: ignore
if not table:
return None
for tag_key in audio.keys():
tag_value = audio[tag_key]
tag_type = type(tag_value).__name__
# print(f"tag_value: {tag_value}")
# print(f"type(tag_value): {type(tag_value)}")
# print(f"dir(tag_value): {dir(tag_value)}")
# Value format based on tag type
if hasattr(tag_value, "text"):
# print(f"tag_value.text: {tag_value.text}")
# print(f"type(tag_value.text): {type(tag_value.text)}")
value = tag_value.text[0] if isinstance(tag_value.text, list) else str(tag_value.text)
# print(f"value: {value}")
# print(f"type(value): {type(value)}")
# print(f"dir(value): {dir(value)}")
if hasattr(value, 'text'):
# print(f"value.text: {value.text}")
# print(f"type(value.text): {type(value.text)}")
# print(f"dir(value.text): {dir(value.text)}")
value = value.text # type: ignore
elif hasattr(tag_value, "url"):
value = tag_value.url
elif hasattr(tag_value, "data"):
value = f"Binary data ({len(tag_value.data)} bytes)"
else:
value = " ".join(tag_value) if isinstance(tag_value, (list or tuple)) else str(tag_value)
# Trim the value if it is too long
if len(value) > 100:
value = value[:97] + "..."
# Calculate the size
size = "N/A"
if hasattr(tag_value, "data"):
size = f"{len(tag_value.data)} bytes" # type: ignore
elif hasattr(tag_value, "__sizeof__"):
try:
size = f"{tag_value.__sizeof__()} bytes"
except:
size = "Unknown"
table.add_row(tag_key, tag_type, value, size)
# print("="*os.get_terminal_size()[0])
# Show basic file info
file_size = os.path.getsize(self)
file_mtime = os.path.getmtime(self)
mtime_str = datetime.fromtimestamp(file_mtime).strftime('%Y-%m-%d %H:%M:%S')
console.print(table)
console.print(f"\n[bold]File Info:[/]")
console.print(f" Path: {self}")
console.print(f" Size: {file_size:,} bytes ({file_size/1024/1024:.2f} MB)")
console.print(f" Modified: {mtime_str}")
console.print(f" Number of tags: {len(audio.keys())}")
else:
print(f"ID3 Tags - {self.basename()}")
print("-" * 40)
for tag_key in audio.keys():
tag_value = audio[tag_key]
# Value format based on tag type
if hasattr(tag_value, "text"):
value = str(tag_value.text)
elif hasattr(tag_value, "url"):
value = tag_value.url
elif hasattr(tag_value, "data"):
value = f"Binary data ({len(tag_value.data)} bytes)"
else:
value = str(tag_value)
# Trim the value if it is too long
if len(value) > 100:
value = value[:97] + "..."
print(f"{tag_key}: {value}")
print("-" * 40)
file_size = os.path.getsize(self)
file_mtime = os.path.getmtime(self)
mtime_str = datetime.fromtimestamp(file_mtime).strftime('%Y-%m-%d %H:%M:%S')
print(f"File Info:")
print(f" Path: {self}")
print(f" Size: {file_size:,} bytes ({file_size/1024/1024:.2f} MB)")
print(f" Modified: {mtime_str}")
print(f" Number of tags: {len(audio.keys())}")
elif self.is_dir():
for item in self.iterdir():
if item.is_file() and item.ext().lower() in [i.lower() for i in exts]: # type: ignore
item.show_info(table)
[docs]
def music_tag(self, exts: Optional[List] = ['mp3', 'mp4', 'm4a', 'flac', 'ogg', 'wav', 'wma', 'aac']) -> Optional[dict]:
"""
Get music file tags (requires 'mutagen' package).
Returns:
dict: Music tags or None if not a music file or mutagen not installed
Example:
>>> Path('song.mp3').music_tag()
{'title': 'Song Title', 'artist': 'Artist Name'}
"""
if not MUTAGEN_AVAILABLE:
print("mutagen package is not installed. Please install it to before.")
return None
if self.is_file():
if not self.ext().lower() in [i.lower() for i in exts]: # type: ignore
return None
try:
audio = MutagenFile(self) # type: ignore
if audio is None:
return None
tags = {}
for key in audio.keys():
tags[key] = audio[key]
return tags
except Exception:
return None
elif self.is_dir():
all_tags = {}
for item in self.iterdir():
if item.is_file() and item.ext().lower() in [i.lower() for i in exts]: # type: ignore
tags = item.music_tag()
if tags:
all_tags[item.basename()] = tags
return all_tags
# ================================================================
# METADATA
# ================================================================
[docs]
def email_as_attachment(
self,
to: Union[str, List[str]],
subject: str,
body: str = '',
config: Optional[EmailConfig] = None,
from_addr: Optional[str] = None,
cc: Optional[Union[str, List[str]]] = None,
bcc: Optional[Union[str, List[str]]] = None,
body_html: Optional[str] = None,
attachment_name: Optional[str] = None,
inline_images: bool = False
) -> bool:
"""
Send this file as email attachment.
Args:
to: Recipient email(s)
subject: Email subject
body: Email body (plain text)
config: EmailConfig instance with SMTP settings
from_addr: Sender email (if None, uses config.username)
cc: CC recipients
bcc: BCC recipients
body_html: HTML body (optional, overrides plain body)
attachment_name: Custom attachment filename (default: file's name)
inline_images: Embed images inline (for image files only)
Returns:
bool: True if sent successfully
Raises:
ImportError: If email libraries not available
ValueError: If config is not provided or file doesn't exist
ConnectionError: If SMTP connection fails
Example:
>>> config = EmailConfig.gmail('me@gmail.com', 'app_password')
>>> Path('report.pdf').email_as_attachment(
... to='boss@company.com',
... subject='Monthly Report',
... body='Please find attached report.',
... config=config
... )
True
>>> # Multiple recipients
>>> Path('invoice.pdf').email_as_attachment(
... to=['client@company.com', 'manager@company.com'],
... subject='Invoice #12345',
... body='Invoice attached.',
... cc='accounting@company.com',
... config=config
... )
"""
if not EMAIL_AVAILABLE:
raise ImportError("Email modules not available")
if config is None:
raise ValueError(
"EmailConfig required. Example: "
"config = EmailConfig.gmail('user@gmail.com', 'password')"
)
if not self.exists():
raise ValueError(f"File does not exist: {self}")
if not self.is_file():
raise ValueError(f"Not a file: {self}")
# Normalize recipients
to_list = [to] if isinstance(to, str) else to
cc_list = [cc] if isinstance(cc, str) else (cc or [])
bcc_list = [bcc] if isinstance(bcc, str) else (bcc or [])
from_addr = from_addr or config.username
# Create message
msg = MIMEMultipart('alternative' if body_html else 'mixed')
msg['From'] = from_addr
msg['To'] = ', '.join(to_list)
msg['Subject'] = subject
if cc_list:
msg['Cc'] = ', '.join(cc_list)
# Add body
if body_html:
msg.attach(MIMEText(body, 'plain'))
msg.attach(MIMEText(body_html, 'html'))
else:
msg.attach(MIMEText(body, 'plain'))
# Attach file
attachment_name = attachment_name or self.name
# Handle inline images
if inline_images and self.ext().lower() in ('jpg', 'jpeg', 'png', 'gif', 'bmp'):
try:
with self.open('rb') as f:
img_data = f.read()
image = MIMEImage(img_data)
image.add_header('Content-ID', f'<{attachment_name}>')
image.add_header('Content-Disposition', 'inline', filename=attachment_name)
msg.attach(image)
except Exception as e:
raise ValueError(f"Failed to attach inline image: {e}")
else:
# Regular attachment
try:
with self.open('rb') as f:
# Detect MIME type based on extension
ext = self.ext().lower()
if ext in ('jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff'):
attachment = MIMEImage(f.read())
elif ext in ('mp3', 'wav', 'ogg', 'flac'):
attachment = MIMEAudio(f.read())
elif ext == 'pdf':
attachment = MIMEApplication(f.read(), _subtype='pdf')
else:
# Generic binary attachment
part = MIMEBase('application', 'octet-stream')
part.set_payload(f.read())
encoders.encode_base64(part)
attachment = part
attachment.add_header(
'Content-Disposition',
f'attachment; filename="{attachment_name}"'
)
msg.attach(attachment)
except Exception as e:
raise ValueError(f"Failed to attach file: {e}")
# Send email
try:
if config.use_ssl:
server = smtplib.SMTP_SSL(
config.smtp_server,
config.smtp_port,
timeout=config.timeout
)
else:
server = smtplib.SMTP(
config.smtp_server,
config.smtp_port,
timeout=config.timeout
)
if config.use_tls:
server.starttls()
if config.username and config.password:
server.login(config.username, config.password)
# Send to all recipients
all_recipients = to_list + cc_list + bcc_list
server.sendmail(from_addr, all_recipients, msg.as_string())
server.quit()
return True
except smtplib.SMTPAuthenticationError:
raise ConnectionError(
"SMTP authentication failed. Check username/password. "
"For Gmail, use an App Password: https://myaccount.google.com/apppasswords"
)
except smtplib.SMTPException as e:
raise ConnectionError(f"SMTP error: {e}")
except Exception as e:
raise ConnectionError(f"Failed to send email: {e}")
[docs]
@staticmethod
def send_email(
to: Union[str, List[str]],
subject: str,
body: str,
config: EmailConfig,
from_addr: Optional[str] = None,
cc: Optional[Union[str, List[str]]] = None,
bcc: Optional[Union[str, List[str]]] = None,
body_html: Optional[str] = None,
attachments: Optional[List[Union[str, 'Path']]] = None
) -> bool:
"""
Send email with optional multiple attachments.
Args:
to: Recipient email(s)
subject: Email subject
body: Email body (plain text)
config: EmailConfig instance
from_addr: Sender email
cc: CC recipients
bcc: BCC recipients
body_html: HTML body
attachments: List of file paths to attach
Returns:
bool: True if sent successfully
Example:
>>> config = EmailConfig.gmail('me@gmail.com', 'password')
>>> Path.send_email(
... to='boss@company.com',
... subject='Weekly Report',
... body='See attached files.',
... config=config,
... attachments=['report.pdf', 'chart.png']
... )
"""
if not EMAIL_AVAILABLE:
raise ImportError("Email modules not available")
# Normalize recipients
to_list = [to] if isinstance(to, str) else to
cc_list = [cc] if isinstance(cc, str) else (cc or [])
bcc_list = [bcc] if isinstance(bcc, str) else (bcc or [])
from_addr = from_addr or config.username
# Create message
msg = MIMEMultipart('alternative' if body_html else 'mixed')
msg['From'] = from_addr
msg['To'] = ', '.join(to_list)
msg['Subject'] = subject
if cc_list:
msg['Cc'] = ', '.join(cc_list)
# Add body
if body_html:
msg.attach(MIMEText(body, 'plain'))
msg.attach(MIMEText(body_html, 'html'))
else:
msg.attach(MIMEText(body, 'plain'))
# Add attachments
if attachments:
for file_path in attachments:
file_path = Path(file_path)
if not file_path.exists():
raise ValueError(f"Attachment not found: {file_path}")
try:
with file_path.open('rb') as f:
ext = file_path.ext().lower()
if ext in ('jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff'):
attachment = MIMEImage(f.read())
elif ext in ('mp3', 'wav', 'ogg', 'flac'):
attachment = MIMEAudio(f.read())
elif ext == 'pdf':
attachment = MIMEApplication(f.read(), _subtype='pdf')
else:
part = MIMEBase('application', 'octet-stream')
part.set_payload(f.read())
encoders.encode_base64(part)
attachment = part
attachment.add_header(
'Content-Disposition',
f'attachment; filename="{file_path.name}"'
)
msg.attach(attachment)
except Exception as e:
raise ValueError(f"Failed to attach {file_path}: {e}")
# Send email
try:
if config.use_ssl:
server = smtplib.SMTP_SSL(
config.smtp_server,
config.smtp_port,
timeout=config.timeout
)
else:
server = smtplib.SMTP(
config.smtp_server,
config.smtp_port,
timeout=config.timeout
)
if config.use_tls:
server.starttls()
if config.username and config.password:
server.login(config.username, config.password)
all_recipients = to_list + cc_list + bcc_list
server.sendmail(from_addr, all_recipients, msg.as_string())
server.quit()
return True
except smtplib.SMTPAuthenticationError:
raise ConnectionError(
"SMTP authentication failed. Check username/password. "
"For Gmail, use an App Password: https://myaccount.google.com/apppasswords"
)
except smtplib.SMTPException as e:
raise ConnectionError(f"SMTP error: {e}")
except Exception as e:
raise ConnectionError(f"Failed to send email: {e}")
[docs]
def to_ico(
self,
sizes: Optional[List[int]] = None,
output_path: Optional[Union[str, 'Path']] = None,
multi_size: bool = False,
overwrite: bool = False
) -> Union['Path', List['Path']]:
"""
Convert image (PNG, JPEG, etc.) to ICO format.
Args:
sizes: List of icon sizes (default: [16, 32, 48, 64, 128, 256])
output_path: Output path (default: same name with .ico extension)
multi_size: Create one multi-size ICO file (default: separate files)
overwrite: Overwrite existing files
Returns:
Path or List[Path]: Created ICO file(s)
Raises:
ImportError: If Pillow is not installed
ValueError: If file is not an image or conversion fails
Example:
>>> # Single image to multiple ICO files
>>> Path('logo.png').to_ico()
[Path('logo_16.ico'), Path('logo_32.ico'), ...]
>>> # Custom sizes
>>> Path('icon.png').to_ico(sizes=[16, 32, 64])
>>> # Multi-size ICO (Windows style)
>>> Path('app.png').to_ico(multi_size=True)
Path('app.ico')
>>> # Custom output path
>>> Path('image.jpg').to_ico(
... output_path='favicon.ico',
... multi_size=True
... )
"""
if not PIL_AVAILABLE:
raise ImportError(
"Pillow library not installed. Install with: pip install Pillow"
)
if not self.exists():
raise ValueError(f"File does not exist: {self}")
if not self.is_file():
raise ValueError(f"Not a file: {self}")
# Default sizes
if sizes is None:
sizes = [16, 32, 48, 64, 128, 256]
# Validate sizes
for size in sizes:
if size <= 0:
raise ValueError(f"Invalid size: {size}. Sizes must be positive.")
if size > 1024:
raise ValueError(f"Size {size} too large. Maximum is 1024.")
try:
from PIL import Image
# Open and verify image
with Image.open(self) as img:
img.verify()
# Reopen for processing
with Image.open(self) as img:
img_rgba = img.convert("RGBA")
if multi_size:
# Create one multi-size ICO file
if output_path is None:
output_path = Path(self.with_suffix('.ico'))
else:
output_path = Path(output_path)
if output_path.exists() and not overwrite:
raise FileExistsError(f"File exists: {output_path}")
# Prepare all sizes
ico_images = []
for size in sizes:
square = self._make_square_image(img_rgba, size)
ico_images.append((size, square))
# Sort by size (largest first)
ico_images.sort(key=lambda x: x[0], reverse=True)
# Save multi-size ICO
base_size, base_image = ico_images[0]
all_images = [img for _, img in ico_images]
all_sizes = [(s, s) for s, _ in ico_images]
base_image.save(
output_path,
format="ICO",
sizes=all_sizes,
append_images=all_images[1:] if len(all_images) > 1 else []
)
return output_path
else:
# Create separate ICO files for each size
output_files = []
for size in sizes:
if output_path is None:
out_file = Path(self.parent / f"{self.stem}_{size}.ico")
else:
# Use provided path with size suffix
base = Path(output_path).stem
out_file = Path(output_path).parent / f"{base}_{size}.ico"
if out_file.exists() and not overwrite:
raise FileExistsError(f"File exists: {out_file}")
# Create square and save
square = self._make_square_image(img_rgba, size)
square.save(out_file, format="ICO")
output_files.append(out_file)
return output_files
except Exception as e:
raise ValueError(f"Failed to convert to ICO: {e}")
def _make_square_image(self, img: 'Image.Image', size: int) -> 'Image.Image':
"""
Internal helper: Resize and pad image to square with transparency.
Args:
img: PIL Image (RGBA mode)
size: Target size
Returns:
Image: Square image with padding
"""
from PIL import Image
if size <= 0:
raise ValueError(f"Invalid size: {size}")
w, h = img.size
if w == 0 or h == 0:
raise ValueError("Image has zero dimensions")
# Already square and correct size
if w == h == size:
return img.copy()
# Calculate scaling to fit in square
scale = min(size / w, size / h)
new_w = max(1, int(w * scale))
new_h = max(1, int(h * scale))
# Resize with high quality
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
# Create transparent canvas
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
# Center the image
paste_x = (size - new_w) // 2
paste_y = (size - new_h) // 2
canvas.paste(resized, (paste_x, paste_y), resized)
return canvas
[docs]
def resize(
self,
width: Optional[int] = None,
height: Optional[int] = None,
max_size: Optional[int] = None,
keep_aspect: bool = True,
output_path: Optional[Union[str, 'Path']] = None,
quality: int = 95,
optimize: bool = True
) -> 'Path':
"""
Resize image with aspect ratio preservation.
Args:
width: Target width (None = auto from height)
height: Target height (None = auto from width)
max_size: Maximum dimension (scales to fit)
keep_aspect: Preserve aspect ratio
output_path: Output path (default: overwrite original)
quality: JPEG quality 1-100 (default: 95)
optimize: Optimize output file size
Returns:
Path: Output file path
Raises:
ImportError: If Pillow not installed
ValueError: If invalid parameters
Example:
>>> # Resize to specific width (auto height)
>>> Path('image.jpg').resize(width=800)
>>> # Resize to fit in 1024x1024
>>> Path('photo.png').resize(max_size=1024)
>>> # Exact size (no aspect ratio)
>>> Path('banner.jpg').resize(
... width=1200,
... height=400,
... keep_aspect=False
... )
>>> # Save to different file
>>> Path('original.png').resize(
... width=500,
... output_path='thumbnail.png'
... )
"""
if not PIL_AVAILABLE:
raise ImportError(
"Pillow library not installed. Install with: pip install Pillow"
)
if not self.exists():
raise ValueError(f"File does not exist: {self}")
# Validate parameters
if width is None and height is None and max_size is None:
raise ValueError("Must specify width, height, or max_size")
try:
from PIL import Image
with Image.open(self) as img:
orig_w, orig_h = img.size
# Calculate new dimensions
if max_size is not None:
# Scale to fit in max_size x max_size
scale = min(max_size / orig_w, max_size / orig_h)
new_w = int(orig_w * scale)
new_h = int(orig_h * scale)
elif width is not None and height is not None:
if keep_aspect:
# Use width/height as max dimensions
scale = min(width / orig_w, height / orig_h)
new_w = int(orig_w * scale)
new_h = int(orig_h * scale)
else:
# Exact dimensions (may distort)
new_w = width
new_h = height
elif width is not None:
# Width specified, calculate height
new_w = width
new_h = int(orig_h * (width / orig_w)) if keep_aspect else orig_h
else: # height is not None
# Height specified, calculate width
new_h = height
new_w = int(orig_w * (height / orig_h)) if keep_aspect else orig_w
# Ensure minimum size
new_w = max(1, new_w)
new_h = max(1, new_h)
# Resize image
resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
# Determine output path
if output_path is None:
output_path = self
else:
output_path = Path(output_path)
# Save with appropriate format
save_kwargs = {}
if output_path.suffix.lower() in ('.jpg', '.jpeg'):
save_kwargs['quality'] = quality
save_kwargs['optimize'] = optimize
elif output_path.suffix.lower() == '.png':
save_kwargs['optimize'] = optimize
resized.save(output_path, **save_kwargs)
return Path(output_path)
except Exception as e:
raise ValueError(f"Failed to resize image: {e}")
[docs]
def thumbnail(
self,
size: int = 256,
output_path: Optional[Union[str, 'Path']] = None,
suffix: str = '_thumb',
square: bool = False
) -> 'Path':
"""
Create thumbnail from image.
Args:
size: Maximum dimension (default: 256)
output_path: Output path (default: add suffix to filename)
suffix: Filename suffix (default: '_thumb')
square: Make square thumbnail with padding
Returns:
Path: Thumbnail file path
Example:
>>> Path('photo.jpg').thumbnail()
Path('photo_thumb.jpg')
>>> Path('image.png').thumbnail(size=128, square=True)
Path('image_thumb.png')
"""
if not PIL_AVAILABLE:
raise ImportError(
"Pillow library not installed. Install with: pip install Pillow"
)
try:
from PIL import Image
with Image.open(self) as img:
if square:
# Create square thumbnail with padding
img_rgba = img.convert("RGBA")
thumb = self._make_square_image(img_rgba, size)
else:
# Standard thumbnail (maintain aspect ratio)
img.thumbnail((size, size), Image.Resampling.LANCZOS)
thumb = img.copy()
# Determine output path
if output_path is None:
output_path = Path(self.parent / f"{self.stem}{suffix}{self.suffix}")
else:
output_path = Path(output_path)
# Save thumbnail
thumb.save(output_path)
return output_path
except Exception as e:
raise ValueError(f"Failed to create thumbnail: {e}")
# ===================================================================
# PurePath3 - Extended PurePath (for path manipulation without I/O)
# ===================================================================
[docs]
class PurePath3(PurePath):
"""Extended PurePath with additional utility methods (no I/O operations)"""
[docs]
def ext(self) -> str:
"""Get file extension without the dot"""
return self.suffix.lstrip('.')
[docs]
def basename(self) -> str:
"""Get the base name (filename with extension)"""
return self.name
[docs]
def base(self) -> str:
"""Get the base name without extension"""
return self.stem
[docs]
def dirname(self) -> str:
"""Get the directory name as string"""
return str(self.parent)
[docs]
def join(self, *args) -> 'PurePath3':
"""Join path components"""
result = self
for arg in args:
result = result / arg
return PurePath3(result)
[docs]
def split_ext(self) -> Tuple[str, str]:
"""Split path into base and extension"""
return (str(self.with_suffix('')), self.suffix)
[docs]
def split_path(self) -> List[str]:
"""Split path into list of components"""
return list(self.parts)
[docs]
def change_ext(self, new_ext: str) -> 'PurePath3':
"""Change file extension"""
if not new_ext.startswith('.'):
new_ext = '.' + new_ext
return PurePath3(self.with_suffix(new_ext))
def get_version():
"""Get version from __version__.py file"""
try:
version_file = Path(__file__).parent / "__version__.py"
if version_file.is_file():
with open(version_file, "r") as f:
for line in f:
if line.strip().startswith("version"):
parts = line.split("=")
if len(parts) == 2:
return parts[1].strip().strip('"').strip("'")
except Exception as e:
if os.getenv('TRACEBACK') and os.getenv('TRACEBACK') in ['1', 'true', 'True']:
print(traceback.format_exc())
else:
print(f"ERROR: {e}")
return "3.0.0"
# ===================================================================
# EXPORTS - Re-export everything from pathlib + new classes
# ===================================================================
__all__ = [
# Original pathlib exports (except Path which we override)
'PurePath',
'PosixPath',
'WindowsPath',
'PurePosixPath',
'PureWindowsPath',
# New extended classes
'Path', # Our extended Path class
'PurePath3',
'YAML_AVAILABLE',
'TOML_AVAILABLE',
'INI_AVAILABLE',
'PIL_AVAILABLE',
'PYPDF2_AVAILABLE',
'MUTAGEN_AVAILABLE',
'PYTHON_DOCX_AVAILABLE',
'OPENPYXL_AVAILABLE',
'RICH_AVAILABLE',
'EmailConfig',
'EMAIL_AVAILABLE',
]
__version__ = get_version()
__author__ = 'Hadi Cahyadi'
__description__ = 'Extended pathlib with 40+ additional utility methods'