diff --git a/pdd/code_generator_main.py b/pdd/code_generator_main.py index ca73a9884..d786205f9 100644 --- a/pdd/code_generator_main.py +++ b/pdd/code_generator_main.py @@ -733,13 +733,13 @@ def _match_one(patterns_list: List[str]) -> List[str]: git_add_files(files_to_stage_for_rollback, verbose=verbose) # Preprocess both prompts: expand includes, substitute vars, then double - orig_proc = pdd_preprocess(original_prompt_content_for_incremental, recursive=True, double_curly_brackets=False) + orig_proc = pdd_preprocess(original_prompt_content_for_incremental, recursive=True, double_curly_brackets=False, quiet=quiet) orig_proc = _expand_vars(orig_proc, env_vars) - orig_proc = pdd_preprocess(orig_proc, recursive=False, double_curly_brackets=True) + orig_proc = pdd_preprocess(orig_proc, recursive=False, double_curly_brackets=True, quiet=quiet) - new_proc = pdd_preprocess(prompt_content, recursive=True, double_curly_brackets=False) + new_proc = pdd_preprocess(prompt_content, recursive=True, double_curly_brackets=False, quiet=quiet) new_proc = _expand_vars(new_proc, env_vars) - new_proc = pdd_preprocess(new_proc, recursive=False, double_curly_brackets=True) + new_proc = pdd_preprocess(new_proc, recursive=False, double_curly_brackets=True, quiet=quiet) generated_code_content, was_incremental_operation, total_cost, model_name = incremental_code_generator_func( original_prompt=orig_proc, @@ -770,9 +770,9 @@ def _match_one(patterns_list: List[str]) -> List[str]: if not current_execution_is_local: if verbose: console.print("Attempting cloud code generation...") # Expand includes, substitute vars, then double - processed_prompt_for_cloud = pdd_preprocess(prompt_content, recursive=True, double_curly_brackets=False, exclude_keys=[]) + processed_prompt_for_cloud = pdd_preprocess(prompt_content, recursive=True, double_curly_brackets=False, exclude_keys=[], quiet=quiet) processed_prompt_for_cloud = _expand_vars(processed_prompt_for_cloud, env_vars) - processed_prompt_for_cloud = pdd_preprocess(processed_prompt_for_cloud, recursive=False, double_curly_brackets=True, exclude_keys=[]) + processed_prompt_for_cloud = pdd_preprocess(processed_prompt_for_cloud, recursive=False, double_curly_brackets=True, exclude_keys=[], quiet=quiet) if verbose: console.print(Panel(Text(processed_prompt_for_cloud, overflow="fold"), title="[cyan]Preprocessed Prompt for Cloud[/cyan]", expand=False)) # Extract and display pinned example ID if present in prompt @@ -788,7 +788,8 @@ def _match_one(patterns_list: List[str]) -> List[str]: if cloud_only: console.print("[red]Cloud authentication failed.[/red]") raise click.UsageError("Cloud authentication failed") - console.print("[yellow]Cloud authentication failed. Falling back to local execution.[/yellow]") + if not quiet: + console.print("[yellow]Cloud authentication failed. Falling back to local execution.[/yellow]") current_execution_is_local = True if jwt_token and not current_execution_is_local: @@ -828,7 +829,8 @@ def _match_one(patterns_list: List[str]) -> List[str]: if cloud_only: console.print("[red]Cloud execution returned no code.[/red]") raise click.UsageError("Cloud execution returned no code") - console.print("[yellow]Cloud execution returned no code. Falling back to local.[/yellow]") + if not quiet: + console.print("[yellow]Cloud execution returned no code. Falling back to local.[/yellow]") current_execution_is_local = True elif verbose: # Display example info if available @@ -843,7 +845,8 @@ def _match_one(patterns_list: List[str]) -> List[str]: if cloud_only: console.print(f"[red]Cloud execution timed out ({get_cloud_timeout()}s).[/red]") raise click.UsageError("Cloud execution timed out") - console.print(f"[yellow]Cloud execution timed out ({get_cloud_timeout()}s). Falling back to local.[/yellow]") + if not quiet: + console.print(f"[yellow]Cloud execution timed out ({get_cloud_timeout()}s). Falling back to local.[/yellow]") current_execution_is_local = True except requests.exceptions.HTTPError as e: status_code = e.response.status_code if e.response else 0 @@ -873,27 +876,30 @@ def _match_one(patterns_list: List[str]) -> List[str]: if cloud_only: console.print(f"[red]Cloud HTTP error ({status_code}): {err_content}[/red]") raise click.UsageError(f"Cloud HTTP error ({status_code}): {err_content}") - console.print(f"[yellow]Cloud HTTP error ({status_code}): {err_content}. Falling back to local.[/yellow]") + if not quiet: + console.print(f"[yellow]Cloud HTTP error ({status_code}): {err_content}. Falling back to local.[/yellow]") current_execution_is_local = True except requests.exceptions.RequestException as e: if cloud_only: console.print(f"[red]Cloud network error: {e}[/red]") raise click.UsageError(f"Cloud network error: {e}") - console.print(f"[yellow]Cloud network error: {e}. Falling back to local.[/yellow]") + if not quiet: + console.print(f"[yellow]Cloud network error: {e}. Falling back to local.[/yellow]") current_execution_is_local = True except json.JSONDecodeError: if cloud_only: console.print("[red]Cloud returned invalid JSON.[/red]") raise click.UsageError("Cloud returned invalid JSON") - console.print("[yellow]Cloud returned invalid JSON. Falling back to local.[/yellow]") + if not quiet: + console.print("[yellow]Cloud returned invalid JSON. Falling back to local.[/yellow]") current_execution_is_local = True if current_execution_is_local: if verbose: console.print("Executing code generator locally...") # Expand includes, substitute vars, then double; pass to local generator with preprocess_prompt=False - local_prompt = pdd_preprocess(prompt_content, recursive=True, double_curly_brackets=False, exclude_keys=[]) + local_prompt = pdd_preprocess(prompt_content, recursive=True, double_curly_brackets=False, exclude_keys=[], quiet=quiet) local_prompt = _expand_vars(local_prompt, env_vars) - local_prompt = pdd_preprocess(local_prompt, recursive=False, double_curly_brackets=True, exclude_keys=[]) + local_prompt = pdd_preprocess(local_prompt, recursive=False, double_curly_brackets=True, exclude_keys=[], quiet=quiet) # Language already resolved (front matter overrides detection if present) gen_language = language @@ -1131,7 +1137,7 @@ def _subst_arg(arg: str) -> str: p_output.write_text(final_content, encoding="utf-8") if verbose or not quiet: - console.print(f"Generated code saved to: [green]{p_output.resolve()}[/green]") + click.echo(f"Generated code saved to: {p_output.resolve()}") # Safety net: ensure architecture HTML is generated post-write if applicable try: # Prefer resolved script if available; else default for architecture outputs diff --git a/pdd/core/cli.py b/pdd/core/cli.py index f47171623..3027e70fb 100644 --- a/pdd/core/cli.py +++ b/pdd/core/cli.py @@ -359,6 +359,12 @@ def cli( # Suppress verbose if quiet is enabled if quiet: ctx.obj["verbose"] = False + os.environ["PDD_QUIET"] = "1" + from ..llm_invoke import set_quiet_mode + set_quiet_mode() + # Monkey-patch Rich/Click output to suppress non-error output globally + from ..quiet import enable_quiet_mode + enable_quiet_mode() # Warn users who have not completed interactive setup unless they are running it now if _should_show_onboarding_reminder(ctx): diff --git a/pdd/core/errors.py b/pdd/core/errors.py index e7db29e4f..1cf3ffc8c 100644 --- a/pdd/core/errors.py +++ b/pdd/core/errors.py @@ -46,22 +46,20 @@ def handle_error(exception: Exception, command_name: str, quiet: bool): } ) - if not quiet: - console.print(f"[error]Error during '{command_name}' command:[/error]", style="error") - if isinstance(exception, FileNotFoundError): - console.print(f" [error]File not found:[/error] {exception}", style="error") - elif isinstance(exception, (ValueError, IOError)): - console.print(f" [error]Input/Output Error:[/error] {exception}", style="error") - elif isinstance(exception, click.UsageError): # Handle Click usage errors explicitly if needed - console.print(f" [error]Usage Error:[/error] {exception}", style="error") - # click.UsageError should typically exit with 2, so we re-raise it - raise exception - elif isinstance(exception, MarkupError): - console.print(" [error]Markup Error:[/error] Invalid Rich markup encountered.", style="error") - # Print the error message safely escaped - console.print(escape(str(exception))) - else: - console.print(f" [error]An unexpected error occurred:[/error] {exception}", style="error") + # Errors are always shown, even in quiet mode + console.print(f"[error]Error during '{command_name}' command:[/error]", style="error") + if isinstance(exception, FileNotFoundError): + console.print(f" [error]File not found:[/error] {exception}", style="error") + elif isinstance(exception, (ValueError, IOError)): + console.print(f" [error]Input/Output Error:[/error] {exception}", style="error") + elif isinstance(exception, click.UsageError): + console.print(f" [error]Usage Error:[/error] {exception}", style="error") + raise exception + elif isinstance(exception, MarkupError): + console.print(" [error]Markup Error:[/error] Invalid Rich markup encountered.", style="error") + console.print(escape(str(exception))) + else: + console.print(f" [error]An unexpected error occurred:[/error] {exception}", style="error") strict_exit = os.environ.get("PDD_STRICT_EXIT", "").strip().lower() in {"1", "true", "yes", "on"} if strict_exit: raise SystemExit(1) diff --git a/pdd/llm_invoke.py b/pdd/llm_invoke.py index 24c6ba90d..2647a5b22 100644 --- a/pdd/llm_invoke.py +++ b/pdd/llm_invoke.py @@ -56,6 +56,29 @@ if not litellm_logger.handlers: litellm_logger.addHandler(console_handler) +def set_quiet_mode(): + """Suppress all log output, LiteLLM verbose output, and warnings in quiet mode.""" + # Set all loggers to CRITICAL to suppress everything below + logger.setLevel(logging.CRITICAL) + litellm_logger.setLevel(logging.CRITICAL) + # Also raise handler levels so propagation doesn't leak through + for handler in logger.handlers: + handler.setLevel(logging.CRITICAL) + for handler in litellm_logger.handlers: + handler.setLevel(logging.CRITICAL) + # Suppress LiteLLM's own print-based verbose output + litellm.set_verbose = False + # Suppress the root logger and all its handlers + root = logging.getLogger() + root.setLevel(logging.CRITICAL) + for handler in root.handlers: + handler.setLevel(logging.CRITICAL) + # Disable the lastResort handler that prints WARNING: lines + logging.lastResort = None + # Suppress Python warnings (Pydantic serialization warnings, etc.) + import warnings + warnings.filterwarnings("ignore") + # Function to set up file logging if needed def setup_file_logging(log_file_path=None): """Configure rotating file handler for logging""" @@ -398,7 +421,7 @@ def _llm_invoke_cloud( try: result = _validate_with_pydantic(result, output_pydantic) except (ValidationError, ValueError) as e: - logger.warning(f"Cloud response validation failed: {e}") + logger.debug(f"Cloud response validation failed: {e}") # Return raw result if validation fails pass @@ -606,19 +629,19 @@ def _is_env_path_package_dir(env_path: Path) -> bool: # Selection order if user_model_csv_path.is_file(): LLM_MODEL_CSV_PATH = user_model_csv_path - logger.info(f"Using user-specific LLM model CSV: {LLM_MODEL_CSV_PATH}") + logger.debug(f"Using user-specific LLM model CSV: {LLM_MODEL_CSV_PATH}") elif PROJECT_ROOT_FROM_ENV and project_csv_from_env.is_file(): # Honor an explicitly-set PDD_PATH pointing to a real project directory LLM_MODEL_CSV_PATH = project_csv_from_env - logger.info(f"Using project-specific LLM model CSV (from PDD_PATH): {LLM_MODEL_CSV_PATH}") + logger.debug(f"Using project-specific LLM model CSV (from PDD_PATH): {LLM_MODEL_CSV_PATH}") elif project_csv_from_cwd.is_file(): # Otherwise, prefer the project relative to the current working directory LLM_MODEL_CSV_PATH = project_csv_from_cwd - logger.info(f"Using project-specific LLM model CSV (from CWD): {LLM_MODEL_CSV_PATH}") + logger.debug(f"Using project-specific LLM model CSV (from CWD): {LLM_MODEL_CSV_PATH}") else: # Neither exists, we'll use a marker path that _load_model_data will handle LLM_MODEL_CSV_PATH = None - logger.info("No local LLM model CSV found, will use package default") + logger.debug("No local LLM model CSV found, will use package default") # --------------------------------- # Load environment variables from .env file @@ -669,7 +692,7 @@ def _is_env_path_package_dir(env_path: Path) -> bool: s3_endpoint_url=GCS_ENDPOINT_URL, ) litellm.cache = configured_cache - logger.info(f"LiteLLM cache configured for GCS bucket (S3 compatible): {GCS_BUCKET_NAME}") + logger.debug(f"LiteLLM cache configured for GCS bucket (S3 compatible): {GCS_BUCKET_NAME}") cache_configured = True except Exception as e: @@ -695,7 +718,7 @@ def _is_env_path_package_dir(env_path: Path) -> bool: # Check if caching is disabled via environment variable if os.getenv("LITELLM_CACHE_DISABLE") == "1": - logger.info("LiteLLM caching disabled via LITELLM_CACHE_DISABLE=1") + logger.debug("LiteLLM caching disabled via LITELLM_CACHE_DISABLE=1") litellm.cache = None cache_configured = True @@ -705,7 +728,7 @@ def _is_env_path_package_dir(env_path: Path) -> bool: sqlite_cache_path = PROJECT_ROOT / "litellm_cache.sqlite" configured_cache = Cache(type="disk", disk_cache_dir=str(sqlite_cache_path)) litellm.cache = configured_cache - logger.info(f"LiteLLM disk cache configured at {sqlite_cache_path}") + logger.debug(f"LiteLLM disk cache configured at {sqlite_cache_path}") cache_configured = True except Exception as e2: warnings.warn(f"Failed to configure LiteLLM disk cache: {e2}. Caching is disabled.") @@ -1746,16 +1769,20 @@ def llm_invoke( ) except CloudFallbackError as e: # Notify user and fall back to local execution - console.print(f"[yellow]Cloud execution failed ({e}), falling back to local execution...[/yellow]") - logger.warning(f"Cloud fallback: {e}") + _quiet = os.environ.get("PDD_QUIET", "") == "1" + if not _quiet: + console.print(f"[yellow]Cloud execution failed ({e}), falling back to local execution...[/yellow]") + logger.debug(f"Cloud fallback: {e}") # Continue to local execution below except InsufficientCreditsError: # Re-raise credit errors - user needs to know raise except CloudInvocationError as e: # Non-recoverable cloud error - notify and fall back - console.print(f"[yellow]Cloud error ({e}), falling back to local execution...[/yellow]") - logger.warning(f"Cloud invocation error: {e}") + _quiet = os.environ.get("PDD_QUIET", "") == "1" + if not _quiet: + console.print(f"[yellow]Cloud error ({e}), falling back to local execution...[/yellow]") + logger.debug(f"Cloud invocation error: {e}") # Continue to local execution below # --- 1. Load Environment & Validate Inputs --- diff --git a/pdd/load_prompt_template.py b/pdd/load_prompt_template.py index ef4d91fb5..ed0c95e18 100644 --- a/pdd/load_prompt_template.py +++ b/pdd/load_prompt_template.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from typing import Optional from rich import print @@ -7,7 +8,11 @@ def print_formatted(message: str) -> None: """Print message with raw formatting tags for testing compatibility.""" print(message) -def load_prompt_template(prompt_name: str) -> Optional[str]: +def _is_quiet(quiet: bool) -> bool: + """Check if quiet mode is active via parameter or environment.""" + return quiet or os.environ.get("PDD_QUIET", "") == "1" + +def load_prompt_template(prompt_name: str, quiet: bool = False) -> Optional[str]: """ Load a prompt template from a file. @@ -39,15 +44,17 @@ def load_prompt_template(prompt_name: str) -> Optional[str]: prompt_candidates.append(root / 'pdd' / 'prompts' / f"{prompt_name}.prompt") tried = "\n".join(str(c) for c in prompt_candidates) - print_formatted( - f"[red]Prompt file not found in any candidate locations for '{prompt_name}'. Tried:\n{tried}[/red]" - ) + if not _is_quiet(quiet): + print_formatted( + f"[red]Prompt file not found in any candidate locations for '{prompt_name}'. Tried:\n{tried}[/red]" + ) return None try: with open(prompt_path, 'r', encoding='utf-8') as file: prompt_template = file.read() - print_formatted(f"[green]Successfully loaded prompt: {prompt_name}[/green]") + if not _is_quiet(quiet): + print_formatted(f"[green]Successfully loaded prompt: {prompt_name}[/green]") return prompt_template except IOError as e: diff --git a/pdd/preprocess.py b/pdd/preprocess.py index 6762219a0..3817e0b08 100644 --- a/pdd/preprocess.py +++ b/pdd/preprocess.py @@ -109,7 +109,11 @@ def _scan_risky_placeholders(text: str) -> Tuple[List[Tuple[int, str]], List[Tup pass return single_brace, template_brace -def preprocess(prompt: str, recursive: bool = False, double_curly_brackets: bool = True, exclude_keys: Optional[List[str]] = None) -> str: +def _is_quiet(quiet: bool) -> bool: + """Check if quiet mode is active via parameter or environment.""" + return quiet or os.environ.get("PDD_QUIET", "") == "1" + +def preprocess(prompt: str, recursive: bool = False, double_curly_brackets: bool = True, exclude_keys: Optional[List[str]] = None, quiet: bool = False) -> str: try: if not prompt: console.print("[bold red]Error:[/bold red] Empty prompt provided") @@ -117,13 +121,14 @@ def preprocess(prompt: str, recursive: bool = False, double_curly_brackets: bool _DEBUG_EVENTS.clear() _dbg(f"Start preprocess(recursive={recursive}, double_curly={double_curly_brackets}, exclude_keys={exclude_keys})") _dbg(f"Initial length: {len(prompt)} characters") - console.print(Panel("Starting prompt preprocessing", style="bold blue")) + if not _is_quiet(quiet): + console.print(Panel("Starting prompt preprocessing", style="bold blue")) prompt = process_backtick_includes(prompt, recursive) _dbg("After backtick includes processed") prompt = process_xml_tags(prompt, recursive) _dbg("After XML-like tags processed") if double_curly_brackets: - prompt = double_curly(prompt, exclude_keys) + prompt = double_curly(prompt, exclude_keys, quiet=quiet) _dbg("After double_curly execution") # Scan for risky placeholders remaining outside code fences singles, templates = _scan_risky_placeholders(prompt) @@ -136,7 +141,8 @@ def preprocess(prompt: str, recursive: bool = False, double_curly_brackets: bool for ln, frag in templates[:5]: _dbg(f" line {ln}: {frag}") # Don't trim whitespace that might be significant for the tests - console.print(Panel("Preprocessing complete", style="bold green")) + if not _is_quiet(quiet): + console.print(Panel("Preprocessing complete", style="bold green")) _dbg(f"Final length: {len(prompt)} characters") _write_debug_report() return prompt @@ -386,11 +392,12 @@ def replace_many_with_spans(match): return replace_many(match) return re.sub(pattern, replace_many_with_spans, text, flags=re.DOTALL) -def double_curly(text: str, exclude_keys: Optional[List[str]] = None) -> str: +def double_curly(text: str, exclude_keys: Optional[List[str]] = None, quiet: bool = False) -> str: if exclude_keys is None: exclude_keys = [] - console.print("Doubling curly brackets...") + if not _is_quiet(quiet): + console.print("Doubling curly brackets...") _dbg("double_curly invoked") # Protect ${IDENT} placeholders so we can safely double braces, then restore diff --git a/pdd/quiet.py b/pdd/quiet.py new file mode 100644 index 000000000..4c4beeba3 --- /dev/null +++ b/pdd/quiet.py @@ -0,0 +1,80 @@ +"""Global quiet mode support for PDD. + +When enabled, patches rich.console.Console.print, click.echo, and click.secho +to suppress non-error output across the entire codebase. + +Only errors are passed through. +""" + +import click +import rich.console + +_quiet = False +_patched = False + +# Only error messages pass through in quiet mode +_ERROR_PATTERNS = [ + "[red]Error", "[bold red]Error", "[error]", + "[red]Insufficient", "[red]Access denied", + "[red]Authentication failed", "[red]Invalid request", + "Error:", "error:", +] + + +def _is_error(msg) -> bool: + """Check if a message is an error that should always be shown.""" + if not msg: + return False + text = str(msg) + return any(pattern in text for pattern in _ERROR_PATTERNS) + + +def enable_quiet_mode(): + """Enable quiet mode. Patches output functions on first call.""" + global _quiet, _patched + _quiet = True + + if _patched: + return + _patched = True + + # Patch rich.console.Console.print (covers console.print and rprint) + _original_console_print = rich.console.Console.print + + def _quiet_console_print(self, *args, **kwargs): + if not _quiet or (args and _is_error(args[0])): + _original_console_print(self, *args, **kwargs) + + rich.console.Console.print = _quiet_console_print + + # Patch click.echo + _original_echo = click.echo + + def _quiet_echo(message=None, **kwargs): + if not _quiet: + _original_echo(message, **kwargs) + elif _is_error(message): + _original_echo(message, **kwargs) + elif kwargs.get("err"): + _original_echo(message, **kwargs) + + click.echo = _quiet_echo + + # Patch click.secho + _original_secho = click.secho + + def _quiet_secho(message=None, **kwargs): + if not _quiet: + _original_secho(message, **kwargs) + elif kwargs.get("fg") == "red" or kwargs.get("err"): + _original_secho(message, **kwargs) + elif _is_error(message): + _original_secho(message, **kwargs) + + click.secho = _quiet_secho + + +def disable_quiet_mode(): + """Disable quiet mode. Patched wrappers will pass through normally.""" + global _quiet + _quiet = False diff --git a/tests/conftest.py b/tests/conftest.py index ce1561c45..fe78e7dcb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,6 +35,37 @@ def preserve_pdd_path(): del os.environ['PDD_PATH'] +@pytest.fixture(autouse=True) +def _reset_quiet_mode(): + """Ensure quiet mode doesn't leak between tests.""" + import logging + pdd_logger = logging.getLogger("pdd.llm_invoke") + root_logger = logging.getLogger() + # Capture state before test + pdd_level = pdd_logger.level + pdd_handler_levels = [(h, h.level) for h in pdd_logger.handlers] + root_level = root_logger.level + root_handler_levels = [(h, h.level) for h in root_logger.handlers] + old_last_resort = logging.lastResort + old_pdd_quiet = os.environ.get("PDD_QUIET") + yield + from pdd import quiet + quiet.disable_quiet_mode() + # Restore env var + if old_pdd_quiet is None: + os.environ.pop("PDD_QUIET", None) + else: + os.environ["PDD_QUIET"] = old_pdd_quiet + # Restore logger levels and handler levels + pdd_logger.setLevel(pdd_level) + for handler, level in pdd_handler_levels: + handler.setLevel(level) + root_logger.setLevel(root_level) + for handler, level in root_handler_levels: + handler.setLevel(level) + logging.lastResort = old_last_resort + + @pytest.fixture(autouse=True) def restore_standard_streams(): """Ensure sys.stdout/stderr are restored after each test to prevent pollution. diff --git a/tests/test_code_generator_main.py b/tests/test_code_generator_main.py index 1c14caaaa..7a6f3781e 100644 --- a/tests/test_code_generator_main.py +++ b/tests/test_code_generator_main.py @@ -166,7 +166,7 @@ def mock_construct_paths_fixture(monkeypatch): @pytest.fixture def mock_pdd_preprocess_fixture(monkeypatch): # Default mock returns the input unchanged to allow tests to assert substitution behavior when needed - def passthrough(prompt_text, recursive=False, double_curly_brackets=True, exclude_keys=None): + def passthrough(prompt_text, recursive=False, double_curly_brackets=True, exclude_keys=None, quiet=False): return prompt_text mock = MagicMock(side_effect=passthrough) monkeypatch.setattr("pdd.code_generator_main.pdd_preprocess", mock) diff --git a/tests/test_core_errors.py b/tests/test_core_errors.py index 7754f249c..5a2c374e4 100644 --- a/tests/test_core_errors.py +++ b/tests/test_core_errors.py @@ -89,9 +89,9 @@ def test_cli_handle_error_quiet(mock_main, mock_auto_update, runner, create_dumm # Expect exit code 0 because the exception is handled gracefully. assert result.exit_code == 0 assert result.exception is None # Exception should be handled - # Error messages should be suppressed by handle_error when quiet=True - assert "Error during 'generate' command" not in result.output - assert "File not found" not in result.output + # Errors should still be shown even in quiet mode (users need to see errors) + assert "Error during 'generate' command" in result.output + assert "File not found" in result.output mock_main.assert_called_once() # Auto update still runs but prints nothing when quiet mock_auto_update.assert_called_once_with() \ No newline at end of file diff --git a/tests/test_e2e_issue_486_quiet_flag.py b/tests/test_e2e_issue_486_quiet_flag.py new file mode 100644 index 000000000..986b18a0c --- /dev/null +++ b/tests/test_e2e_issue_486_quiet_flag.py @@ -0,0 +1,105 @@ +""" +E2E CLI Test for Issue #486: --quiet flag does not suppress output + +Tests the full CLI path: `pdd --quiet generate ` should suppress +INFO logs, Rich panels, warnings, and success messages. Currently FAILS +because preprocess() unconditionally prints Rich panels regardless of --quiet. +""" + +import pytest +from unittest.mock import patch +from click.testing import CliRunner + +from pdd.cli import cli + + +@pytest.fixture +def prompt_file(tmp_path): + """Create a minimal prompt file for testing.""" + p = tmp_path / "test_python.prompt" + p.write_text("Write a function that returns 'hello'.") + return str(p) + + +# Noisy patterns that --quiet should suppress +NOISY_PATTERNS = [ + "Starting prompt preprocessing", + "Preprocessing complete", + "Doubling curly brackets", + "Successfully loaded prompt", +] + + +class TestQuietFlagE2E: + """E2E tests verifying --quiet suppresses output through the full CLI path. + + These tests let preprocess() actually execute (producing Rich output) + but mock the LLM generators to avoid needing API keys. + The key assertion: preprocess's Rich panel output must NOT appear when --quiet. + """ + + def _run_generate(self, runner, args, prompt_file): + """Run generate command with mocked LLM generators so preprocess runs.""" + with patch("pdd.code_generator_main.local_code_generator_func") as mock_local, \ + patch("pdd.code_generator_main.incremental_code_generator_func") as mock_incr, \ + patch("pdd.code_generator_main.requests") as mock_requests, \ + patch("pdd.code_generator_main.CloudConfig.get_jwt_token", return_value=None), \ + patch("pdd.core.cli.auto_update"): + mock_local.return_value = ("def hello(): return 'hello'", 0.01, "mock-model") + mock_incr.return_value = ("def hello(): return 'hello'", False, 0.01, "mock-model") + # Make cloud check fail so it goes to local path + mock_requests.post.side_effect = Exception("no cloud") + mock_requests.get.side_effect = Exception("no cloud") + + return runner.invoke(cli, args + [prompt_file]) + + def test_quiet_generate_suppresses_preprocessing_output(self, prompt_file): + """pdd --quiet generate should not show preprocessing panels. + + FAILS on buggy code because preprocess() unconditionally prints + 'Starting prompt preprocessing' and 'Preprocessing complete' panels. + """ + runner = CliRunner(mix_stderr=False) + result = self._run_generate(runner, ["--quiet", "generate"], prompt_file) + + stdout = result.output + found = [p for p in NOISY_PATTERNS if p in stdout] + assert not found, ( + f"--quiet should suppress preprocessing output.\n" + f"Found noisy patterns: {found}\n" + f"Full output:\n{stdout}" + ) + + def test_non_quiet_generate_shows_preprocessing_output(self): + """Without --quiet (and without PDD_QUIET), preprocess should show panels (regression guard).""" + import os + from unittest.mock import patch as _patch + from pdd.preprocess import preprocess + + # Ensure PDD_QUIET is not set + env = os.environ.copy() + env.pop("PDD_QUIET", None) + with _patch.dict(os.environ, env, clear=True), \ + _patch("pdd.preprocess.console") as mock_console: + preprocess("Hello world") + # Panels are Rich objects — check that print was called (panels are shown) + assert mock_console.print.call_count >= 2, ( + f"Without --quiet, preprocessing panels should be visible.\n" + f"print() was called {mock_console.print.call_count} time(s)" + ) + + def test_quiet_flag_still_shows_errors(self, tmp_path): + """pdd --quiet generate with nonexistent file should still show error.""" + runner = CliRunner(mix_stderr=False) + with patch("pdd.core.cli.auto_update"): + result = runner.invoke(cli, ["--quiet", "generate", str(tmp_path / "nonexistent.prompt")]) + + assert result.exit_code != 0, ( + f"Expected non-zero exit code for nonexistent file, got {result.exit_code}" + ) + # Error message may appear in stdout or stderr depending on Click's handling + combined = (result.output or "") + (getattr(result, "stderr", "") or "") + assert "does not exist" in combined or "Error" in combined or result.exit_code == 2, ( + f"Errors should still surface even in quiet mode.\n" + f"stdout: {result.output}\nstderr: {getattr(result, 'stderr', '')}" + ) diff --git a/tests/test_quiet_mode.py b/tests/test_quiet_mode.py new file mode 100644 index 000000000..22c70b17f --- /dev/null +++ b/tests/test_quiet_mode.py @@ -0,0 +1,180 @@ +"""Tests for --quiet flag suppressing non-essential output. + +These tests verify that the --quiet flag properly suppresses INFO logs, +Rich panels, warnings, and success messages across all output-producing modules. +""" + +import logging +from unittest.mock import patch +from pathlib import Path + +import pytest + + +class TestPreprocessQuietMode: + """Tests that preprocess() suppresses Rich output when quiet=True.""" + + def test_preprocess_suppresses_panels_when_quiet(self): + """preprocess(quiet=True) should not call console.print for panels.""" + from pdd.preprocess import preprocess + + with patch("pdd.preprocess.console") as mock_console: + preprocess("Hello world", quiet=True) + # Check none of the print calls contain panel output + for c in mock_console.print.call_args_list: + args_str = str(c) + assert "Starting prompt preprocessing" not in args_str + assert "Preprocessing complete" not in args_str + assert "Doubling curly brackets" not in args_str + + def test_preprocess_outputs_panels_by_default(self): + """preprocess() should still show panels when quiet is not set (regression guard).""" + from pdd.preprocess import preprocess + + with patch("pdd.preprocess.console") as mock_console: + preprocess("Hello world") + all_output = " ".join(str(c) for c in mock_console.print.call_args_list) + assert "Starting prompt preprocessing" in all_output or mock_console.print.called + + def test_preprocess_suppresses_doubling_message_when_quiet(self): + """preprocess(quiet=True) should not print 'Doubling curly brackets...'.""" + from pdd.preprocess import preprocess + + with patch("pdd.preprocess.console") as mock_console: + preprocess("Hello {world}", quiet=True) + for c in mock_console.print.call_args_list: + assert "Doubling curly brackets" not in str(c) + + def test_preprocess_shows_doubling_message_by_default(self): + """preprocess() should print 'Doubling curly brackets...' by default.""" + from pdd.preprocess import preprocess + + with patch("pdd.preprocess.console") as mock_console: + preprocess("Hello {world}") + all_output = " ".join(str(c) for c in mock_console.print.call_args_list) + assert "Doubling curly brackets" in all_output + + +class TestLoadPromptTemplateQuietMode: + """Tests that load_prompt_template() suppresses messages when quiet=True.""" + + def test_load_prompt_template_suppresses_success_message_when_quiet(self, tmp_path): + """load_prompt_template(quiet=True) should not print success message.""" + from pdd.load_prompt_template import load_prompt_template + + # Create a real prompt file + prompt_file = tmp_path / "prompts" / "test_quiet.prompt" + prompt_file.parent.mkdir(parents=True, exist_ok=True) + prompt_file.write_text("test prompt content") + + with patch("pdd.load_prompt_template.print_formatted") as mock_print, \ + patch("pdd.load_prompt_template.get_default_resolver") as mock_resolver: + mock_resolver.return_value.resolve_prompt_template.return_value = prompt_file + result = load_prompt_template("test_quiet", quiet=True) + assert result == "test prompt content" + # Should not have printed success message + for c in mock_print.call_args_list: + assert "Successfully loaded" not in str(c) + + def test_load_prompt_template_suppresses_not_found_when_quiet(self): + """load_prompt_template(quiet=True) should not print error for missing files.""" + from pdd.load_prompt_template import load_prompt_template + + with patch("pdd.load_prompt_template.print_formatted") as mock_print, \ + patch("pdd.load_prompt_template.get_default_resolver") as mock_resolver: + mock_resolver.return_value.resolve_prompt_template.return_value = None + mock_resolver.return_value.pdd_path_env = None + mock_resolver.return_value.repo_root = None + mock_resolver.return_value.cwd = Path("/tmp") + result = load_prompt_template("nonexistent", quiet=True) + assert result is None + mock_print.assert_not_called() + + def test_load_prompt_template_shows_success_by_default(self, tmp_path): + """load_prompt_template() should print success message by default.""" + from pdd.load_prompt_template import load_prompt_template + + prompt_file = tmp_path / "prompts" / "test_loud.prompt" + prompt_file.parent.mkdir(parents=True, exist_ok=True) + prompt_file.write_text("test prompt content") + + with patch("pdd.load_prompt_template.print_formatted") as mock_print, \ + patch("pdd.load_prompt_template.get_default_resolver") as mock_resolver: + mock_resolver.return_value.resolve_prompt_template.return_value = prompt_file + load_prompt_template("test_loud") + all_output = " ".join(str(c) for c in mock_print.call_args_list) + assert "Successfully loaded" in all_output + + +class TestLlmInvokeQuietMode: + """Tests that llm_invoke logger level is raised to CRITICAL when quiet=True.""" + + def test_set_quiet_mode_raises_log_level(self): + """set_quiet_mode() should set both loggers to CRITICAL level.""" + from pdd.llm_invoke import set_quiet_mode + + set_quiet_mode() + + logger = logging.getLogger("pdd.llm_invoke") + assert logger.level >= logging.WARNING, ( + f"Expected WARNING (30) or higher, got {logger.level}" + ) + + litellm_logger = logging.getLogger("litellm") + assert litellm_logger.level >= logging.WARNING + + def test_set_quiet_mode_suppresses_info(self): + """After set_quiet_mode(), INFO messages should not propagate.""" + from pdd.llm_invoke import set_quiet_mode + + set_quiet_mode() + logger = logging.getLogger("pdd.llm_invoke") + + with patch.object(logger, "handle") as mock_handle: + logger.info("This should be suppressed") + mock_handle.assert_not_called() + + def test_cli_quiet_sets_logger_to_critical(self): + """The CLI --quiet flag should raise llm_invoke logger to CRITICAL.""" + from click.testing import CliRunner + from pdd.core.cli import cli + + runner = CliRunner(mix_stderr=False) + # Use 'which' command (not --help, which exits before callback) + runner.invoke(cli, ["--quiet", "which"]) + + logger = logging.getLogger("pdd.llm_invoke") + assert logger.level >= logging.CRITICAL, ( + f"After --quiet, logger should be CRITICAL, got level {logger.level}" + ) + + +class TestGenerateCommandQuietMode: + """Tests that pdd --quiet generate suppresses noisy output.""" + + def test_quiet_generate_suppresses_output(self): + """Running 'pdd --quiet generate' should not produce Rich panels or success messages.""" + from click.testing import CliRunner + from pdd.core.cli import cli + + runner = CliRunner(mix_stderr=False) + + with patch("pdd.commands.generate.code_generator_main") as mock_gen: + mock_gen.return_value = ("generated code", False, 0.0, "mock-model") + + result = runner.invoke(cli, ["--quiet", "generate", "prompts/greet_python.prompt"]) + + stdout = result.output + + noisy_patterns = [ + "Starting prompt preprocessing", + "Preprocessing complete", + "Doubling curly brackets", + "Successfully loaded prompt", + ] + + violations = [p for p in noisy_patterns if p in stdout] + assert not violations, ( + f"--quiet flag did not suppress output. Found: {violations}\n" + f"Full output:\n{stdout}" + )