Files
yfinance-fork/cli/yfin/renderers.py
T
goldyard 057bc4493e Add yfin CLI + tool catalog + CLAUDE.md
Wraps the entire yfinance public API behind a single binary `yfin` with
51 subcommands across 13 namespaces (ticker, download, tickers, search,
lookup, market, sector, industry, calendars, screen, live, auth, config).
Output renders as Rich tables on TTY, JSON when piped; --format / --out
support table | json | ndjson | csv | tsv | parquet | yaml.

Highlights:
- 1857 LOC across 18 Python files under cli/yfin/
- Typer + Rich, packaged via hatchling + uv
- Install globally: `cd cli && uv tool install -e .`
- ~/.yfin/config.toml for persistent proxy/cookies/locale defaults
- Friendly YFRateLimitError handler (exit 2, no Rich traceback)
- Validated end-to-end with 33 live tests against Yahoo from a
  residential exit (AAPL, MSFT, NVDA, options, screens, calendars,
  financials, news, holders, parquet round-trip)

yfinance-tools.yaml is the single source of truth:
- tools (49)       — every public yfinance entry point with parameters
- cli (13 groups)  — exact CLI invocations, mapped back to tool names
- outputSchemas (27) — actual return shapes captured from live calls
- learnings (19)   — IP rate-limit, crumb handshake, Typer gotchas,
                     screen-build wrapping rules, upstream stubs, etc.

CLAUDE.md documents the upstream codebase layout for future agents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:36:56 +08:00

