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
This commit is contained in:
512
genpass/cli.py
512
genpass/cli.py
@@ -1,47 +1,515 @@
|
||||
"""
|
||||
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
|
||||
|
||||
def generate_password(length, pools, ensure_each=True):
|
||||
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 = []
|
||||
|
||||
password: List[str] = []
|
||||
|
||||
# Ensure at least one character from each pool
|
||||
if ensure_each:
|
||||
if length < len(pools):
|
||||
raise ValueError("Password length too small")
|
||||
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 main():
|
||||
parser = argparse.ArgumentParser(prog="genpass", description="Secure password generator")
|
||||
parser.add_argument("-l", "--length", type=int, default=16)
|
||||
parser.add_argument("-n", "--count", type=int, default=1)
|
||||
parser.add_argument("--lower", action="store_true")
|
||||
parser.add_argument("--upper", action="store_true")
|
||||
parser.add_argument("--digits", action="store_true")
|
||||
parser.add_argument("--symbols", action="store_true")
|
||||
parser.add_argument("--symbol-set", default="!@#$%^&*()-_=+[]{};:,.<>?")
|
||||
parser.add_argument("--no-ensure", action="store_true")
|
||||
|
||||
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()
|
||||
|
||||
pools = []
|
||||
if args.lower: pools.append(string.ascii_lowercase)
|
||||
if args.upper: pools.append(string.ascii_uppercase)
|
||||
if args.digits: pools.append(string.digits)
|
||||
if args.symbols: pools.append(args.symbol_set)
|
||||
# Load config
|
||||
config = load_config()
|
||||
|
||||
if not pools:
|
||||
print("Select at least one character set", file=sys.stderr)
|
||||
# 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)
|
||||
|
||||
for _ in range(args.count):
|
||||
print(generate_password(args.length, pools, ensure_each=not args.no_ensure))
|
||||
# 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()
|
||||
|
||||
Reference in New Issue
Block a user