Source code for color_tools.cli

"""
Command-line interface for color_tools.

Provides main commands:
- color: Search and query CSS colors
- filament: Search and query 3D printing filaments
- convert: Convert between color spaces and check gamut
- name: Generate descriptive color names
- validate: Validate if hex codes match color names
- cvd: Color vision deficiency simulation/correction
- image: Image color analysis and manipulation

This is the "top" of the dependency tree - it imports from everywhere
but nothing imports from it (except __main__.py).
"""

from __future__ import annotations
import argparse
import sys
from pathlib import Path

from . import __version__
from .constants import ColorConstants
from .config import set_dual_color_mode
from .conversions import rgb_to_lab, lab_to_rgb, rgb_to_hsl, hsl_to_rgb, rgb_to_lch, lch_to_lab, lch_to_rgb
from .gamut import is_in_srgb_gamut, find_nearest_in_gamut
from .palette import Palette, load_colors, load_palette
from .filament_palette import FilamentPalette, load_filaments, load_maker_synonyms
from .color_deficiency import simulate_cvd, correct_cvd
from .validation import validate_color
from .export import export_filaments, export_colors, list_export_formats
from .cli_commands.handlers import (
    handle_name_command,
    handle_validate_command,
    handle_cvd_command,
    handle_color_command,
    handle_filament_command,
    handle_convert_command,
    handle_image_command,
)
from .cli_commands.utils import (
    validate_color_input_exclusivity,
    get_rgb_from_args,
    parse_hex_or_exit,
    is_valid_lab,
    is_valid_lch,
    get_program_name
)
from .cli_commands.reporting import handle_verification_flags


