diff --git a/src/investo/cli.py b/src/investo/cli.py index cf1404c..43584fc 100644 --- a/src/investo/cli.py +++ b/src/investo/cli.py @@ -316,17 +316,22 @@ def _cmd_analyze(args: argparse.Namespace) -> int: print(json.dumps(report.model_dump(), indent=2, default=str)) if want_html: - from .export import save_html + from .export import file_url, save_html out = save_html(report, args.html) # args.html is None when the flag is bare print(f"Wrote HTML report to {out}") + print(f" Open: {file_url(out)}") # clickable — opens in the browser on click if want_pdf: - from .export import PdfExportError, save_pdf + from .export import PdfExportError, file_url, 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})") + print(f" Open: {file_url(out)}") # the pdf + sidecar = out.with_suffix(".html") + if sidecar.exists(): + print(f" HTML: {file_url(sidecar)}") # the html written alongside it 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) @@ -336,9 +341,10 @@ def _cmd_analyze(args: argparse.Namespace) -> int: # (--html / --pdf already produce a document) or opted out with --no-html. The announcement # goes to stderr so `investo analyze X --json` stays pipeable on stdout. if not (want_html or want_pdf or getattr(args, "no_html", False)): - from .export import save_html + from .export import file_url, save_html out = save_html(report, None) print(f"Wrote HTML report to {out}", file=sys.stderr) + print(f" Open: {file_url(out)}", file=sys.stderr) if not (args.json or want_html or want_pdf): print(render_report(report)) diff --git a/src/investo/export.py b/src/investo/export.py index cc6a437..3a4927f 100644 --- a/src/investo/export.py +++ b/src/investo/export.py @@ -32,6 +32,17 @@ _UNSAFE = str.maketrans(dict.fromkeys('<>:"/\\|?*&%', "-")) +def file_url(path: str | os.PathLike) -> str: + """A clickable, absolute ``file://`` URL for a local path, percent-escaped. + + ``resolve().as_uri()`` is the same construction the headless-Chrome path uses to load the + report, so clicking this link opens exactly the file that was written (a space becomes ``%20``, + Windows drive letters get ``file:///C:/…``). Callers surface it so a reader can open the report + without hunting for the path. + """ + return Path(path).resolve().as_uri() + + class PdfExportError(RuntimeError): """Raised when no PDF backend could produce a file. The message names the remedies.""" @@ -139,9 +150,9 @@ def _chrome_pdf(browser: Path, html: str, out_path: Path, timeout: float) -> str 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() + # (2) file_url() -> 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 = file_url(src) cmd = [ str(browser), diff --git a/src/investo/models.py b/src/investo/models.py index d68dcf5..94f42b5 100644 --- a/src/investo/models.py +++ b/src/investo/models.py @@ -548,12 +548,25 @@ class AiSignals(_Base): # -------------------------------------------------------------------------------------- # Analysis tools: export, technicals, DCF sensitivity, multi-company compare # -------------------------------------------------------------------------------------- +class ExportedFile(_Base): + """One written artifact and how to open it.""" + + path: str + file_url: str # clickable file:// URL — opens the file in the default app on click + format: Literal["pdf", "html"] + bytes: int = 0 + + class ExportResult(_Base): path: str + file_url: str | None = None # clickable file:// URL of `path` format: Literal["pdf", "html"] bytes: int = 0 engine: str | None = None # e.g. "chrome.exe (headless)" | "playwright-chromium" warnings: list[str] = Field(default_factory=list) + # Every artifact this call wrote, primary first — a PDF export also lists its HTML sidecar, + # so a caller has a clickable location for the html and the pdf. + files: list[ExportedFile] = Field(default_factory=list) class TechnicalSnapshot(_Base): @@ -683,6 +696,7 @@ class AnalysisReport(_Base): # Auto-export metadata: set when analyze_company writes an HTML report so a client can open # it without a second tool call. Optional — the report is complete without them. html_report_path: str | None = None + html_report_url: str | None = None # clickable file:// URL of html_report_path generated_at: str | None = None # ISO-8601 UTC timestamp of when the report was written investo_version: str | None = None html_bytes: int | None = None diff --git a/src/investo/server.py b/src/investo/server.py index 8c19d3c..b64b0f9 100644 --- a/src/investo/server.py +++ b/src/investo/server.py @@ -28,6 +28,7 @@ CompanyProfile, DCFResult, DcfSensitivity, + ExportedFile, ExportResult, Financials, FundamentalTrend, @@ -136,13 +137,14 @@ def _attach_html_report(report: AnalysisReport) -> None: from datetime import datetime, timezone from . import __version__ - from .export import default_filename, save_html + from .export import default_filename, file_url, save_html report.generated_at = datetime.now(timezone.utc).isoformat(timespec="seconds") report.investo_version = __version__ try: out = save_html(report, _safe_export_path(default_filename(report, "html"), "html")) report.html_report_path = str(out) + report.html_report_url = file_url(out) # clickable — opens the report on click report.html_bytes = out.stat().st_size except Exception as exc: # noqa: BLE001 — the convenience export must never sink the analysis _log.warning("automatic HTML export failed: %s", exc) @@ -411,25 +413,31 @@ def export_report( a caller cannot write outside it. """ from .analysis.report import analyze - from .export import PdfExportError, html_to_pdf, save_html + from .export import PdfExportError, file_url, html_to_pdf, save_html + + def _exported(p, fmt: Literal["pdf", "html"]) -> ExportedFile: + return ExportedFile(path=str(p), file_url=file_url(p), format=fmt, bytes=p.stat().st_size) out = _safe_export_path(path, format) report = analyze(query, market) if format == "html": - written = save_html(report, out) - return ExportResult(path=str(written), format="html", - bytes=written.stat().st_size, engine="renderer") + f = _exported(save_html(report, out), "html") + return ExportResult(path=f.path, file_url=f.file_url, format="html", + bytes=f.bytes, engine="renderer", files=[f]) from .render import render_html html = render_html(report) - out.with_suffix(".html").write_text(html, encoding="utf-8") # sidecar survives a PDF failure + sidecar = out.with_suffix(".html") + sidecar.write_text(html, encoding="utf-8") # sidecar survives a PDF failure try: engine, warnings = html_to_pdf(html, out) except PdfExportError as exc: raise ValueError(f"{exc}") from exc - return ExportResult(path=str(out), format="pdf", bytes=out.stat().st_size, - engine=engine, warnings=warnings) + # Return a clickable location for the pdf and its html sidecar (primary first). + pdf, html_side = _exported(out, "pdf"), _exported(sidecar, "html") + return ExportResult(path=pdf.path, file_url=pdf.file_url, format="pdf", bytes=pdf.bytes, + engine=engine, warnings=warnings, files=[pdf, html_side]) @mcp.tool(title="Full analysis", annotations=_WRITE) diff --git a/tests/test_cli.py b/tests/test_cli.py index 3875b99..e3bb4e7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -40,6 +40,7 @@ def test_no_output_flag_prints_the_terminal_report_and_auto_writes_html(tmp_path written = list(tmp_path.glob("investo-KPITTECH.NS-*.html")) assert len(written) == 1 assert "Wrote HTML report" in captured.err + assert "file://" in captured.err # clickable location, announced on stderr def test_json_flag_emits_valid_json_on_a_clean_stdout(tmp_path, monkeypatch, capsys): @@ -72,7 +73,9 @@ def test_html_flag_writes_a_file_and_creates_parents(tmp_path, capsys): 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 + out = capsys.readouterr().out + assert "Wrote HTML report" in out + assert "file://" in out and "report.html" in out # a clickable location to open it def test_bare_html_flag_uses_a_default_name(tmp_path, monkeypatch, capsys): @@ -95,7 +98,9 @@ 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 + out = capsys.readouterr().out + assert "chrome (headless)" in out + assert "file://" in out and "k.pdf" in out # clickable location for the pdf def test_pdf_failure_exits_two_and_keeps_the_html(tmp_path, monkeypatch, capsys): diff --git a/tests/test_export.py b/tests/test_export.py index 324aba5..2b12f95 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -73,6 +73,17 @@ def test_windows_style_path_becomes_a_percent_escaped_file_uri(): assert "%20" in uri # the space is escaped, not left raw +def test_file_url_is_an_absolute_clickable_uri(tmp_path): + # The clickable location returned to the user: absolute, percent-escaped, opens on click. + target = tmp_path / "a b" / "investo-report.html" + target.parent.mkdir(parents=True) + target.write_text("", encoding="utf-8") + url = export.file_url(target) + assert url.startswith("file:///") + assert "%20" in url # the space in the directory name is escaped + assert url.endswith("investo-report.html") + + # -------------------------------------------------------------------------------------- # Headless Chrome path — stubbed subprocess, never a real launch # -------------------------------------------------------------------------------------- diff --git a/tests/test_server_tools.py b/tests/test_server_tools.py index 47b5d9e..b600427 100644 --- a/tests/test_server_tools.py +++ b/tests/test_server_tools.py @@ -110,6 +110,9 @@ def test_attach_html_report_writes_into_the_sandbox_and_records_metadata(tmp_pat assert written.read_text(encoding="utf-8").startswith("") assert report.html_bytes and report.html_bytes > 0 assert report.investo_version and report.generated_at + # A clickable location so a client can open the report without a second call. + assert report.html_report_url and report.html_report_url.startswith("file:///") + assert report.html_report_url == written.resolve().as_uri() # --------------------------------------------------------------------------------------