322 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Output rendering: --format dispatch + --out writing.
Commands return raw Python values (DataFrame / Series / dict / list / NamedTuple).
This module turns them into bytes / text on the right sink.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any, Optional
import pandas as pd
import yaml
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.tree import Tree
_EXT_MAP = {
".json": "json",
".ndjson": "ndjson",
".jsonl": "ndjson",
".csv": "csv",
".tsv": "tsv",
".parquet": "parquet",
".pq": "parquet",
".yaml": "yaml",
".yml": "yaml",
}
_VALID = {"table", "json", "ndjson", "csv", "tsv", "parquet", "yaml"}
# Module-level console — re-bound by the root app so --no-color/--quiet propagate.
_console: Optional[Console] = None
def get_console() -> Console:
global _console
if _console is None:
_console = Console()
return _console
def set_console(console: Console) -> None:
global _console
_console = console
def resolve_format(fmt: Optional[str], out: Optional[Path]) -> str:
"""Choose final format. Explicit --format wins; otherwise infer from --out; else default."""
if fmt:
if fmt not in _VALID:
raise ValueError(f"Unknown --format {fmt!r}; valid: {sorted(_VALID)}")
return fmt
if out:
return _EXT_MAP.get(out.suffix.lower(), "json")
return "table" if sys.stdout.isatty() else "json"
def render(
value: Any,
fmt: Optional[str] = None,
out: Optional[Path] = None,
pretty: Optional[bool] = None,
columns: Optional[str] = None,
limit: Optional[int] = None,
) -> None:
"""Render `value` to stdout or to `out`. Mutates nothing else."""
if value is None:
get_console().print("[dim](no result)[/dim]" if sys.stdout.isatty() else "")
return
resolved = resolve_format(fmt, out)
# Apply --columns / --limit on tabular values before rendering.
if isinstance(value, pd.DataFrame):
if columns:
cols = [c.strip() for c in columns.split(",")]
keep = [c for c in cols if c in value.columns]
if keep:
value = value[keep]
if limit is not None:
value = value.head(limit)
elif isinstance(value, pd.Series):
if limit is not None:
value = value.head(limit)
elif isinstance(value, list) and limit is not None:
value = value[:limit]
if out:
out = Path(out)
out.parent.mkdir(parents=True, exist_ok=True)
if resolved == "table":
_render_table(value)
elif resolved == "json":
_write_text(_to_json(value, pretty=pretty if pretty is not None else True), out)
elif resolved == "ndjson":
_write_text(_to_ndjson(value), out)
elif resolved == "csv":
_write_text(_to_csv(value, sep=","), out)
elif resolved == "tsv":
_write_text(_to_csv(value, sep="\t"), out)
elif resolved == "parquet":
_write_parquet(value, out)
elif resolved == "yaml":
_write_text(_to_yaml(value), out)
else:
raise ValueError(f"Unsupported format: {resolved}")
# ---------------------------------------------------------------------------- #
# Format implementations
# ---------------------------------------------------------------------------- #
def _to_json(value: Any, *, pretty: bool) -> str:
indent = 2 if pretty else None
if isinstance(value, pd.DataFrame):
# orient='split' keeps index + columns + data sections, round-trippable.
return value.to_json(orient="split", date_format="iso", indent=indent or 0)
if isinstance(value, pd.Series):
return value.to_json(orient="split", date_format="iso", indent=indent or 0)
if hasattr(value, "_asdict"): # NamedTuple
value = {k: _coerce(v) for k, v in value._asdict().items()}
return json.dumps(_coerce(value), default=str, indent=indent, ensure_ascii=False)
def _to_ndjson(value: Any) -> str:
if isinstance(value, pd.DataFrame):
return value.to_json(orient="records", lines=True, date_format="iso")
if isinstance(value, pd.Series):
return value.to_json(orient="records", lines=True, date_format="iso")
if isinstance(value, list):
return "\n".join(json.dumps(_coerce(x), default=str, ensure_ascii=False) for x in value)
return json.dumps(_coerce(value), default=str, ensure_ascii=False)
def _to_csv(value: Any, sep: str) -> str:
if isinstance(value, pd.DataFrame):
return value.to_csv(sep=sep)
if isinstance(value, pd.Series):
return value.to_csv(sep=sep, header=True)
if isinstance(value, list) and value and isinstance(value[0], dict):
return pd.DataFrame(value).to_csv(sep=sep, index=False)
raise TypeError(f"--format csv/tsv only supports DataFrames / Series / list[dict] (got {type(value).__name__})")
def _write_parquet(value: Any, out: Optional[Path]) -> None:
if not isinstance(value, pd.DataFrame):
if isinstance(value, pd.Series):
value = value.to_frame()
elif isinstance(value, list) and value and isinstance(value[0], dict):
value = pd.DataFrame(value)
else:
raise TypeError("--format parquet only supports DataFrames / Series / list[dict]")
if out is None:
raise ValueError("--format parquet requires --out")
value.to_parquet(out)
get_console().print(f"[green]wrote[/green] {out} ({len(value):,} rows × {len(value.columns)} cols)", highlight=False)
def _to_yaml(value: Any) -> str:
if isinstance(value, pd.DataFrame):
value = value.reset_index().to_dict(orient="records")
elif isinstance(value, pd.Series):
value = value.to_dict()
return yaml.safe_dump(_coerce(value), sort_keys=False, allow_unicode=True)
def _write_text(text: str, out: Optional[Path]) -> None:
if out:
out.write_text(text)
get_console().print(f"[green]wrote[/green] {out} ({len(text):,} bytes)", highlight=False)
else:
# Use plain print so piping isn't corrupted by Rich markup.
print(text)
# ---------------------------------------------------------------------------- #
# Rich table rendering
# ---------------------------------------------------------------------------- #
def _render_table(value: Any) -> None:
c = get_console()
if isinstance(value, pd.DataFrame):
c.print(_dataframe_to_table(value))
elif isinstance(value, pd.Series):
c.print(_series_to_table(value))
elif hasattr(value, "_asdict"): # NamedTuple (e.g. option_chain)
for name, sub in value._asdict().items():
c.print(Panel(_render_any_to_renderable(sub), title=str(name)))
elif isinstance(value, dict):
c.print(_dict_to_table(value))
elif isinstance(value, list):
c.print(_list_to_renderable(value))
else:
c.print(value)
def _render_any_to_renderable(value: Any):
if isinstance(value, pd.DataFrame):
return _dataframe_to_table(value)
if isinstance(value, pd.Series):
return _series_to_table(value)
if isinstance(value, dict):
return _dict_to_table(value)
if isinstance(value, list):
return _list_to_renderable(value)
return str(value)
def _dataframe_to_table(df: pd.DataFrame, max_rows: int = 200) -> Table:
t = Table(show_header=True, header_style="bold cyan", row_styles=["", "dim"])
index_name = df.index.name or "index"
t.add_column(str(index_name), style="bold")
for col in df.columns:
t.add_column(str(col))
n = min(len(df), max_rows)
for idx, row in df.head(n).iterrows():
t.add_row(_fmt(idx), *[_fmt(v) for v in row.values])
if len(df) > n:
t.caption = f"[dim]… {len(df) - n:,} more rows omitted[/dim]"
return t
def _series_to_table(s: pd.Series) -> Table:
t = Table(show_header=True, header_style="bold cyan")
t.add_column(str(s.index.name or "index"), style="bold")
t.add_column(str(s.name or "value"))
for idx, val in s.items():
t.add_row(_fmt(idx), _fmt(val))
return t
def _dict_to_table(d: dict) -> Any:
# If values are themselves complex, fall back to a Tree for readability.
if any(isinstance(v, (dict, list, pd.DataFrame, pd.Series)) for v in d.values()):
tree = Tree("[bold cyan]dict[/bold cyan]")
_dict_to_tree(d, tree)
return tree
t = Table(show_header=True, header_style="bold cyan")
t.add_column("key", style="bold")
t.add_column("value")
for k, v in d.items():
t.add_row(str(k), _fmt(v))
return t
def _dict_to_tree(d: dict, parent: Tree) -> None:
for k, v in d.items():
if isinstance(v, dict):
sub = parent.add(f"[bold]{k}[/bold]")
_dict_to_tree(v, sub)
elif isinstance(v, pd.DataFrame):
parent.add(f"[bold]{k}[/bold] [dim](DataFrame {v.shape[0]}×{v.shape[1]})[/dim]")
elif isinstance(v, list):
sub = parent.add(f"[bold]{k}[/bold] [dim](list × {len(v)})[/dim]")
for i, item in enumerate(v[:10]):
sub.add(_fmt(item))
if len(v) > 10:
sub.add(f"[dim]… {len(v) - 10} more[/dim]")
else:
parent.add(f"[bold]{k}[/bold]: {_fmt(v)}")
def _list_to_renderable(lst: list) -> Any:
if not lst:
return "[dim](empty list)[/dim]"
if all(isinstance(x, dict) for x in lst):
try:
return _dataframe_to_table(pd.DataFrame(lst))
except Exception:
pass
# Bullet list
return "\n".join(f"• {_fmt(x)}" for x in lst[:200])
# ---------------------------------------------------------------------------- #
# Coercion / formatting helpers
# ---------------------------------------------------------------------------- #
def _fmt(v: Any) -> str:
if v is None:
return "[dim]—[/dim]"
if isinstance(v, float):
if v != v: # NaN
return "[dim]NaN[/dim]"
if abs(v) >= 1e9:
return f"{v:,.4g}"
if abs(v) >= 1:
return f"{v:,.4f}".rstrip("0").rstrip(".")
return f"{v:.6g}"
if isinstance(v, int):
return f"{v:,}"
if isinstance(v, pd.Timestamp):
return v.isoformat()
s = str(v)
if len(s) > 200:
s = s[:197] + "…"
return s
def _coerce(value: Any) -> Any:
"""Recursively convert pandas / numpy / Timestamp into JSON-safe Python."""
if isinstance(value, pd.DataFrame):
return json.loads(value.to_json(orient="split", date_format="iso"))
if isinstance(value, pd.Series):
return json.loads(value.to_json(orient="split", date_format="iso"))
if isinstance(value, pd.Timestamp):
return value.isoformat()
if isinstance(value, dict):
return {str(k): _coerce(v) for k, v in value.items()}
if isinstance(value, (list, tuple, set)):
return [_coerce(x) for x in value]
if hasattr(value, "item"): # numpy scalar
try:
return value.item()
except Exception:
return str(value)
return value