From 0c602846ba9bcb89a43ede9ac93f49bfdfdc7992 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 24 Sep 2026 19:51:25 +0000 Subject: [PATCH] feat(cli): run an analysis without questions from flags (#1127, #1133) - --ticker, --date and --analysts answer their steps, checked as the prompts check them; --save/--no-save and --show/--no-show answer the questions after the run - with no terminal, the run stops before it starts and names every flag or TRADINGAGENTS_* variable still needed; a missing API key and an announcement no longer wait for input - README shows an unattended run --- README.md | 8 ++ cli/announcements.py | 3 +- cli/main.py | 20 +++- cli/prompts.py | 42 +++++++++ cli/run.py | 53 +++++++---- cli/selections.py | 102 ++++++++++++++------- tests/conftest.py | 9 ++ tests/test_cli_commands.py | 3 +- tests/test_cli_headless.py | 162 +++++++++++++++++++++++++++++++++ tests/test_cli_memory_log.py | 2 +- tests/test_rating_integrity.py | 2 +- 11 files changed, 347 insertions(+), 59 deletions(-) create mode 100644 tests/test_cli_headless.py diff --git a/README.md b/README.md index 23bc73bc7..c92b5dce6 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,14 @@ python -m cli.main # alternative: run directly from source ``` You will see a screen where you can select your desired tickers, analysis date, LLM provider, research depth, and more. Your previous run's answers come back as the defaults, so pressing Enter accepts them. The `TRADINGAGENTS_*` variables in `.env` still skip their step entirely. +To run without questions, for a scheduled job or a script, answer the per-run steps with flags and the rest with `TRADINGAGENTS_*` variables: +```bash +export TRADINGAGENTS_LLM_PROVIDER=openai TRADINGAGENTS_QUICK_THINK_LLM=gpt-6-luna TRADINGAGENTS_DEEP_THINK_LLM=gpt-6-sol +export TRADINGAGENTS_OUTPUT_LANGUAGE=English TRADINGAGENTS_MAX_DEBATE_ROUNDS=1 TRADINGAGENTS_MAX_RISK_ROUNDS=1 +tradingagents --ticker NVDA --date 2026-09-23 --analysts market,news,fundamentals --save --no-show +``` +Each flag skips only its own question. Run without a terminal, a missing answer stops the run before it starts and names the flag or variable to set. + ### Markets and tickers TradingAgents works with any market Yahoo Finance covers, using the exchange-suffixed ticker. Company identity and the alpha benchmark resolve automatically per market. diff --git a/cli/announcements.py b/cli/announcements.py index 3ff70159c..a3c8223f4 100644 --- a/cli/announcements.py +++ b/cli/announcements.py @@ -1,4 +1,5 @@ import getpass +import sys import requests from rich.console import Console @@ -46,7 +47,7 @@ def display_announcements(console: Console, data: dict) -> None: ) console.print(panel) - if require_attention: + if require_attention and sys.stdin.isatty(): getpass.getpass("Press Enter to continue...") else: console.print() diff --git a/cli/main.py b/cli/main.py index 7ee214d01..a906893d1 100644 --- a/cli/main.py +++ b/cli/main.py @@ -47,8 +47,23 @@ def analyze( help="JSON file with current holdings and cash, so the trader, risk and " "portfolio agents size against your actual position.", ), + ticker: str = typer.Option(None, "--ticker", help="Ticker to analyze, e.g. NVDA or 0700.HK; skips the prompt"), + date: str = typer.Option(None, "--date", help="Analysis date, YYYY-MM-DD; skips the prompt"), + analysts: str = typer.Option( + None, "--analysts", help="Comma-separated analysts, e.g. market,news; skips the prompt" + ), + save: bool | None = typer.Option( + None, "--save/--no-save", help="Save the report under results_dir without asking" + ), + show: bool | None = typer.Option( + None, "--show/--no-show", help="Show the full report at the end without asking" + ), ): - """Run an analysis. This is what a bare `tradingagents` does.""" + """Run an analysis. This is what a bare `tradingagents` does. + + Flags answer their questions; with provider, models, depth and language also + set through TRADINGAGENTS_* variables, the run asks nothing. + """ if ctx.invoked_subcommand is not None: return if clear_checkpoints: @@ -64,7 +79,8 @@ def analyze( raise typer.Exit(code=1) from None try: - run_analysis(checkpoint=checkpoint, portfolio=portfolio_context) + flags = {"ticker": ticker, "date": date, "analysts": analysts, "save": save, "show": show} + run_analysis(checkpoint=checkpoint, portfolio=portfolio_context, flags=flags) except _NO_CONSOLE_ERRORS: # A terminal with no console buffer cannot host the interactive prompts. # Emit one actionable line on stderr instead of a prompt_toolkit diff --git a/cli/prompts.py b/cli/prompts.py index 1aae00f8e..4be760b9b 100644 --- a/cli/prompts.py +++ b/cli/prompts.py @@ -1,7 +1,10 @@ +import datetime import os +import sys from pathlib import Path import questionary +import typer from dotenv import find_dotenv, set_key from cli.display import console @@ -60,6 +63,41 @@ def get_ticker() -> str: return normalize_ticker_symbol(ticker) if ticker.strip() else "SPY" +def parse_ticker(value: str) -> str: + """A ticker given on the command line, canonical; empty or malformed is refused.""" + if not value.strip() or not is_valid_ticker_input(value): + raise ValueError(f"not a ticker symbol: {value!r} (e.g. {TICKER_INPUT_EXAMPLES})") + return normalize_ticker_symbol(value) + + +def parse_analysis_date(value: str) -> str: + """An analysis date as YYYY-MM-DD, today or earlier.""" + try: + day = datetime.datetime.strptime(value.strip(), "%Y-%m-%d").date() + except ValueError: + raise ValueError(f"not a date: {value!r}; use YYYY-MM-DD") from None + if day > datetime.date.today(): + raise ValueError(f"{value} is in the future") + return day.isoformat() + + +def parse_analysts(value: str, asset_type: AssetType) -> list[AnalystType]: + """Comma-separated analyst names, in the canonical order, checked against the asset.""" + # "sentiment" is the name users see; the analyst's key is "social". + names = [{"sentiment": "social"}.get(n, n) for n in (n.strip().lower() for n in value.split(",")) if n] + if not names: + raise ValueError("name at least one analyst") + known = {a.value: a for a in AnalystType} + unknown = [n for n in names if n not in known] + if unknown: + raise ValueError(f"unknown analyst {', '.join(unknown)}; choose from {', '.join(known)}") + available = filter_analysts_for_asset_type(list(known.values()), asset_type) + unavailable = [n for n in names if known[n] not in available] + if unavailable: + raise ValueError(f"{', '.join(unavailable)} is not available for {asset_type.value}") + return [a for a in available if a.value in names] + + def normalize_ticker_symbol(ticker: str) -> str: """Resolve user input to its canonical Yahoo symbol (single source of truth). @@ -611,6 +649,10 @@ def ensure_api_key(provider: str) -> str | None: if existing: return existing + if not sys.stdin.isatty(): + console.print(f"[red]{env_var} is not set; there is no terminal to ask for it.[/red]") + raise typer.Exit(code=1) + console.print( f"\n[yellow]{env_var} is not set in your environment.[/yellow]" ) diff --git a/cli/run.py b/cli/run.py index a404085e9..dc6408cb2 100644 --- a/cli/run.py +++ b/cli/run.py @@ -2,6 +2,7 @@ import datetime import os +import sys import time from functools import wraps from pathlib import Path @@ -21,7 +22,7 @@ from cli.display import ( update_display, update_research_team_status, ) -from cli.selections import get_user_selections +from cli.selections import get_user_selections, unattended_gaps from cli.stats_handler import StatsCallbackHandler from tradingagents.agents.rating import is_review, run_rating from tradingagents.dataflows.symbols import safe_ticker_component @@ -93,9 +94,19 @@ def _build_run_config(selections: dict, checkpoint: bool | None) -> dict: return config -def run_analysis(checkpoint: bool | None = None, portfolio=None): - # First get all user selections - selections = get_user_selections() +def run_analysis(checkpoint: bool | None = None, portfolio=None, flags=None): + flags = flags or {} + # With no terminal nothing can answer a prompt: name every question still + # open before any model is called, rather than stopping at the first one. + if not (sys.stdin and sys.stdin.isatty()): + gaps = unattended_gaps(flags) + if gaps: + console.print("[red]No terminal to answer the setup questions. Set:[/red]") + for gap in gaps: + console.print(f" {gap}") + raise typer.Exit(code=1) + + selections = get_user_selections(flags) config = _build_run_config(selections, checkpoint) @@ -366,29 +377,35 @@ def run_analysis(checkpoint: bool | None = None, portfolio=None): ) console.print(f"[dim]{analyst_wall_time_tracker.format_summary()}[/dim]") - # Prompt to save report - save_choice = typer.prompt("Save report?", default="Y").strip().upper() - if save_choice in ("Y", "YES", ""): + _offer_reports(final_state, graph, config, selections["ticker"], + save=flags.get("save"), show=flags.get("show")) + + +def _offer_reports(final_state, graph, config, ticker, save=None, show=None): + """Save the report tree and show it; ``save``/``show`` answer the questions when given.""" + asked = save is None + if asked: + save = typer.prompt("Save report?", default="Y").strip().upper() in ("Y", "YES", "") + if save: timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") # Under results_dir, not the working directory: in Docker the working # directory is inside the container and the report goes with it, while # results_dir is the mounted volume the rest of the run already writes to. - default_path = (Path(config["results_dir"]) / "reports" - / f"{safe_ticker_component(selections['ticker'])}_{timestamp}") - save_path_str = typer.prompt( - "Save path (press Enter for default)", - default=str(default_path) - ).strip() - save_path = Path(save_path_str) + save_path = (Path(config["results_dir"]) / "reports" + / f"{safe_ticker_component(ticker)}_{timestamp}") + if asked: # someone at the prompt may pick another folder + save_path = Path(typer.prompt( + "Save path (press Enter for default)", default=str(save_path) + ).strip()) try: - report_file = write_report_tree(final_state, selections["ticker"], save_path, + report_file = write_report_tree(final_state, ticker, save_path, settings=graph.run_settings()) console.print(f"\n[green]✓ Report saved to:[/green] {save_path.resolve()}") console.print(f" [dim]Complete report:[/dim] {report_file.name}") except Exception as e: console.print(f"[red]Error saving report: {e}[/red]") - # Prompt to display full report - display_choice = typer.prompt("\nDisplay full report on screen?", default="Y").strip().upper() - if display_choice in ("Y", "YES", ""): + if show is None: + show = typer.prompt("\nDisplay full report on screen?", default="Y").strip().upper() in ("Y", "YES", "") + if show: display_complete_report(final_state) diff --git a/cli/selections.py b/cli/selections.py index df7640481..e0e8f96ac 100644 --- a/cli/selections.py +++ b/cli/selections.py @@ -25,6 +25,9 @@ from cli.prompts import ( detect_asset_type, ensure_api_key, get_ticker, + parse_analysis_date, + parse_analysts, + parse_ticker, prompt_openai_compatible_url, resolve_backend_url, select_analysts, @@ -36,15 +39,40 @@ from cli.prompts import ( from tradingagents.default_config import DEFAULT_CONFIG -def get_user_selections(): +def get_user_selections(flags=None): """Ask for the run's settings, offering the previous run's answers.""" - selections = _prompt_selections(load_last_run()) + selections = _prompt_selections(load_last_run(), flags or {}) save_last_run(selections) return selections -def _prompt_selections(prefs): - """Walk the selection steps. ``prefs`` prefills, the environment skips.""" +def unattended_gaps(flags) -> list[str]: + """The flags and environment variables a run with no terminal still needs.""" + env = os.environ.get + gaps = [f"--{name}" for name in ("ticker", "date", "analysts") if flags.get(name) is None] + gaps += [f"--{name} or --no-{name}" for name in ("save", "show") if flags.get(name) is None] + if not env("TRADINGAGENTS_OUTPUT_LANGUAGE"): + gaps.append("TRADINGAGENTS_OUTPUT_LANGUAGE") + if not (env("TRADINGAGENTS_MAX_DEBATE_ROUNDS") and env("TRADINGAGENTS_MAX_RISK_ROUNDS")): + gaps.append("TRADINGAGENTS_MAX_DEBATE_ROUNDS and TRADINGAGENTS_MAX_RISK_ROUNDS") + if not env("TRADINGAGENTS_LLM_PROVIDER"): + gaps.append("TRADINGAGENTS_LLM_PROVIDER") + if not (env("TRADINGAGENTS_QUICK_THINK_LLM") or env("TRADINGAGENTS_DEEP_THINK_LLM")): + gaps.append("TRADINGAGENTS_QUICK_THINK_LLM or TRADINGAGENTS_DEEP_THINK_LLM") + return gaps + + +def _from_flag(parse, value, *args): + """A flag's value through the same check its prompt applies; a bad one ends the run.""" + try: + return parse(value, *args) + except ValueError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(code=1) from None + + +def _prompt_selections(prefs, flags): + """Walk the selection steps. ``prefs`` prefills; flags and the environment skip.""" with open(Path(__file__).parent / "static" / "welcome.txt", encoding="utf-8") as f: welcome_ascii = f.read() @@ -93,14 +121,18 @@ def _prompt_selections(prefs): return prompt_fn() # Step 1: Ticker symbol - console.print( - create_question_box( - "Step 1: Ticker Symbol", - "Enter the ticker, with exchange suffix when needed (e.g. SPY, 0700.HK, BTC-USD)", - "SPY", + if flags.get("ticker") is not None: + selected_ticker = _from_flag(parse_ticker, flags["ticker"]) + console.print(f"[green]✓ Ticker from --ticker:[/green] {selected_ticker}") + else: + console.print( + create_question_box( + "Step 1: Ticker Symbol", + "Enter the ticker, with exchange suffix when needed (e.g. SPY, 0700.HK, BTC-USD)", + "SPY", + ) ) - ) - selected_ticker = get_ticker() + selected_ticker = get_ticker() asset_type = detect_asset_type(selected_ticker) # Only announce when it's not the default stock path, to avoid printing # "stock" on every run. @@ -110,15 +142,19 @@ def _prompt_selections(prefs): ) # Step 2: Analysis date - default_date = datetime.datetime.now().strftime("%Y-%m-%d") - console.print( - create_question_box( - "Step 2: Analysis Date", - "Enter the analysis date (YYYY-MM-DD)", - default_date, + if flags.get("date") is not None: + analysis_date = _from_flag(parse_analysis_date, flags["date"]) + console.print(f"[green]✓ Analysis date from --date:[/green] {analysis_date}") + else: + default_date = datetime.datetime.now().strftime("%Y-%m-%d") + console.print( + create_question_box( + "Step 2: Analysis Date", + "Enter the analysis date (YYYY-MM-DD)", + default_date, + ) ) - ) - analysis_date = get_analysis_date() + analysis_date = get_analysis_date() # Step 3: Output language (skipped when set via TRADINGAGENTS_OUTPUT_LANGUAGE) if os.environ.get("TRADINGAGENTS_OUTPUT_LANGUAGE"): @@ -136,13 +172,16 @@ def _prompt_selections(prefs): output_language = ask_output_language(prefs.get("output_language")) # Step 4: Select analysts - console.print( - create_question_box( - "Step 4: Analysts Team", "Select your LLM analyst agents for the analysis" - ) - ) prefs = sanitize(prefs, asset_type.value) - selected_analysts = select_analysts(asset_type, prefs.get("analysts")) + if flags.get("analysts") is not None: + selected_analysts = _from_flag(parse_analysts, flags["analysts"], asset_type) + else: + console.print( + create_question_box( + "Step 4: Analysts Team", "Select your LLM analyst agents for the analysis" + ) + ) + selected_analysts = select_analysts(asset_type, prefs.get("analysts")) console.print( f"[green]Selected analysts:[/green] {', '.join(analyst.value for analyst in selected_analysts)}" ) @@ -303,13 +342,6 @@ def get_analysis_date(): "", default=datetime.datetime.now().strftime("%Y-%m-%d") ) try: - # Validate date format and ensure it's not in the future - analysis_date = datetime.datetime.strptime(date_str, "%Y-%m-%d") - if analysis_date.date() > datetime.datetime.now().date(): - console.print("[red]Error: Analysis date cannot be in the future[/red]") - continue - return date_str - except ValueError: - console.print( - "[red]Error: Invalid date format. Please use YYYY-MM-DD[/red]" - ) + return parse_analysis_date(date_str) + except ValueError as exc: + console.print(f"[red]Error: {exc}[/red]") diff --git a/tests/conftest.py b/tests/conftest.py index 52d1c185b..c134581fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,6 +45,15 @@ def _no_network(request, monkeypatch): monkeypatch.setattr(socket.socket, "connect_ex", refuse) +@pytest.fixture(autouse=True) +def _at_a_terminal(monkeypatch): + """Tests of the interactive steps run as if at a terminal; pytest's stdin is + not one. A test of an unattended run sets isatty to False itself.""" + import sys + + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + + @pytest.fixture(autouse=True) def _own_cli_prefs(tmp_path, monkeypatch): """The CLI keeps the last run's selections in the user's home; tests keep theirs apart.""" diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 6ad20dac0..c46fc66c4 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -25,7 +25,8 @@ calls: list = [] @pytest.mark.unit def test_no_arguments_still_runs_an_analysis(runner): assert runner.invoke(m.app, []).exit_code == 0 - assert calls == [("analysis", {"checkpoint": None, "portfolio": None})] + no_flags = {"ticker": None, "date": None, "analysts": None, "save": None, "show": None} + assert calls == [("analysis", {"checkpoint": None, "portfolio": None, "flags": no_flags})] @pytest.mark.unit diff --git a/tests/test_cli_headless.py b/tests/test_cli_headless.py new file mode 100644 index 000000000..15f292cc6 --- /dev/null +++ b/tests/test_cli_headless.py @@ -0,0 +1,162 @@ +"""Unattended CLI runs: flags answer the per-run questions, and a run with no +terminal stops up front, naming what to set, instead of waiting on a prompt.""" + +import datetime + +import pytest +import typer + +from cli import prompts, run, selections +from cli.models import AnalystType, AssetType + +UNATTENDED_ENV = { + "TRADINGAGENTS_OUTPUT_LANGUAGE": "English", + "TRADINGAGENTS_MAX_DEBATE_ROUNDS": "1", + "TRADINGAGENTS_MAX_RISK_ROUNDS": "1", + "TRADINGAGENTS_LLM_PROVIDER": "openai", + "TRADINGAGENTS_QUICK_THINK_LLM": "gpt-6-luna", +} +FLAGS = {"ticker": "NVDA", "date": "2026-09-23", "analysts": "market,news", "save": True, "show": False} + + +@pytest.mark.unit +class TestFlagValues: + def test_a_ticker_is_normalised_and_an_empty_one_refused(self): + assert prompts.parse_ticker("0700.hk") == "0700.HK" + with pytest.raises(ValueError, match="ticker"): + prompts.parse_ticker(" ") + with pytest.raises(ValueError, match="ticker"): + prompts.parse_ticker("NV DA") + + def test_a_date_must_be_a_past_or_present_day(self): + assert prompts.parse_analysis_date("2026-09-23") == "2026-09-23" + tomorrow = (datetime.date.today() + datetime.timedelta(days=1)).isoformat() + with pytest.raises(ValueError, match="future"): + prompts.parse_analysis_date(tomorrow) + with pytest.raises(ValueError, match="YYYY-MM-DD"): + prompts.parse_analysis_date("23/09/2026") + + def test_analysts_are_named_and_checked_against_the_asset(self): + assert prompts.parse_analysts(" Market, news ", AssetType.STOCK) == [AnalystType.MARKET, AnalystType.NEWS] + with pytest.raises(ValueError, match="market, social, news, fundamentals"): + prompts.parse_analysts("market,macro", AssetType.STOCK) + with pytest.raises(ValueError, match="crypto"): + prompts.parse_analysts("fundamentals", AssetType.CRYPTO) + with pytest.raises(ValueError, match="at least one"): + prompts.parse_analysts(" , ", AssetType.STOCK) + + +@pytest.mark.unit +def test_flags_skip_exactly_their_own_steps(monkeypatch): + for name, value in UNATTENDED_ENV.items(): + monkeypatch.setenv(name, value) + + def no_prompt(*a, **k): + raise AssertionError("prompted although a flag answered the step") + + for step in ("get_ticker", "get_analysis_date", "select_analysts"): + monkeypatch.setattr(selections, step, no_prompt) + monkeypatch.setattr(selections, "fetch_announcements", lambda: None) + monkeypatch.setattr(selections, "display_announcements", lambda *a: None) + + chosen = selections._prompt_selections({}, FLAGS) + + assert chosen["ticker"] == "NVDA" and chosen["analysis_date"] == "2026-09-23" + assert chosen["analysts"] == [AnalystType.MARKET, AnalystType.NEWS] + + +@pytest.mark.unit +def test_without_a_terminal_every_unanswered_question_is_named_before_the_run(monkeypatch, capsys): + for name in UNATTENDED_ENV: + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(run.sys.stdin, "isatty", lambda: False) + + with pytest.raises(typer.Exit): + run.run_analysis(flags={"ticker": "NVDA"}) + + out = capsys.readouterr().out + for needed in ("--date", "--analysts", "--save", "--show", "TRADINGAGENTS_LLM_PROVIDER"): + assert needed in out + assert "--ticker" not in out + + +@pytest.mark.unit +def test_nothing_is_missing_when_flags_and_environment_answer_every_step(monkeypatch): + for name, value in UNATTENDED_ENV.items(): + monkeypatch.setenv(name, value) + assert selections.unattended_gaps(FLAGS) == [] + + +@pytest.mark.unit +def test_save_and_show_answer_the_questions_after_the_run(monkeypatch, tmp_path): + def no_prompt(*a, **k): + raise AssertionError("prompted although --save/--show answered") + + monkeypatch.setattr(run.typer, "prompt", no_prompt) + shown = [] + monkeypatch.setattr(run, "display_complete_report", lambda state: shown.append(state)) + graph = type("G", (), {"run_settings": lambda self: {}})() + state = {"market_report": "M", "final_trade_decision": "**Rating**: Hold"} + + run._offer_reports(state, graph, {"results_dir": str(tmp_path)}, "NVDA", save=True, show=False) + + assert list(tmp_path.glob("reports/NVDA_*/complete_report.md")) + assert shown == [] + + + +@pytest.mark.unit +def test_an_announcement_does_not_wait_for_enter_without_a_terminal(monkeypatch): + from cli import announcements + from cli.display import console + + def no_wait(*a): + raise AssertionError("waited for Enter with no terminal") + + monkeypatch.setattr(announcements.getpass, "getpass", no_wait) + monkeypatch.setattr(announcements.sys.stdin, "isatty", lambda: False) + announcements.display_announcements(console, {"content": "Maintenance tonight", "require_attention": True}) + + +@pytest.mark.unit +def test_a_missing_key_without_a_terminal_names_the_variable(monkeypatch, capsys): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.setattr(prompts.sys.stdin, "isatty", lambda: False) + + with pytest.raises(typer.Exit): + prompts.ensure_api_key("openai") + assert "OPENAI_API_KEY" in capsys.readouterr().out + + +@pytest.mark.unit +def test_the_command_passes_its_flags_to_the_run(monkeypatch): + from typer.testing import CliRunner + + from cli import main as cli_main + + seen = {} + monkeypatch.setattr(cli_main, "run_analysis", lambda **kw: seen.update(kw)) + + result = CliRunner().invoke(cli_main.app, ["--ticker", "NVDA", "--date", "2026-09-23", + "--analysts", "market,news", "--save", "--no-show"]) + + assert result.exit_code == 0, result.output + assert seen["flags"] == FLAGS + + +@pytest.mark.unit +def test_sentiment_names_the_sentiment_analyst(): + assert prompts.parse_analysts("sentiment,market", AssetType.STOCK) == [AnalystType.MARKET, AnalystType.SOCIAL] + + +@pytest.mark.unit +def test_an_empty_flag_is_checked_like_its_step_not_reported_missing(monkeypatch): + assert "--ticker" not in selections.unattended_gaps(dict(FLAGS, ticker="")) + + +@pytest.mark.unit +def test_a_closed_stdin_counts_as_no_terminal(monkeypatch, capsys): + monkeypatch.setattr(run.sys, "stdin", None) + with pytest.raises(typer.Exit): + run.run_analysis(flags={}) + assert "--ticker" in capsys.readouterr().out diff --git a/tests/test_cli_memory_log.py b/tests/test_cli_memory_log.py index 9e266fbfb..fa55dfd63 100644 --- a/tests/test_cli_memory_log.py +++ b/tests/test_cli_memory_log.py @@ -147,7 +147,7 @@ def _run_cli(monkeypatch, tmp_path, fake): monkeypatch.setattr(cli_run, "create_layout", lambda: None) monkeypatch.setattr(cli_run, "update_display", lambda *a, **k: None) monkeypatch.setattr(cli_run, "Live", _NullLive) - monkeypatch.setattr(cli_run, "get_user_selections", lambda: { + monkeypatch.setattr(cli_run, "get_user_selections", lambda flags=None: { "ticker": "NVDA", "analysis_date": "2026-01-10", "analysts": [AnalystType.MARKET], "asset_type": "stock", }) diff --git a/tests/test_rating_integrity.py b/tests/test_rating_integrity.py index 8d6523e08..fabe76082 100644 --- a/tests/test_rating_integrity.py +++ b/tests/test_rating_integrity.py @@ -151,7 +151,7 @@ def test_the_cli_says_when_a_run_produced_no_usable_rating(monkeypatch, tmp_path monkeypatch.setattr(m.console, "print", lambda *a, **k: printed.append(" ".join(str(x) for x in a))) monkeypatch.setattr(cli_run, "display_complete_report", lambda *a, **k: None) monkeypatch.setattr(m.typer, "prompt", lambda *a, **k: "N") - monkeypatch.setattr(cli_run, "get_user_selections", lambda: { + monkeypatch.setattr(cli_run, "get_user_selections", lambda flags=None: { "ticker": "NVDA", "analysis_date": "2026-01-10", "analysts": [AnalystType.MARKET], "asset_type": "stock", })