diff --git a/.env.example b/.env.example index 29aebdc..4b6ae78 100644 --- a/.env.example +++ b/.env.example @@ -37,3 +37,10 @@ INVESTO_AV_DAILY_CAP=25 # Alpha Vantage daily request cap (free tier), t # Set to false to force the Yahoo insider/institutional fallback (e.g. offline). Optional. INVESTO_ENABLE_INDIA_HOLDINGS=true INVESTO_INDIA_HOLDINGS_MIN_INTERVAL=1.0 # polite gap between NSE/BSE calls + +# PDF export (investo analyze --pdf / the export_report tool). All optional. +# By default Investo finds a system Chrome/Edge/Chromium; set this to force a specific one. +INVESTO_CHROME= +INVESTO_PDF_TIMEOUT=60.0 # seconds before a headless render is abandoned +# Sandboxes the export_report MCP tool's output directory (empty => a temp dir). +INVESTO_EXPORT_DIR= diff --git a/CHANGELOG.md b/CHANGELOG.md index cd901ee..7d6e891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,20 @@ All notable changes to Investo are documented here. The format follows ## [Unreleased] +### Added +- **PDF export** — `investo analyze --pdf [FILE]` renders the research note to PDF, with **no new + required dependency**. It shells out to a system Chrome/Edge/Chromium/Brave if one is installed + (the usual case), falls back to a Playwright-managed Chromium (`pip install 'investo[pdf]' && + playwright install chromium`), and otherwise fails with a message naming all three remedies — + while still leaving the `.html` on disk. `INVESTO_CHROME` overrides browser discovery; + `INVESTO_PDF_TIMEOUT` and `INVESTO_EXPORT_DIR` are configurable. The engine lives in + `investo.export` (`save_html`, `save_pdf`, `find_browser`, `html_to_pdf`). + ### Changed +- **`investo analyze` output flags now compose.** `--json`, `--html` and `--pdf` each do one thing + and can be combined; previously `--html` silently suppressed `--json`. Bare `--html`/`--pdf` write + `investo--.`; parent directories are created; a PDF-engine failure exits + 2 (with the `.html` retained) and prints to stderr. - **`investo analyze --html` now renders an institutional research note, not a dashboard.** The old one-pager (rounded cards, KPI tiles, coloured status pills, ✓/▲ emoji, no charts) read as a generated artifact and covered fewer sections than the terminal report. The new renderer diff --git a/README.md b/README.md index 26c4010..bf65f52 100644 --- a/README.md +++ b/README.md @@ -92,10 +92,18 @@ investo analyze "Infosys" investo analyze "Reliance Industries" investo analyze "Tata Motors" investo analyze AAPL -investo analyze "Reliance Industries" --html reliance.html # self-contained analyst one-pager +investo analyze "Reliance Industries" --html reliance.html # self-contained research note +investo analyze "Infosys" --pdf infosys.pdf # PDF via headless Chrome/Edge +investo analyze "Infosys" --json --html infy.html # flags compose; nothing is discarded investo search "tata motors" ``` +`--pdf` needs a Chromium-family browser: it uses a system **Chrome, Edge, Chromium or Brave** if one +is installed (no setup), falls back to a managed Chromium via `pip install 'investo[pdf]' && +playwright install chromium`, and otherwise prints exactly how to fix it while still leaving the +`.html` on disk. Point `INVESTO_CHROME` at a specific executable to override discovery. Bare `--html` +/ `--pdf` (no filename) write `investo--.` in the working directory. + ## Use it from Claude Code / Cursor Do the one-time setup (creates the venv the launcher looks for): diff --git a/pyproject.toml b/pyproject.toml index ba23b7c..6cc6c90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,9 @@ dependencies = [ ] [project.optional-dependencies] +# PDF export needs no dependency when a system Chrome/Edge/Chromium is present (the usual case); +# this extra is only the managed-browser fallback for machines without one. +pdf = ["playwright>=1.40"] dev = ["pytest>=8.0.0", "ruff>=0.6.0", "mypy>=1.10.0", "build>=1.2.0"] [project.scripts] diff --git a/src/investo/cli.py b/src/investo/cli.py index fac1226..68be5a5 100644 --- a/src/investo/cli.py +++ b/src/investo/cli.py @@ -17,6 +17,9 @@ from .models import AnalysisReport, CompanyProfile +# Distinguishes "flag absent" from "flag given with no value" for --html/--pdf (see build_parser). +_UNSET = object() + # -------------------------------------------------------------------------------------- # Formatting helpers @@ -302,17 +305,36 @@ def render_report(r: AnalysisReport) -> str: def _cmd_analyze(args: argparse.Namespace) -> int: from .analysis.report import analyze report = analyze(args.query, args.market) - if getattr(args, "html", None): - from .render import render_html - with open(args.html, "w", encoding="utf-8") as fh: - fh.write(render_html(report)) - print(f"Wrote HTML report to {args.html}") - return 0 + + # --json / --html / --pdf are composable and each does exactly one thing. If none is given, + # print the terminal report. Nothing is silently discarded when several are combined. + want_html = getattr(args, "html", _UNSET) is not _UNSET + want_pdf = getattr(args, "pdf", _UNSET) is not _UNSET + exit_code = 0 + if args.json: print(json.dumps(report.model_dump(), indent=2, default=str)) - else: + + if want_html: + from .export import save_html + out = save_html(report, args.html) # args.html is None when the flag is bare + print(f"Wrote HTML report to {out}") + + if want_pdf: + from .export import PdfExportError, save_pdf + try: + out, engine, warnings = save_pdf(report, args.pdf) + for w in warnings: + print(f"warning: {w}", file=sys.stderr) + print(f"Wrote PDF report to {out} ({engine})") + except PdfExportError as exc: + # The .html sidecar is still on disk; a failed export is still a failed command. + print(f"error: PDF export failed.\n{exc}", file=sys.stderr) + exit_code = 2 + + if not (args.json or want_html or want_pdf): print(render_report(report)) - return 0 + return exit_code def _cmd_search(args: argparse.Namespace) -> int: @@ -356,8 +378,13 @@ def build_parser() -> argparse.ArgumentParser: pa = sub.add_parser("analyze", help="Full investment analysis") pa.add_argument("query", help="Company name or ticker") - pa.add_argument("--json", action="store_true", help="Emit raw JSON") - pa.add_argument("--html", metavar="FILE", help="Write a self-contained HTML report to FILE") + pa.add_argument("--json", action="store_true", help="Emit raw JSON to stdout") + # nargs="?" + a distinct default: absent => _UNSET (don't write); bare => None (default name); + # with a value => that path. This is what lets --json/--html/--pdf compose. + pa.add_argument("--html", nargs="?", const=None, default=_UNSET, metavar="FILE", + help="Write a self-contained HTML research note (default name if FILE omitted)") + pa.add_argument("--pdf", nargs="?", const=None, default=_UNSET, metavar="FILE", + help="Write a PDF via headless Chrome/Edge (default name if FILE omitted)") _add_market(pa) pa.set_defaults(func=_cmd_analyze) diff --git a/src/investo/config.py b/src/investo/config.py index 390f7b1..2d148bd 100644 --- a/src/investo/config.py +++ b/src/investo/config.py @@ -69,6 +69,12 @@ class Config: enable_india_holdings: bool = True india_holdings_min_interval: float = 1.0 # polite gap between NSE/BSE calls + # PDF export. `chrome_path` overrides browser discovery; `export_dir` sandboxes the + # MCP export tool's output (empty => a temp dir). + chrome_path: str = "" + pdf_timeout: float = 60.0 + export_dir: str = "" + # Logging log_level: str = "WARNING" @@ -105,6 +111,9 @@ def load_config() -> Config: av_daily_cap=_get_int("INVESTO_AV_DAILY_CAP", 25), enable_india_holdings=_get_bool("INVESTO_ENABLE_INDIA_HOLDINGS", True), india_holdings_min_interval=_get_float("INVESTO_INDIA_HOLDINGS_MIN_INTERVAL", 1.0), + chrome_path=os.getenv("INVESTO_CHROME", "").strip(), + pdf_timeout=_get_float("INVESTO_PDF_TIMEOUT", 60.0), + export_dir=os.getenv("INVESTO_EXPORT_DIR", "").strip(), log_level=(os.getenv("INVESTO_LOG_LEVEL", "WARNING").strip().upper() or "WARNING"), ) diff --git a/src/investo/export.py b/src/investo/export.py new file mode 100644 index 0000000..4f9e643 --- /dev/null +++ b/src/investo/export.py @@ -0,0 +1,267 @@ +"""Turn a rendered report into a file — HTML always, PDF when a browser engine is available. + +PDF generation deliberately takes on **no required dependency**. It shells out to a headless +Chrome/Edge/Chromium if one is installed (the common case — most machines have one), falls back to +a Playwright-managed Chromium if that package is present, and otherwise raises an error that tells +the user exactly how to fix it. The HTML is always written first, so a PDF failure still leaves a +usable artifact on disk rather than nothing. + +Four details below are load-bearing and each was a real bug in an earlier draft; they are called +out at their site. +""" + +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING + +from .config import CONFIG + +if TYPE_CHECKING: + from .models import AnalysisReport + +_log = logging.getLogger("investo.export") + +# Sanitised into default filenames. `M&M.NS` is a real ticker, so `&` must not survive into a path. +_UNSAFE = str.maketrans(dict.fromkeys('<>:"/\\|?*&%', "-")) + + +class PdfExportError(RuntimeError): + """Raised when no PDF backend could produce a file. The message names the remedies.""" + + +# -------------------------------------------------------------------------------------- +# Browser discovery +# -------------------------------------------------------------------------------------- +def _candidate_paths() -> list[Path]: + """Platform install locations for a Chromium-family browser, most-preferred first.""" + out: list[Path] = [] + if sys.platform == "win32": + roots = [os.environ.get("PROGRAMFILES", r"C:\Program Files"), + os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"), + os.environ.get("LOCALAPPDATA", "")] + rel = [ + r"Google\Chrome\Application\chrome.exe", + r"Microsoft\Edge\Application\msedge.exe", + r"Chromium\Application\chrome.exe", + r"BraveSoftware\Brave-Browser\Application\brave.exe", + ] + out = [Path(root) / r for root in roots if root for r in rel] + elif sys.platform == "darwin": + out = [Path(p) for p in ( + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + )] + else: + out = [Path(p) for p in ( + "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", "/usr/bin/chromium-browser", + "/usr/bin/microsoft-edge", "/usr/bin/brave-browser", + )] + return out + + +def find_browser() -> Path | None: + """Locate a headless-capable browser: the configured override, an install path, then PATH. + + An override that is set but missing is a warning, not a fatal error — we still try the other + routes rather than dying on a stale env var. + """ + override = CONFIG.chrome_path + if override: + p = Path(override) + if p.exists(): + return p + _log.warning("INVESTO_CHROME=%s does not exist; falling back to discovery", override) + + for path in _candidate_paths(): + if path.exists(): + return path + + for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", + "chrome", "msedge", "microsoft-edge", "brave", "brave-browser"): + found = shutil.which(name) + if found: + return Path(found) + return None + + +# -------------------------------------------------------------------------------------- +# HTML -> PDF +# -------------------------------------------------------------------------------------- +def html_to_pdf(html: str, out_path: Path, *, timeout: float | None = None) -> tuple[str, list[str]]: + """Render ``html`` to ``out_path`` as PDF. Returns (engine, warnings); raises on total failure. + + Tries headless Chrome/Edge, then Playwright, then gives up with an actionable message. + """ + timeout = CONFIG.pdf_timeout if timeout is None else timeout + out_path = Path(out_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + warnings: list[str] = [] + + browser = find_browser() + if browser is not None: + try: + return _chrome_pdf(browser, html, out_path, timeout), warnings + except PdfExportError as exc: + warnings.append(f"Headless browser failed ({exc}); trying Playwright.") + + try: + return _playwright_pdf(html, out_path, timeout), warnings + except _PlaywrightUnavailable as exc: + warnings.append(str(exc)) + + raise PdfExportError( + "No PDF engine available. Do one of:\n" + " • install Google Chrome, Microsoft Edge or Chromium, or\n" + " • pip install 'investo[pdf]' && playwright install chromium, or\n" + " • set INVESTO_CHROME to a Chrome/Edge executable.\n" + "The HTML report was still written." + ) + + +def _chrome_pdf(browser: Path, html: str, out_path: Path, timeout: float) -> str: + """Headless Chrome/Edge via --print-to-pdf. Four non-obvious requirements, each marked.""" + # (1) A TemporaryDirectory with our own file inside it — NOT NamedTemporaryFile. On Windows a + # still-open NamedTemporaryFile cannot be reopened by path, which is exactly what Chrome + # needs to do. + with tempfile.TemporaryDirectory(prefix="investo-pdf-") as tmp: + tmp_dir = Path(tmp) + src = tmp_dir / "report.html" + src.write_text(html, encoding="utf-8") + profile = tmp_dir / "profile" # (3) throwaway profile, see below + + # (2) resolve().as_uri() builds file:///C:/... and percent-escapes spaces. A hand-built + # "file://" + str(path) gets both the slashes and the spaces wrong on Windows. + url = src.resolve().as_uri() + + cmd = [ + str(browser), + "--headless=new", + "--disable-gpu", + # (3) A throwaway --user-data-dir is mandatory: without it, headless can attach to an + # already-running browser's profile and silently produce nothing. This is the + # single most common "works in CI, not on my machine" cause. + f"--user-data-dir={profile}", + "--no-first-run", + "--no-pdf-header-footer", + f"--print-to-pdf={out_path}", + url, + ] + # --no-sandbox only where it's needed (root in a Linux container); never on Win/macOS. + if sys.platform.startswith("linux") and hasattr(os, "geteuid") and os.geteuid() == 0: + cmd.insert(1, "--no-sandbox") + + try: + proc = subprocess.run(cmd, capture_output=True, timeout=timeout, check=False) + except subprocess.TimeoutExpired as exc: + raise PdfExportError(f"{browser.name} timed out after {timeout:.0f}s") from exc + except OSError as exc: + raise PdfExportError(f"could not launch {browser.name}: {exc}") from exc + + # (4) Do NOT trust the return code. Chrome can exit 0 having written nothing; the only + # reliable check is that the file exists and is non-empty. + if not out_path.exists() or out_path.stat().st_size == 0: + tail = proc.stderr.decode("utf-8", "replace")[-300:].strip() + raise PdfExportError(f"{browser.name} produced no PDF" + f"{f' — {tail}' if tail else ''}") + return f"{browser.name} (headless)" + + +class _PlaywrightUnavailable(RuntimeError): + """Playwright isn't installed, or its browser binary hasn't been downloaded.""" + + +def _playwright_pdf(html: str, out_path: Path, timeout: float) -> str: + try: + from playwright.sync_api import sync_playwright + except ImportError as exc: + raise _PlaywrightUnavailable( + "Playwright is not installed (pip install 'investo[pdf]')." + ) from exc + + try: + with sync_playwright() as pw: + browser = pw.chromium.launch() + try: + page = browser.new_page() + page.set_content(html, wait_until="load") + page.pdf(path=str(out_path), format="A4", print_background=True, + # margins live in the document's @page rule; keep Playwright's off. + margin={"top": "0", "bottom": "0", "left": "0", "right": "0"}) + finally: + browser.close() + except Exception as exc: # noqa: BLE001 + msg = str(exc) + # The distinctive "download the browser" error is recoverable-with-instructions, so it is + # reported as unavailable (fall through to the actionable message) rather than a hard fail. + if "Executable doesn't exist" in msg or "playwright install" in msg: + raise _PlaywrightUnavailable( + "Playwright is installed but its Chromium isn't (run: playwright install chromium)." + ) from exc + raise PdfExportError(f"Playwright failed to render the PDF: {msg}") from exc + + if not out_path.exists() or out_path.stat().st_size == 0: + raise PdfExportError("Playwright produced no PDF.") + return "playwright-chromium" + + +# -------------------------------------------------------------------------------------- +# Filenames and the save entry points (used by both the CLI and the MCP tool) +# -------------------------------------------------------------------------------------- +def default_filename(report: AnalysisReport, ext: str) -> str: + """A stable, filesystem-safe default name: investo--..""" + from datetime import date + + sym = (report.resolved.symbol if report.resolved else None) or report.query + sym = sym.translate(_UNSAFE).strip("-") or "report" + return f"investo-{sym}-{date.today().isoformat()}.{ext.lstrip('.')}" + + +def _resolve_out(report: AnalysisReport, path: str | os.PathLike | None, ext: str) -> Path: + """Turn a user path (or None, or a directory) into a concrete file path, creating parents.""" + if path is None: + out = Path.cwd() / default_filename(report, ext) + else: + out = Path(path).expanduser() + if out.is_dir() or str(path).endswith(("/", "\\")): + out = out / default_filename(report, ext) + out.parent.mkdir(parents=True, exist_ok=True) + return out + + +def save_html(report: AnalysisReport, path: str | os.PathLike | None = None) -> Path: + from .render import render_html + + out = _resolve_out(report, path, "html") + out.write_text(render_html(report), encoding="utf-8") + return out + + +def save_pdf( + report: AnalysisReport, + path: str | os.PathLike | None = None, + *, + keep_html: bool = True, +) -> tuple[Path, str, list[str]]: + """Write a PDF (and, by default, an .html sidecar). Returns (pdf_path, engine, warnings). + + The HTML is written first and on purpose: if the PDF engine fails, the caller still has a + usable report on disk rather than an empty hand. + """ + from .render import render_html + + out = _resolve_out(report, path, "pdf") + html = render_html(report) + if keep_html: + out.with_suffix(".html").write_text(html, encoding="utf-8") + engine, warnings = html_to_pdf(html, out) + return out, engine, warnings diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..b1328e2 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,99 @@ +"""CLI tests for `investo analyze` output routing (no network). + +`analyze` is monkeypatched to a canned report, so these test argument routing and file/exit +behaviour, not the analysis. The old CLI shipped with none of this and had a silent precedence +bug where --html quietly suppressed --json. +""" + +import json + +import pytest + +from investo import cli +from investo.models import AnalysisReport, CompanyProfile, TickerCandidate + + +@pytest.fixture(autouse=True) +def _canned_analyze(monkeypatch): + report = AnalysisReport( + query="KPIT Technologies", + resolved=TickerCandidate(symbol="KPITTECH.NS", name="KPIT Technologies Limited"), + profile=CompanyProfile(ticker="KPITTECH.NS", name="KPIT Technologies Limited"), + ) + monkeypatch.setattr("investo.analysis.report.analyze", lambda query, market: report) + return report + + +def _run(argv: list[str]) -> int: + args = cli.build_parser().parse_args(argv) + return args.func(args) + + +def test_no_output_flag_prints_the_terminal_report(capsys): + assert _run(["analyze", "KPIT"]) == 0 + out = capsys.readouterr().out + assert "KPIT Technologies Limited" in out + assert not out.lstrip().startswith("{") # not JSON + + +def test_json_flag_emits_valid_json(capsys): + assert _run(["analyze", "KPIT", "--json"]) == 0 + parsed = json.loads(capsys.readouterr().out) + assert parsed["query"] == "KPIT Technologies" + + +def test_html_flag_writes_a_file_and_creates_parents(tmp_path, capsys): + target = tmp_path / "sub" / "dir" / "report.html" + assert _run(["analyze", "KPIT", "--html", str(target)]) == 0 + assert target.exists() + assert target.read_text(encoding="utf-8").startswith("") + assert "Wrote HTML report" in capsys.readouterr().out + + +def test_bare_html_flag_uses_a_default_name(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + assert _run(["analyze", "KPIT", "--html"]) == 0 + written = list(tmp_path.glob("investo-KPITTECH.NS-*.html")) + assert len(written) == 1 + + +def test_json_and_html_compose_neither_is_discarded(tmp_path, capsys): + # The precedence-bug regression: the old CLI let --html silently suppress --json. + target = tmp_path / "r.html" + assert _run(["analyze", "KPIT", "--json", "--html", str(target)]) == 0 + out = capsys.readouterr().out + assert json.loads(out.split("Wrote HTML report")[0]) # JSON was printed + assert target.exists() # and the HTML was written + + +def test_pdf_success_reports_the_engine(tmp_path, monkeypatch, capsys): + monkeypatch.setattr("investo.export.save_pdf", + lambda report, path: (tmp_path / "k.pdf", "chrome (headless)", [])) + assert _run(["analyze", "KPIT", "--pdf", str(tmp_path / "k.pdf")]) == 0 + assert "chrome (headless)" in capsys.readouterr().out + + +def test_pdf_failure_exits_two_and_keeps_the_html(tmp_path, monkeypatch, capsys): + from investo.export import PdfExportError, save_html + + # Mirror the real save_pdf contract: write the .html sidecar, then fail on the PDF engine. + def stub(report, path): + save_html(report, tmp_path / "k.html") + raise PdfExportError("no engine available; run playwright install chromium") + + monkeypatch.setattr("investo.export.save_pdf", stub) + code = _run(["analyze", "KPIT", "--pdf", str(tmp_path / "k.pdf")]) + err = capsys.readouterr().err + assert code == 2 + assert "PDF export failed" in err + assert (tmp_path / "k.html").exists() # a failed PDF still leaves a report + + +def test_pdf_warnings_go_to_stderr(tmp_path, monkeypatch, capsys): + monkeypatch.setattr("investo.export.save_pdf", + lambda report, path: (tmp_path / "k.pdf", "playwright-chromium", + ["Headless browser failed; trying Playwright."])) + assert _run(["analyze", "KPIT", "--pdf", str(tmp_path / "k.pdf")]) == 0 + captured = capsys.readouterr() + assert "Headless browser failed" in captured.err + assert "playwright-chromium" in captured.out diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..324aba5 --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,224 @@ +"""PDF/HTML export tests (no network, and — crucially — no real browser launch). + +Every test here stubs ``subprocess.run`` or Playwright. A test that actually shelled out to Chrome +would be slow, flaky, and machine-dependent — exactly what this offline suite exists to avoid. +""" + +import dataclasses +import subprocess +from pathlib import Path + +import pytest + +from investo import export +from investo.models import AnalysisReport, CompanyProfile, TickerCandidate + + +def _set_chrome_path(monkeypatch, value: str) -> None: + """CONFIG is a frozen dataclass, so swap in a copy rather than assigning a field.""" + monkeypatch.setattr(export, "CONFIG", dataclasses.replace(export.CONFIG, chrome_path=value)) + + +def _report() -> AnalysisReport: + return AnalysisReport( + query="KPIT Technologies", + resolved=TickerCandidate(symbol="KPITTECH.NS", name="KPIT Technologies Limited"), + profile=CompanyProfile(ticker="KPITTECH.NS", name="KPIT Technologies Limited"), + ) + + +# -------------------------------------------------------------------------------------- +# Browser discovery +# -------------------------------------------------------------------------------------- +def test_configured_chrome_path_is_honoured(monkeypatch, tmp_path): + exe = tmp_path / "my-chrome.exe" + exe.write_text("") + _set_chrome_path(monkeypatch, str(exe)) + assert export.find_browser() == exe + + +def test_missing_override_warns_and_falls_through(monkeypatch): + # A stale INVESTO_CHROME must not be fatal — discovery continues. + _set_chrome_path(monkeypatch, r"C:\nope\ghost.exe") + monkeypatch.setattr(export, "_candidate_paths", lambda: []) + monkeypatch.setattr(export.shutil, "which", lambda name: None) + assert export.find_browser() is None # fell through, didn't raise + + +def test_discovery_finds_an_installed_browser(monkeypatch, tmp_path): + real = tmp_path / "chrome" + real.write_text("") + _set_chrome_path(monkeypatch, "") + monkeypatch.setattr(export, "_candidate_paths", lambda: [tmp_path / "ghost", real]) + assert export.find_browser() == real + + +def test_discovery_falls_back_to_path(monkeypatch, tmp_path): + real = tmp_path / "chromium" + real.write_text("") + _set_chrome_path(monkeypatch, "") + monkeypatch.setattr(export, "_candidate_paths", lambda: []) + monkeypatch.setattr(export.shutil, "which", + lambda name: str(real) if name == "chromium" else None) + assert export.find_browser() == real + + +# -------------------------------------------------------------------------------------- +# The Windows path quirk that a hand-built file:// URL gets wrong +# -------------------------------------------------------------------------------------- +def test_windows_style_path_becomes_a_percent_escaped_file_uri(): + # resolve().as_uri() is the whole reason we don't concatenate "file://" + str(path). + uri = Path("C:/a b/report.html").resolve().as_uri() + assert uri.startswith("file:///") + assert "%20" in uri # the space is escaped, not left raw + + +# -------------------------------------------------------------------------------------- +# Headless Chrome path — stubbed subprocess, never a real launch +# -------------------------------------------------------------------------------------- +def test_chrome_pdf_reports_engine_and_writes_a_file(monkeypatch, tmp_path): + browser = tmp_path / "chrome.exe" + browser.write_text("") + out = tmp_path / "out.pdf" + + def fake_run(cmd, **kwargs): + # Chrome writes the PDF to the --print-to-pdf target; imitate that. + target = next(a.split("=", 1)[1] for a in cmd if a.startswith("--print-to-pdf=")) + Path(target).write_bytes(b"%PDF-1.4 fake") + return subprocess.CompletedProcess(cmd, 0, b"", b"") + + monkeypatch.setattr(export.subprocess, "run", fake_run) + engine = export._chrome_pdf(browser, "", out, timeout=30) + assert "headless" in engine + assert out.read_bytes().startswith(b"%PDF") + + +def test_chrome_exit_zero_but_no_file_is_treated_as_failure(monkeypatch, tmp_path): + # The load-bearing one: --print-to-pdf can exit 0 having written nothing. The return code + # is not trustworthy; the file is. + browser = tmp_path / "chrome.exe" + browser.write_text("") + monkeypatch.setattr(export.subprocess, "run", + lambda cmd, **kw: subprocess.CompletedProcess(cmd, 0, b"", b"boom")) + with pytest.raises(export.PdfExportError): + export._chrome_pdf(browser, "", tmp_path / "out.pdf", timeout=30) + + +def test_chrome_uses_a_throwaway_profile_and_the_new_headless(monkeypatch, tmp_path): + browser = tmp_path / "chrome.exe" + browser.write_text("") + seen = {} + + def fake_run(cmd, **kwargs): + seen["cmd"] = cmd + target = next(a.split("=", 1)[1] for a in cmd if a.startswith("--print-to-pdf=")) + Path(target).write_bytes(b"%PDF-1.4") + return subprocess.CompletedProcess(cmd, 0, b"", b"") + + monkeypatch.setattr(export.subprocess, "run", fake_run) + export._chrome_pdf(browser, "", tmp_path / "out.pdf", timeout=30) + cmd = seen["cmd"] + assert "--headless=new" in cmd + assert any(a.startswith("--user-data-dir=") for a in cmd), "throwaway profile is mandatory" + assert any(a.startswith("file:///") for a in cmd), "must pass a file:// URI" + + +def test_chrome_timeout_becomes_a_pdf_error(monkeypatch, tmp_path): + browser = tmp_path / "chrome.exe" + browser.write_text("") + + def boom(cmd, **kwargs): + raise subprocess.TimeoutExpired(cmd, 30) + + monkeypatch.setattr(export.subprocess, "run", boom) + with pytest.raises(export.PdfExportError, match="timed out"): + export._chrome_pdf(browser, "", tmp_path / "out.pdf", timeout=30) + + +# -------------------------------------------------------------------------------------- +# The no-backend path gives an actionable message +# -------------------------------------------------------------------------------------- +def test_no_backend_raises_with_all_three_remedies(monkeypatch, tmp_path): + monkeypatch.setattr(export, "find_browser", lambda: None) + + def no_playwright(html, out, timeout): + raise export._PlaywrightUnavailable("Playwright is not installed") + + monkeypatch.setattr(export, "_playwright_pdf", no_playwright) + with pytest.raises(export.PdfExportError) as exc: + export.html_to_pdf("", tmp_path / "out.pdf") + msg = str(exc.value) + assert "Chrome" in msg + assert "playwright install" in msg + assert "INVESTO_CHROME" in msg + + +def test_html_to_pdf_prefers_chrome_when_present(monkeypatch, tmp_path): + browser = tmp_path / "chrome.exe" + browser.write_text("") + monkeypatch.setattr(export, "find_browser", lambda: browser) + monkeypatch.setattr(export, "_chrome_pdf", lambda *a, **k: "chrome.exe (headless)") + engine, warnings = export.html_to_pdf("", tmp_path / "out.pdf") + assert engine == "chrome.exe (headless)" + assert warnings == [] + + +def test_html_to_pdf_falls_through_to_playwright_when_chrome_fails(monkeypatch, tmp_path): + browser = tmp_path / "chrome.exe" + browser.write_text("") + monkeypatch.setattr(export, "find_browser", lambda: browser) + + def chrome_fails(*a, **k): + raise export.PdfExportError("chrome broke") + + monkeypatch.setattr(export, "_chrome_pdf", chrome_fails) + monkeypatch.setattr(export, "_playwright_pdf", lambda *a, **k: "playwright-chromium") + engine, warnings = export.html_to_pdf("", tmp_path / "out.pdf") + assert engine == "playwright-chromium" + assert any("Headless browser failed" in w for w in warnings) + + +# -------------------------------------------------------------------------------------- +# Filenames and save entry points +# -------------------------------------------------------------------------------------- +def test_default_filename_is_dated_and_filesystem_safe(): + r = _report() + r.resolved.symbol = "M&M.NS" # a real ticker with a shell-hostile character + name = export.default_filename(r, "pdf") + assert name.startswith("investo-M-M.NS-") + assert name.endswith(".pdf") + assert "&" not in name + + +def test_save_html_writes_and_creates_parents(tmp_path): + out = export.save_html(_report(), tmp_path / "nested" / "dir" / "r.html") + assert out.exists() + assert out.read_text(encoding="utf-8").startswith("") + + +def test_save_html_with_a_directory_appends_the_default_name(tmp_path): + out = export.save_html(_report(), tmp_path) + assert out.parent == tmp_path + assert out.name.startswith("investo-KPITTECH.NS-") + + +def test_save_pdf_writes_the_html_sidecar_even_when_the_engine_fails(monkeypatch, tmp_path): + # A PDF failure must still leave a usable report on disk, not an empty hand. + def fail(html, out, timeout=None): + raise export.PdfExportError("no engine") + + monkeypatch.setattr(export, "html_to_pdf", fail) + out = tmp_path / "kpit.pdf" + with pytest.raises(export.PdfExportError): + export.save_pdf(_report(), out) + assert out.with_suffix(".html").exists(), "the .html sidecar should survive a PDF failure" + + +def test_save_pdf_returns_engine_and_path_on_success(monkeypatch, tmp_path): + monkeypatch.setattr(export, "html_to_pdf", + lambda html, out, timeout=None: ("chrome (headless)", [])) + # html_to_pdf is stubbed, so nothing writes the .pdf; assert on the returned metadata. + out, engine, warnings = export.save_pdf(_report(), tmp_path / "k.pdf") + assert out.name == "k.pdf" + assert engine == "chrome (headless)" + assert out.with_suffix(".html").exists()