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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<SYMBOL>-<YYYY-MM-DD>.<ext>`; 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
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<SYMBOL>-<date>.<ext>` in the working directory.

## Use it from Claude Code / Cursor

Do the one-time setup (creates the venv the launcher looks for):
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
47 changes: 37 additions & 10 deletions src/investo/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
9 changes: 9 additions & 0 deletions src/investo/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"),
)

Expand Down
Loading
Loading