Files
GenPass/genpass/cli.py
kilyabin eeca690c0a feat: add type hints, new CLI features, and improve project structure
chore: add uv.lock to gitignore

Major changes:
- Add type hints and docstrings to all functions (PEP 484)
- Add __main__.py for module execution (python -m genpass)
- Expose public API in __init__.py
- Add config file support (~/.genpass/config.json)
- Add entropy calculation (--entropy)
- Add clipboard support (--clipboard/-c)
- Add ambiguous characters exclusion (--no-ambiguous)
- Add output formats: plain, json, delimited (--format)
- Add sensible defaults (all character types enabled by default)
- Add input validation for length and count
- Update shell completions for all new options
- Add pre-commit config with ruff, mypy, black
- Update pyproject.toml with dev dependencies and tool configs
- Add requirements.txt and requirements-dev.txt
- Update README.md with comprehensive examples

BREAKING CHANGE: Default behavior now includes all character types
2026-03-10 00:43:53 +04:00

516 lines
14 KiB
Python

"""
GenPass - Secure password generator CLI.
This module provides functionality to generate strong, random passwords
with customizable character sets, entropy calculation, and various output formats.
"""
import argparse
import json
import math
import secrets
import string
import sys
from pathlib import Path
from typing import List, Optional
try:
import pyperclip # type: ignore
HAS_PYPERCLIP = True
except ImportError:
HAS_PYPERCLIP = False
# Default character sets
DEFAULT_SYMBOLS = "!@#$%^&*()-_=+[]{};:,.<>?"
AMBIGUOUS_CHARS = "l1IO0"
# Config file path
CONFIG_DIR = Path.home() / ".genpass"
CONFIG_FILE = CONFIG_DIR / "config.json"
def load_config() -> dict:
"""
Load user configuration from ~/.genpass/config.json.
Returns:
Dictionary containing user configuration settings.
Returns empty dict if config file doesn't exist or is invalid.
"""
if CONFIG_FILE.exists():
try:
with open(CONFIG_FILE, encoding="utf-8") as f:
config = json.load(f)
return config if isinstance(config, dict) else {}
except (OSError, json.JSONDecodeError):
return {}
return {}
def save_config(config: dict) -> None:
"""
Save user configuration to ~/.genpass/config.json.
Args:
config: Dictionary containing configuration settings to save.
"""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
def calculate_entropy(password: str, pool_size: int) -> float:
"""
Calculate the entropy (in bits) of a generated password.
Entropy is calculated as: length * log2(pool_size)
Higher entropy indicates a stronger password.
Args:
password: The generated password.
pool_size: Total number of possible characters in the pool.
Returns:
Entropy value in bits.
"""
if pool_size <= 1:
return 0.0
return len(password) * math.log2(pool_size)
def get_character_pools(
use_lower: bool,
use_upper: bool,
use_digits: bool,
use_symbols: bool,
symbol_set: str,
exclude_ambiguous: bool,
) -> List[str]:
"""
Build character pools based on user preferences.
Args:
use_lower: Include lowercase letters.
use_upper: Include uppercase letters.
use_digits: Include digits.
use_symbols: Include symbols.
symbol_set: Custom set of symbols to use.
exclude_ambiguous: Exclude ambiguous characters (l, 1, I, O, 0).
Returns:
List of character pool strings.
"""
pools = []
if use_lower:
chars = string.ascii_lowercase
if exclude_ambiguous:
chars = chars.replace("l", "")
pools.append(chars)
if use_upper:
chars = string.ascii_uppercase
if exclude_ambiguous:
chars = chars.replace("IO", "")
pools.append(chars)
if use_digits:
chars = string.digits
if exclude_ambiguous:
chars = chars.replace("10", "")
pools.append(chars)
if use_symbols:
chars = symbol_set
if exclude_ambiguous:
for char in AMBIGUOUS_CHARS:
chars = chars.replace(char, "")
if chars:
pools.append(chars)
return pools
def generate_password(
length: int,
pools: List[str],
ensure_each: bool = True,
) -> str:
"""
Generate a secure random password.
Args:
length: Desired password length.
pools: List of character pools to choose from.
ensure_each: Ensure at least one character from each pool.
Returns:
Generated password string.
Raises:
ValueError: If no character pools provided or length too small.
"""
if not pools:
raise ValueError("No character sets selected")
password: List[str] = []
# Ensure at least one character from each pool
if ensure_each:
if length < len(pools):
raise ValueError("Password length too small for ensure_each option")
for pool in pools:
password.append(secrets.choice(pool))
# Fill remaining length with random characters from all pools
all_chars = "".join(pools)
while len(password) < length:
password.append(secrets.choice(all_chars))
# Shuffle to randomize positions
secrets.SystemRandom().shuffle(password)
return "".join(password)
def format_output(
passwords: List[str],
output_format: str,
show_entropy: bool = False,
entropy_value: Optional[float] = None,
) -> str:
"""
Format passwords for output.
Args:
passwords: List of generated passwords.
output_format: Output format ('plain', 'json', 'delimited').
show_entropy: Whether to include entropy information.
entropy_value: Entropy value to include in output.
Returns:
Formatted output string.
"""
if output_format == "json":
output_data: dict = {"passwords": passwords}
if show_entropy and entropy_value is not None:
output_data["entropy_bits"] = round(entropy_value, 2)
return json.dumps(output_data, indent=2)
if output_format == "delimited":
result = "\n".join(passwords)
if show_entropy and entropy_value is not None:
result += f"\n# Entropy: {entropy_value:.2f} bits"
return result
# Plain format (default)
result = "\n".join(passwords)
if show_entropy and entropy_value is not None:
result += f"\n# Entropy: {entropy_value:.2f} bits"
return result
def copy_to_clipboard(text: str) -> bool:
"""
Copy text to system clipboard.
Args:
text: Text to copy to clipboard.
Returns:
True if successful, False otherwise.
"""
if not HAS_PYPERCLIP:
return False
try:
pyperclip.copy(text)
return True
except Exception:
return False
def validate_args(args: argparse.Namespace) -> None:
"""
Validate command-line arguments.
Args:
args: Parsed command-line arguments.
Raises:
ValueError: If arguments are invalid.
"""
if args.length <= 0:
raise ValueError("Password length must be positive")
if args.length > 1000:
raise ValueError("Password length cannot exceed 1000")
if args.count <= 0:
raise ValueError("Password count must be positive")
if args.count > 100:
raise ValueError("Password count cannot exceed 100")
if args.symbol_set and len(args.symbol_set) < 1:
raise ValueError("Symbol set cannot be empty")
def create_parser() -> argparse.ArgumentParser:
"""
Create and configure the argument parser.
Returns:
Configured ArgumentParser instance.
"""
parser = argparse.ArgumentParser(
prog="genpass",
description="Secure password generator CLI - Generate strong, random passwords",
epilog="Examples:\n"
" genpass Generate one 16-char password (all char types)\n"
" genpass -l 20 -n 5 Generate 5 passwords of length 20\n"
" genpass --lower --upper --digits Generate password without symbols\n"
" genpass --no-ambiguous Exclude confusing characters\n"
" genpass --entropy Show password entropy\n"
" genpass --format json Output as JSON\n"
" genpass --clipboard Copy to clipboard\n",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
# Password options
parser.add_argument(
"-l",
"--length",
type=int,
default=None,
help="Password length (default: 16)",
)
parser.add_argument(
"-n",
"--count",
type=int,
default=None,
help="Number of passwords to generate (default: 1)",
)
# Character sets
parser.add_argument(
"--lower",
action="store_true",
default=None,
help="Include lowercase letters (default: yes)",
)
parser.add_argument(
"--upper",
action="store_true",
default=None,
help="Include uppercase letters (default: yes)",
)
parser.add_argument(
"--digits",
action="store_true",
default=None,
help="Include digits (default: yes)",
)
parser.add_argument(
"--symbols",
action="store_true",
default=None,
help="Include symbols (default: yes)",
)
parser.add_argument(
"--symbol-set",
type=str,
default=None,
help="Custom symbol set (default: " + DEFAULT_SYMBOLS.replace("%", "%%") + ")",
)
# Options
parser.add_argument(
"--no-ensure",
action="store_true",
help="Disable ensuring at least one char from each selected type",
)
parser.add_argument(
"--no-ambiguous",
action="store_true",
help="Exclude ambiguous characters (l, 1, I, O, 0)",
)
parser.add_argument(
"--entropy",
action="store_true",
help="Calculate and display password entropy",
)
parser.add_argument(
"--clipboard",
"-c",
action="store_true",
help="Copy the first password to clipboard",
)
# Output options
parser.add_argument(
"--format",
choices=["plain", "json", "delimited"],
default="plain",
help="Output format (default: plain)",
)
parser.add_argument(
"--config",
action="store_true",
help="Show current configuration and exit",
)
parser.add_argument(
"--save-config",
action="store_true",
help="Save current options as defaults",
)
return parser
def apply_defaults(args: argparse.Namespace, config: dict) -> argparse.Namespace:
"""
Apply default values from config to arguments.
Args:
args: Parsed command-line arguments.
config: Loaded configuration dictionary.
Returns:
Updated arguments with defaults applied.
"""
if args.length is None:
args.length = config.get("length", 16)
if args.count is None:
args.count = config.get("count", 1)
# For boolean flags, use config if not explicitly set
if args.lower is None:
args.lower = config.get("lower", True)
if args.upper is None:
args.upper = config.get("upper", True)
if args.digits is None:
args.digits = config.get("digits", True)
if args.symbols is None:
args.symbols = config.get("symbols", True)
if args.symbol_set is None:
args.symbol_set = config.get("symbol_set", DEFAULT_SYMBOLS)
if not getattr(args, "no_ambiguous", False):
args.no_ambiguous = config.get("no_ambiguous", False)
return args
def main() -> None:
"""Main entry point for the genpass CLI."""
parser = create_parser()
args = parser.parse_args()
# Load config
config = load_config()
# Show config if requested
if args.config:
if config:
print(json.dumps(config, indent=2))
else:
print("No configuration file found. Using defaults.")
return
# Apply defaults from config
args = apply_defaults(args, config)
# Validate arguments
try:
validate_args(args)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Build character pools
try:
pools = get_character_pools(
use_lower=args.lower,
use_upper=args.upper,
use_digits=args.digits,
use_symbols=args.symbols,
symbol_set=args.symbol_set,
exclude_ambiguous=args.no_ambiguous,
)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if not pools:
print("Error: Select at least one character set", file=sys.stderr)
sys.exit(1)
# Generate passwords
passwords: List[str] = []
entropy_value: Optional[float] = None
try:
for _ in range(args.count):
pwd = generate_password(
length=args.length,
pools=pools,
ensure_each=not args.no_ensure,
)
passwords.append(pwd)
# Calculate entropy for the first password
if args.entropy:
pool_size = sum(len(pool) for pool in pools)
entropy_value = calculate_entropy(passwords[0], pool_size)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Format output
output = format_output(
passwords=passwords,
output_format=args.format,
show_entropy=args.entropy,
entropy_value=entropy_value,
)
# Copy to clipboard if requested
if args.clipboard and passwords:
if copy_to_clipboard(passwords[0]):
print("✓ Copied to clipboard", file=sys.stderr)
else:
print(
"Warning: Clipboard functionality unavailable. Install 'pyperclip'.",
file=sys.stderr,
)
# Print output
print(output)
# Save config if requested
if args.save_config:
new_config = {
"length": args.length,
"count": args.count,
"lower": args.lower,
"upper": args.upper,
"digits": args.digits,
"symbols": args.symbols,
"symbol_set": args.symbol_set,
"no_ambiguous": args.no_ambiguous,
}
save_config(new_config)
print(f"✓ Configuration saved to {CONFIG_FILE}", file=sys.stderr)
if __name__ == "__main__":
main()