|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | 5 | from dataclasses import dataclass, field |
| 6 | +from html import escape as _html_escape |
| 7 | + |
| 8 | + |
| 9 | +_TABLE_STYLE = ( |
| 10 | + "border-collapse:collapse;font-family:monospace;font-size:13px;" |
| 11 | +) |
| 12 | +_TH_STYLE = ( |
| 13 | + "text-align:left;padding:4px 10px;border:1px solid #ccc;" |
| 14 | + "background:#f4f4f4;font-weight:600;" |
| 15 | +) |
| 16 | +_TD_STYLE = "text-align:left;padding:4px 10px;border:1px solid #ccc;" |
| 17 | +_CAPTION_STYLE = ( |
| 18 | + "caption-side:top;text-align:left;font-weight:700;" |
| 19 | + "font-size:14px;padding-bottom:4px;" |
| 20 | +) |
| 21 | + |
| 22 | + |
| 23 | +def _html_kv_table(pairs: list[tuple[str, str]], caption: str = "") -> str: |
| 24 | + """Build an HTML key-value table (two columns: Field / Value).""" |
| 25 | + rows = "".join( |
| 26 | + f"<tr><th style=\"{_TH_STYLE}\">{_html_escape(str(k))}</th>" |
| 27 | + f"<td style=\"{_TD_STYLE}\">{v}</td></tr>" |
| 28 | + for k, v in pairs |
| 29 | + ) |
| 30 | + cap = f"<caption style=\"{_CAPTION_STYLE}\">{_html_escape(caption)}</caption>" if caption else "" |
| 31 | + return f"<table style=\"{_TABLE_STYLE}\">{cap}{rows}</table>" |
| 32 | + |
| 33 | + |
| 34 | +def _html_row_table( |
| 35 | + headers: list[str], |
| 36 | + rows: list[list[str]], |
| 37 | + caption: str = "", |
| 38 | +) -> str: |
| 39 | + """Build an HTML table with column headers and multiple data rows.""" |
| 40 | + hdr = "".join(f"<th style=\"{_TH_STYLE}\">{_html_escape(h)}</th>" for h in headers) |
| 41 | + body = "" |
| 42 | + for row in rows: |
| 43 | + cells = "".join(f"<td style=\"{_TD_STYLE}\">{v}</td>" for v in row) |
| 44 | + body += f"<tr>{cells}</tr>" |
| 45 | + cap = f"<caption style=\"{_CAPTION_STYLE}\">{_html_escape(caption)}</caption>" if caption else "" |
| 46 | + return f"<table style=\"{_TABLE_STYLE}\">{cap}<tr>{hdr}</tr>{body}</table>" |
| 47 | + |
| 48 | + |
| 49 | +def _esc(value) -> str: |
| 50 | + """Escape a value for safe HTML display.""" |
| 51 | + return _html_escape(str(value)) |
6 | 52 |
|
7 | 53 |
|
8 | 54 | def _require_pandas(): |
@@ -73,6 +119,23 @@ def accession_number(self) -> str: |
73 | 119 | def __repr__(self) -> str: |
74 | 120 | return f"<Filing form={self.form_type!r} date={self.filing_date[:10]!r} title={self.title!r}>" |
75 | 121 |
|
| 122 | + def _repr_html_(self) -> str: |
| 123 | + url_cell = ( |
| 124 | + f"<a href=\"{_esc(self.url)}\">{_esc(self.url)}</a>" |
| 125 | + if self.url else "" |
| 126 | + ) |
| 127 | + return _html_kv_table( |
| 128 | + [ |
| 129 | + ("Form Type", _esc(self.form_type)), |
| 130 | + ("Filing Date", _esc(self.filing_date[:10])), |
| 131 | + ("Accession #", _esc(self.accession_number)), |
| 132 | + ("Title", _esc(self.title)), |
| 133 | + ("Summary", _esc(self.summary)), |
| 134 | + ("URL", url_cell), |
| 135 | + ], |
| 136 | + caption="Filing", |
| 137 | + ) |
| 138 | + |
76 | 139 |
|
77 | 140 | @dataclass(frozen=True) |
78 | 141 | class CompanyInfo: |
@@ -166,6 +229,39 @@ def __repr__(self) -> str: |
166 | 229 | f"<CompanyInfo name={self.name!r} cik={self.cik!r} tickers={ticker_str!r}>" |
167 | 230 | ) |
168 | 231 |
|
| 232 | + def _repr_html_(self) -> str: |
| 233 | + ticker_str = ", ".join(self.tickers) if self.tickers else "—" |
| 234 | + exchange_str = ", ".join(self.exchanges) if self.exchanges else "—" |
| 235 | + info = _html_kv_table( |
| 236 | + [ |
| 237 | + ("Name", _esc(self.name)), |
| 238 | + ("CIK", _esc(self.cik)), |
| 239 | + ("Entity Type", _esc(self.entity_type)), |
| 240 | + ("SIC", f"{_esc(self.sic)} — {_esc(self.sic_description)}"), |
| 241 | + ("Tickers", _esc(ticker_str)), |
| 242 | + ("Exchanges", _esc(exchange_str)), |
| 243 | + ("Fiscal Year End", _esc(self.fiscal_year_end)), |
| 244 | + ], |
| 245 | + caption="Company Info", |
| 246 | + ) |
| 247 | + subs = self.recent_submissions[:10] |
| 248 | + if subs: |
| 249 | + rows = [ |
| 250 | + [ |
| 251 | + _esc(s.form), |
| 252 | + _esc(s.filing_date), |
| 253 | + _esc(s.accession_number), |
| 254 | + _esc(s.primary_doc_description), |
| 255 | + ] |
| 256 | + for s in subs |
| 257 | + ] |
| 258 | + info += _html_row_table( |
| 259 | + ["Form", "Filing Date", "Accession #", "Description"], |
| 260 | + rows, |
| 261 | + caption=f"Recent Filings (showing {len(subs)})", |
| 262 | + ) |
| 263 | + return info |
| 264 | + |
169 | 265 |
|
170 | 266 | @dataclass(frozen=True) |
171 | 267 | class Submission: |
@@ -234,6 +330,22 @@ def size(self) -> int: |
234 | 330 | def __repr__(self) -> str: |
235 | 331 | return f"<Submission form={self.form!r} date={self.filing_date!r} accession={self.accession_number!r}>" |
236 | 332 |
|
| 333 | + def _repr_html_(self) -> str: |
| 334 | + return _html_kv_table( |
| 335 | + [ |
| 336 | + ("Form", _esc(self.form)), |
| 337 | + ("Filing Date", _esc(self.filing_date)), |
| 338 | + ("Report Date", _esc(self.report_date)), |
| 339 | + ("Accession #", _esc(self.accession_number)), |
| 340 | + ("Primary Document", _esc(self.primary_document)), |
| 341 | + ("Description", _esc(self.primary_doc_description)), |
| 342 | + ("XBRL", "Yes" if self.is_xbrl else "No"), |
| 343 | + ("Inline XBRL", "Yes" if self.is_inline_xbrl else "No"), |
| 344 | + ("Size", f"{self.size:,} bytes" if self.size else "—"), |
| 345 | + ], |
| 346 | + caption="Submission", |
| 347 | + ) |
| 348 | + |
237 | 349 |
|
238 | 350 | @dataclass(frozen=True) |
239 | 351 | class Fact: |
@@ -303,6 +415,22 @@ def __repr__(self) -> str: |
303 | 415 | f" form={self.form!r} fy={self.fiscal_year}>" |
304 | 416 | ) |
305 | 417 |
|
| 418 | + def _repr_html_(self) -> str: |
| 419 | + val = f"{self.value:,}" if isinstance(self.value, (int, float)) else _esc(self.value) |
| 420 | + return _html_kv_table( |
| 421 | + [ |
| 422 | + ("End", _esc(self.end)), |
| 423 | + ("Start", _esc(self.start)), |
| 424 | + ("Value", val), |
| 425 | + ("Form", _esc(self.form)), |
| 426 | + ("Filed", _esc(self.filed)), |
| 427 | + ("Fiscal Year", _esc(self.fiscal_year)), |
| 428 | + ("Fiscal Period", _esc(self.fiscal_period)), |
| 429 | + ("Frame", _esc(self.frame)), |
| 430 | + ], |
| 431 | + caption="Fact", |
| 432 | + ) |
| 433 | + |
306 | 434 |
|
307 | 435 | @dataclass(frozen=True) |
308 | 436 | class Facts: |
@@ -512,6 +640,27 @@ def __repr__(self) -> str: |
512 | 640 | f" taxonomies={tax_count} concepts={total}>" |
513 | 641 | ) |
514 | 642 |
|
| 643 | + def _repr_html_(self) -> str: |
| 644 | + info = _html_kv_table( |
| 645 | + [ |
| 646 | + ("Entity", _esc(self.entity_name)), |
| 647 | + ("CIK", _esc(self.cik)), |
| 648 | + ("Taxonomies", _esc(", ".join(self.taxonomies))), |
| 649 | + ], |
| 650 | + caption="Facts", |
| 651 | + ) |
| 652 | + rows = [ |
| 653 | + [_esc(t), str(len(self.concepts(t)))] |
| 654 | + for t in self.taxonomies |
| 655 | + ] |
| 656 | + if rows: |
| 657 | + info += _html_row_table( |
| 658 | + ["Taxonomy", "Concepts"], |
| 659 | + rows, |
| 660 | + caption="Taxonomy Summary", |
| 661 | + ) |
| 662 | + return info |
| 663 | + |
515 | 664 |
|
516 | 665 | @dataclass(frozen=True) |
517 | 666 | class SearchResult: |
@@ -604,6 +753,25 @@ def __repr__(self) -> str: |
604 | 753 | f" company={self.company_name!r}>" |
605 | 754 | ) |
606 | 755 |
|
| 756 | + def _repr_html_(self) -> str: |
| 757 | + url_cell = ( |
| 758 | + f"<a href=\"{_esc(self.url)}\">{_esc(self.url)}</a>" |
| 759 | + if self.url else "" |
| 760 | + ) |
| 761 | + return _html_kv_table( |
| 762 | + [ |
| 763 | + ("Company", _esc(self.company_name)), |
| 764 | + ("CIK", _esc(self.cik)), |
| 765 | + ("Form", _esc(self.form)), |
| 766 | + ("Filing Date", _esc(self.filing_date)), |
| 767 | + ("Accession #", _esc(self.accession_number)), |
| 768 | + ("File Type", _esc(self.file_type)), |
| 769 | + ("Period Ending", _esc(self.period_ending)), |
| 770 | + ("URL", url_cell), |
| 771 | + ], |
| 772 | + caption="Search Result", |
| 773 | + ) |
| 774 | + |
607 | 775 |
|
608 | 776 | def to_dataframe(items: list): |
609 | 777 | """Convert a list of model objects to a pandas DataFrame. |
|
0 commit comments