[docs] def build_parser() -> argparse.ArgumentParser: """ Build and return the argument parser for color-tools. Separated from main() so the wizard and tests can introspect available choices (--space, --metric, --from, --to, etc.) without running the CLI. """ # Determine the proper program name based on how we were invoked prog_name = get_program_name() parser = argparse.ArgumentParser( prog=prog_name, description="Color search and conversion tools", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=f""" Examples: # Find nearest CSS color to an RGB value {prog_name} color --nearest --value 128 64 200 --space rgb {prog_name} color --nearest --hex "#8040C8" # Find color by name {prog_name} color --name "coral" # Generate descriptive name for an RGB color {prog_name} name --value 255 128 64 {prog_name} name --hex "#FF8040" # Simulate color blindness {prog_name} cvd --value 255 0 0 --type protanopia --mode simulate {prog_name} cvd --hex "#FF0000" --type deutan --mode correct # Extract and redistribute luminance from image {prog_name} image --file photo.jpg --redistribute-luminance --colors 8 # Convert image formats (WebP, PNG, JPEG, HEIC, AVIF, etc.) {prog_name} image --file photo.webp --convert png {prog_name} image --file photo.jpg --convert webp --quality 80 --lossy # Add watermarks to images {prog_name} image --file photo.jpg --watermark --watermark-text "© 2025 MyBrand" {prog_name} image --file photo.jpg --watermark --watermark-image logo.png --watermark-position top-right # Simulate colorblindness and convert to retro palettes {prog_name} image --file chart.png --cvd-simulate deuteranopia --output colorblind_view.png {prog_name} image --file photo.jpg --quantize-palette cga4 --dither --output retro.png # Find nearest filament to an RGB color {prog_name} filament --nearest --value 255 0 0 {prog_name} filament --nearest --hex "#FF0000" # Find all PLA filaments from two different makers {prog_name} filament --type PLA --maker "Bambu Lab" "Sunlu" # List all filament makers {prog_name} filament --list-makers # Convert between color spaces {prog_name} convert --from rgb --to lab --value 255 128 0 {prog_name} convert --from rgb --to cmyk --value 255 128 0 {prog_name} convert --from cmyk --to rgb --value 0 50 100 0 {prog_name} convert --from rgb --to cmy --value 255 128 0 # Check if LAB color is in sRGB gamut {prog_name} convert --check-gamut --value 50 100 50 # Show user file overrides {prog_name} --check-overrides """ ) # Global arguments (apply to all subcommands) parser.add_argument( "--version", action="version", version=f"%(prog)s {__version__}", help="Show version number and exit" ) parser.add_argument( "--json", type=str, metavar="DIR", default=None, # Will use default package data if None help="Path to directory containing JSON data files (colors.json, filaments.json, maker_synonyms.json). Default: uses package data directory" ) parser.add_argument( "--verify-constants", action="store_true", help="Verify integrity of color science constants before proceeding" ) parser.add_argument( "--verify-data", action="store_true", help="Verify integrity of core data files (colors.json, filaments.json, maker_synonyms.json) before proceeding" ) parser.add_argument( "--verify-matrices", action="store_true", help="Verify integrity of transformation matrices before proceeding" ) parser.add_argument( "--verify-all", action="store_true", help="Verify integrity of constants, data files, matrices, and user data before proceeding" ) parser.add_argument( "--verify-user-data", action="store_true", help="Verify integrity of user data files (user/user-colors.json, user/user-filaments.json) against .sha256 files" ) parser.add_argument( "--generate-user-hashes", action="store_true", help="Generate .sha256 files for all user data files and exit" ) parser.add_argument( "--check-overrides", action="store_true", help="Show report of user overrides (user/user-colors.json, user/user-filaments.json) and exit" ) parser.add_argument( "--interactive", "-i", action="store_true", help="Launch the interactive wizard (guided prompts for color, filament, and convert commands)" ) # Create subparsers for the three main commands subparsers = parser.add_subparsers(dest="command", help="Command to execute") # ==================== COLOR SUBCOMMAND ==================== color_parser = subparsers.add_parser( "color", help="Work with CSS colors", description="Search and query CSS color database" ) color_parser.add_argument( "--nearest", action="store_true", help="Find nearest color to the given value" ) color_parser.add_argument( "--name", type=str, help="Find an exact color by name" ) color_parser.add_argument( "--value", nargs=3, type=float, metavar=("V1", "V2", "V3"), help="Color value tuple (RGB: r g b | HSL: h s l | LAB: L a b | LCH: L C h)" ) color_parser.add_argument( "--hex", type=str, metavar="COLOR", help="Hex color value (e.g., '#FF8040' or 'FF8040') - shortcut for RGB input" ) color_parser.add_argument( "--space", choices=["rgb", "hsl", "lab", "lch"], default="lab", help="Color space of the input value (default: lab)" ) color_parser.add_argument( "--metric", choices=["euclidean", "de76", "de94", "de2000", "cmc", "cmc21", "cmc11", "hyab"], default="de2000", help="Distance metric for LAB space (default: de2000). 'cmc21'=CMC(2:1), 'cmc11'=CMC(1:1), 'hyab'=best for large differences" ) color_parser.add_argument( "--cmc-l", type=float, default=ColorConstants.CMC_L_DEFAULT, help="CMC lightness parameter (default: 2.0)" ) color_parser.add_argument( "--cmc-c", type=float, default=ColorConstants.CMC_C_DEFAULT, help="CMC chroma parameter (default: 1.0)" ) color_parser.add_argument( "--palette", type=str, help="Use a retro palette instead of CSS colors. Common palettes include: cga4, cga16, ega16, ega64, vga, web, gameboy, pico8. Use '--palette list' to see all available palettes including user-created ones." ) color_parser.add_argument( "--count", type=int, default=1, metavar="N", help="Number of nearest colors to return (default: 1, max: 50)" ) # Export operations color_parser.add_argument( "--export", type=str, metavar="FORMAT", help="Export colors to file (formats: csv, json)" ) color_parser.add_argument( "--output", type=str, metavar="FILE", help="Output filename (auto-generated with timestamp if not specified)" ) color_parser.add_argument( "--list-export-formats", action="store_true", help="List available export formats and exit" ) # ==================== FILAMENT SUBCOMMAND ==================== filament_parser = subparsers.add_parser( "filament", help="Work with 3D printing filaments", description="Search and query 3D printing filament database" ) filament_parser.add_argument( "--nearest", action="store_true", help="Find nearest filament to the given RGB color" ) filament_parser.add_argument( "--value", nargs=3, type=int, metavar=("R", "G", "B"), help="RGB color value (0-255 for each component)" ) filament_parser.add_argument( "--hex", type=str, metavar="COLOR", help="Hex color value (e.g., '#FF8040' or 'FF8040') - shortcut for RGB input" ) filament_parser.add_argument( "--metric", choices=["euclidean", "de76", "de94", "de2000", "cmc", "hyab"], default="de2000", help="Distance metric (default: de2000). 'hyab'=best for large/dissimilar color differences" ) filament_parser.add_argument( "--cmc-l", type=float, default=ColorConstants.CMC_L_DEFAULT, help="CMC lightness parameter (default: 2.0)" ) filament_parser.add_argument( "--cmc-c", type=float, default=ColorConstants.CMC_C_DEFAULT, help="CMC chroma parameter (default: 1.0)" ) filament_parser.add_argument( "--count", type=int, default=1, metavar="N", help="Number of nearest filaments to return (default: 1, max: 50)" ) # List operations filament_parser.add_argument( "--list-makers", action="store_true", help="List all filament makers" ) filament_parser.add_argument( "--list-types", action="store_true", help="List all filament types" ) filament_parser.add_argument( "--list-finishes", action="store_true", help="List all filament finishes" ) filament_parser.add_argument( "--list-owned", action="store_true", help="List all owned filaments from owned-filaments.json" ) # Owned filaments management filament_parser.add_argument( "--add-owned", type=str, metavar="ID", help="Add filament ID to owned list (saves to owned-filaments.json)" ) filament_parser.add_argument( "--remove-owned", type=str, metavar="ID", help="Remove filament ID from owned list (saves to owned-filaments.json)" ) filament_parser.add_argument( "--manage", action="store_true", help="Launch interactive filament library manager (requires [interactive] extra)" ) filament_parser.add_argument( "--all-filaments", action="store_true", help="Search all filaments (override owned filtering even if owned-filaments.json exists)" ) # Filter operations filament_parser.add_argument( "--maker", nargs='+', type=str, help="Filter by one or more makers (e.g., --maker \"Bambu Lab\" \"Polymaker\")" ) filament_parser.add_argument( "--type", nargs='+', type=str, help="Filter by one or more types (e.g., --type PLA \"PLA+\")" ) filament_parser.add_argument( "--finish", nargs='+', type=str, help="Filter by one or more finishes (e.g., --finish Matte \"Silk+\")" ) filament_parser.add_argument( "--color", type=str, help="Filter by color name" ) filament_parser.add_argument( "--dual-color-mode", choices=["first", "last", "mix"], default="first", help="How to handle dual-color filaments: 'first' (default), 'last', or 'mix' (perceptual blend)" ) # Export operations filament_parser.add_argument( "--export", type=str, metavar="FORMAT", help="Export filtered filaments to file (formats: autoforge, csv, json)" ) filament_parser.add_argument( "--output", type=str, metavar="FILE", help="Output filename (auto-generated with timestamp if not specified)" ) filament_parser.add_argument( "--list-export-formats", action="store_true", help="List available export formats and exit" ) # ==================== CONVERT SUBCOMMAND ==================== convert_parser = subparsers.add_parser( "convert", help="Convert between color spaces", description="Convert colors between RGB, HSL, LAB, LCH, CMY, and CMYK spaces" ) convert_parser.add_argument( "--from", dest="from_space", choices=["rgb", "hsl", "lab", "lch", "cmy", "cmyk"], help="Source color space" ) convert_parser.add_argument( "--to", dest="to_space", choices=["rgb", "hsl", "lab", "lch", "cmy", "cmyk"], help="Target color space" ) convert_parser.add_argument( "--value", nargs="+", type=float, metavar="V", help="Color value (3 components for RGB/HSL/LAB/LCH/CMY, 4 for CMYK; mutually exclusive with --hex)" ) convert_parser.add_argument( "--hex", type=str, metavar="COLOR", help="Hex color code (e.g., FF5733 or #FF5733) - automatically uses RGB space (mutually exclusive with --value)" ) convert_parser.add_argument( "--check-gamut", action="store_true", help="Check if LAB/LCH color is in sRGB gamut (requires --value or --hex)" ) # ==================== NAME SUBCOMMAND ==================== name_parser = subparsers.add_parser( "name", help="Generate descriptive color names from RGB values", description="Generate intelligent, descriptive names for colors using perceptual analysis" ) name_parser.add_argument( "--value", nargs=3, type=int, metavar=("R", "G", "B"), help="RGB color value (0-255 for each component)" ) name_parser.add_argument( "--hex", type=str, metavar="COLOR", help="Hex color value (e.g., '#FF8040' or 'FF8040') - shortcut for RGB input" ) name_parser.add_argument( "--threshold", type=float, default=5.0, metavar="DELTA_E", help="Delta E threshold for 'near' CSS color matches (default: 5.0)" ) name_parser.add_argument( "--show-type", action="store_true", help="Show match type (exact/near/generated) in output" ) # ==================== VALIDATE SUBCOMMAND ==================== validate_parser = subparsers.add_parser( "validate", help="Validate if a hex code matches a color name", description="""Validate color name/hex pairings using fuzzy matching and perceptual color distance (Delta E 2000). Note: For best fuzzy matching results, install the optional [fuzzy] extra: pip install color-match-tools[fuzzy] Without [fuzzy], uses a hybrid matcher (exact/substring/Levenshtein).""" ) validate_parser.add_argument( "--name", type=str, required=True, help="Color name to validate (e.g., 'light blue', 'red', 'dark slate gray')" ) validate_parser.add_argument( "--hex", type=str, required=True, help="Hex color code to validate (e.g., '#ADD8E6', 'ADD8E6')" ) validate_parser.add_argument( "--threshold", type=float, default=20.0, help="Delta E threshold for color match (default: 20.0, lower = stricter)" ) validate_parser.add_argument( "--json-output", action="store_true", help="Output results in JSON format" ) # ==================== CVD SUBCOMMAND ==================== cvd_parser = subparsers.add_parser( "cvd", help="Color vision deficiency simulation and correction", description="Simulate how colors appear with color blindness or apply corrections" ) cvd_parser.add_argument( "--value", nargs=3, type=int, metavar=("R", "G", "B"), help="RGB color value (0-255 for each component)" ) cvd_parser.add_argument( "--hex", type=str, metavar="COLOR", help="Hex color value (e.g., '#FF8040' or 'FF8040') - shortcut for RGB input" ) cvd_parser.add_argument( "--type", choices=["protanopia", "protan", "deuteranopia", "deutan", "tritanopia", "tritan"], required=True, help="Type of color vision deficiency (protanopia=red-blind, deuteranopia=green-blind, tritanopia=blue-blind)" ) cvd_parser.add_argument( "--mode", choices=["simulate", "correct"], default="simulate", help="Mode: 'simulate' shows how colors appear to CVD individuals, 'correct' applies daltonization (default: simulate)" ) # ==================== IMAGE SUBCOMMAND ==================== image_parser = subparsers.add_parser( "image", help="Image color analysis and manipulation", description="""Image processing operations: - Format Conversion: Convert between PNG, JPEG, WebP, HEIC, AVIF, etc. - Watermarking: Add text, image, or SVG watermarks with customizable positioning - Color Analysis: Extract dominant colors with K-means clustering - Luminance Redistribution: Redistribute luminance values for HueForge 3D printing - CVD Simulation/Correction: Simulate or correct for color vision deficiencies - Palette Quantization: Convert to retro palettes (CGA, EGA, VGA, etc.) with dithering """ ) image_parser.add_argument( "--file", type=str, required=False, help="Path to input image file" ) image_parser.add_argument( "--output", type=str, help="Path to save output image (optional)" ) # HueForge operations image_parser.add_argument( "--redistribute-luminance", action="store_true", help="Extract colors and redistribute their luminance values evenly for HueForge" ) image_parser.add_argument( "--colors", type=int, default=10, help="Number of unique colors to extract (default: 10)" ) # CVD operations image_parser.add_argument( "--cvd-simulate", type=str, choices=["protanopia", "protan", "deuteranopia", "deutan", "tritanopia", "tritan"], help="Simulate color vision deficiency (protanopia, deuteranopia, or tritanopia)" ) image_parser.add_argument( "--cvd-correct", type=str, choices=["protanopia", "protan", "deuteranopia", "deutan", "tritanopia", "tritan"], help="Apply CVD correction to improve discriminability for specified deficiency" ) # Palette quantization image_parser.add_argument( "--quantize-palette", type=str, help="Convert image to specified retro palette. Built-in: cga4, ega16, vga, gameboy, commodore64. Custom user palettes must use 'user-' prefix (e.g., user-mycustom)." ) image_parser.add_argument( "--metric", type=str, choices=["de2000", "de94", "de76", "cmc", "euclidean", "hsl_euclidean", "hyab"], default="de2000", help="Color distance metric for palette quantization (default: de2000). 'hyab'=best for large differences" ) image_parser.add_argument( "--dither", action="store_true", help="Apply Floyd-Steinberg dithering for palette quantization (reduces banding)" ) # HyAB k-means quantization image_parser.add_argument( "--quantize-hyab", action="store_true", help="Quantize image using HyAB k-means clustering (use --colors for palette size, default: 16)" ) image_parser.add_argument( "--l-weight", type=float, default=2.0, help="Lightness weight for HyAB distance (default: 2.0). Higher values emphasise lightness separation." ) image_parser.add_argument( "--use-l-median", action="store_true", default=True, help="Use median (not mean) of L channel when updating HyAB centroids (default: True)" ) # Watermarking operations image_parser.add_argument( "--watermark", action="store_true", help="Add a watermark to the image (use with --watermark-text, --watermark-image, or --watermark-svg)" ) image_parser.add_argument( "--watermark-text", type=str, help="Text to use for watermark (e.g., '© 2025 My Brand')" ) image_parser.add_argument( "--watermark-image", type=str, help="Path to image file to use as watermark (PNG recommended)" ) image_parser.add_argument( "--watermark-svg", type=str, help="Path to SVG file to use as watermark (requires cairosvg)" ) image_parser.add_argument( "--watermark-position", type=str, choices=["top-left", "top-center", "top-right", "center-left", "center", "center-right", "bottom-left", "bottom-center", "bottom-right"], default="bottom-right", help="Position for watermark (default: bottom-right)" ) image_parser.add_argument( "--watermark-font-name", type=str, help="System font name for text watermark (e.g., 'Arial', 'Times New Roman')" ) image_parser.add_argument( "--watermark-font-file", type=str, help="Custom font file for text watermark (path or filename in fonts/ directory)" ) image_parser.add_argument( "--watermark-font-size", type=int, default=24, help="Font size for text watermark in points (default: 24)" ) image_parser.add_argument( "--watermark-color", type=str, default="255,255,255", help="Text color as R,G,B (default: 255,255,255 white)" ) image_parser.add_argument( "--watermark-stroke-color", type=str, help="Text outline color as R,G,B (e.g., 0,0,0 for black outline)" ) image_parser.add_argument( "--watermark-stroke-width", type=int, default=0, help="Text outline width in pixels (default: 0, no outline)" ) image_parser.add_argument( "--watermark-opacity", type=float, default=0.8, help="Watermark opacity from 0.0 (transparent) to 1.0 (opaque) (default: 0.8)" ) image_parser.add_argument( "--watermark-scale", type=float, default=1.0, help="Scale factor for image/SVG watermark (default: 1.0)" ) image_parser.add_argument( "--watermark-margin", type=int, default=10, help="Margin from edges in pixels for preset positions (default: 10)" ) # List available palettes image_parser.add_argument( "--list-palettes", action="store_true", help="List all available retro palettes" ) # Image format conversion image_parser.add_argument( "--convert", type=str, metavar="FORMAT", help="Convert image to specified format (png, jpg, webp, heic, avif, etc.). Auto-detects input format from file extension. Defaults to PNG if not specified." ) image_parser.add_argument( "--quality", type=int, metavar="1-100", help="Output quality for lossy formats (1-100). Defaults: JPEG=67, WebP=lossless, AVIF=80" ) image_parser.add_argument( "--lossy", action="store_true", help="Use lossy compression for WebP/AVIF instead of lossless (only with --convert)" ) return parser
[docs] def main(): """ Main entry point for the CLI. Note: No `if __name__ == "__main__":` here! That's __main__.py's job. This function is just the CLI logic - pure and testable. """ # Configure stdout/stderr for UTF-8 on Windows (for Unicode checkmarks, etc.) if sys.platform == 'win32': import io sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') parser = build_parser() # Parse arguments args = parser.parse_args() # Handle all verification flags (may exit early) if handle_verification_flags(args): sys.exit(0) # Handle --interactive flag or no subcommand → launch wizard if getattr(args, 'interactive', False) or not args.command: # Only launch the wizard when stdin is a real interactive terminal. # In pipes, subprocesses, or CI environments fall back to --help. is_interactive_tty = sys.stdin.isatty() and sys.stdout.isatty() if not is_interactive_tty and not args.command: parser.print_help() sys.exit(0) from .interactive_wizard import run_interactive_wizard, check_prompt_toolkit if not check_prompt_toolkit(): if not args.command: parser.print_help() sys.exit(0) else: from .interactive_wizard import _show_install_message _show_install_message() sys.exit(1) _wizard_json = Path(args.json) if args.json else None run_interactive_wizard(_wizard_json) sys.exit(0) # wizard handles its own exit; this is a safety net # Validate and convert json_path to Path if provided json_path = None if args.json: json_path = Path(args.json) if not json_path.exists(): print(f"Error: JSON directory does not exist: {json_path}") sys.exit(1) if not json_path.is_dir(): print(f"Error: --json must be a directory containing colors.json, filaments.json, and maker_synonyms.json") print(f"Provided path is not a directory: {json_path}") sys.exit(1) # ==================== COLOR COMMAND HANDLER ==================== if args.command == "color": handle_color_command(args, json_path) # ==================== FILAMENT COMMAND HANDLER ==================== elif args.command == "filament": handle_filament_command(args, json_path) # ==================== CONVERT COMMAND HANDLER ==================== elif args.command == "convert": handle_convert_command(args) # ==================== NAME COMMAND HANDLER ==================== elif args.command == "name": handle_name_command(args) # ==================== VALIDATE COMMAND HANDLER ==================== elif args.command == "validate": handle_validate_command(args) # ==================== CVD COMMAND HANDLER ==================== elif args.command == "cvd": handle_cvd_command(args) # ==================== IMAGE COMMAND HANDLER ==================== elif args.command == "image": handle_image_command(args) sys.exit(0)