Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/investo/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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))
Expand Down
17 changes: 14 additions & 3 deletions src/investo/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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),
Expand Down
14 changes: 14 additions & 0 deletions src/investo/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
24 changes: 16 additions & 8 deletions src/investo/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
CompanyProfile,
DCFResult,
DcfSensitivity,
ExportedFile,
ExportResult,
Financials,
FundamentalTrend,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 7 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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("<!doctype html>")
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):
Expand All @@ -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):
Expand Down
11 changes: 11 additions & 0 deletions tests/test_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<html></html>", 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
# --------------------------------------------------------------------------------------
Expand Down
3 changes: 3 additions & 0 deletions tests/test_server_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<!doctype html>")
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()


# --------------------------------------------------------------------------------------
Expand Down
Loading