"""Unit tests for proxy-statement helpers and the DEF 14A-based fetchers."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from bs4 import BeautifulSoup
from openbb_core.provider.utils.errors import EmptyDataError
from openbb_sec.models.sec_beneficial_ownership import SecBeneficialOwnershipFetcher
from openbb_sec.models.sec_executive_compensation import (
SecExecutiveCompensationFetcher,
)
from openbb_sec.models.sec_management_profiles import SecManagementProfilesFetcher
from openbb_sec.models.sec_pay_versus_performance import (
SecPayVersusPerformanceFetcher,
)
from openbb_sec.utils import proxy_statement as ps
def _ix_tag(html):
"""Parse a single inline-XBRL element and return its tag."""
return BeautifulSoup(html, "html.parser").find(True)
def _filing(filing_date, report_url):
"""Build a stand-in filing row with the attributes resolve reads."""
return SimpleNamespace(filing_date=filing_date, report_url=report_url)
def _patch_filings(rows):
"""Patch SecCompanyFilingsFetcher to return ``rows`` from fetch_data."""
fetcher_cls = MagicMock()
fetcher_cls.return_value.fetch_data = AsyncMock(return_value=rows)
return patch(
"openbb_sec.models.company_filings.SecCompanyFilingsFetcher", fetcher_cls
)
class TestAttr:
"""proxy_statement._attr attribute coercion."""
def test_string_attr(self):
tag = BeautifulSoup('', "html.parser").find(True)
assert ps._attr(tag, "id") == "c1"
def test_list_attr_joined(self):
tag = BeautifulSoup('', "html.parser").find(True)
assert ps._attr(tag, "class") == "a b"
def test_missing_attr(self):
tag = BeautifulSoup("", "html.parser").find(True)
assert ps._attr(tag, "id") == ""
class TestResolveProxyUrl:
"""proxy_statement.resolve_proxy_url branches."""
def test_defaults_to_newest(self):
rows = [
_filing("2024-04-01", "http://new"),
_filing("2023-04-01", "http://old"),
]
with _patch_filings(rows):
url = asyncio.run(ps.resolve_proxy_url("AAPL", None, False))
assert url == "http://new"
def test_calendar_year_match(self):
rows = [
_filing("2024-04-01", "http://new"),
_filing("2023-04-01", "http://old"),
]
with _patch_filings(rows):
url = asyncio.run(ps.resolve_proxy_url("AAPL", 2023, False))
assert url == "http://old"
def test_calendar_year_no_match_falls_back(self):
rows = [_filing("2024-04-01", "http://new")]
with _patch_filings(rows):
url = asyncio.run(ps.resolve_proxy_url("AAPL", 2010, False))
assert url == "http://new"
def test_no_rows_returns_none(self):
rows = [_filing("2024-04-01", None), _filing("2023-04-01", "")]
with _patch_filings(rows):
url = asyncio.run(ps.resolve_proxy_url("AAPL", None, False))
assert url is None
def test_falls_back_to_foreign_when_no_proxy(self):
rows = [
SimpleNamespace(
filing_date="2024-03-01",
report_url="http://foreign",
report_type="20-F",
)
]
with _patch_filings(rows):
url = asyncio.run(ps.resolve_proxy_url("CNEY", None, False))
assert url == "http://foreign"
def test_unknown_report_type_returns_none(self):
rows = [
SimpleNamespace(
filing_date="2024-03-01",
report_url="http://other",
report_type="8-K",
)
]
with _patch_filings(rows):
url = asyncio.run(ps.resolve_proxy_url("AAPL", None, False))
assert url is None
class TestTableMarkdown:
"""The keyword-predicate table extractors."""
def test_summary_compensation_table(self):
html = (
"
| Name and Principal Position | Year | "
"Salary | Stock Awards | Total |
"
"| CEO | 2024 | 1 | 2 | 3 |
"
)
out = ps.summary_compensation_table(html)
assert "CEO" in out
def test_beneficial_owners_table(self):
html = (
"| Name and Address of Beneficial Owner | "
"Percent of Class |
"
"| Fund X | 5% |
"
)
out = ps.beneficial_owners_table(html)
assert "Fund X" in out
def test_beneficial_owners_table_foreign_style(self):
html = (
"| Directors and Executive Officers (1): | "
"Percentage of Beneficial Ownership |
"
"| All directors and executive officers as a group | 11.2 |
"
"| 5% Shareholders: | Global Clean Energy Limited |
"
"| Percentage of Aggregate Voting Power | 86.4 |
"
)
out = ps.beneficial_owners_table(html)
assert "Global Clean Energy Limited" in out
def test_beneficial_owners_table_from_share_ownership_section(self):
html = (
"E. Share Ownership
"
"text
"
"| Directors and Executive Officers | Voting Power |
"
"| 5% Shareholders: | Global Clean Energy Limited |
"
"| * Represents beneficial ownership. |
"
"F. Related Party Transactions
"
)
out = ps.beneficial_owners_table(html)
assert "Global Clean Energy Limited" in out
assert "Represents beneficial ownership" in out
def test_management_profiles_table(self):
html = (
"| Directors and Executive Officers as a Group | "
"10% |
"
)
out = ps.management_profiles_table(html)
assert "10%" in out
def test_management_information_from_proxy(self):
html = (
"| Nominee and Principal Occupation | Independent | Age |
"
"| Jane Doe | Yes | 62 |
"
)
out = ps.management_information_from_proxy(html)
assert "Jane Doe" in out
def test_management_information_from_proxy_uses_fallback_table(self):
html = (
"| Name | Age | Position |
"
"| John Doe | 51 | Director |
"
)
out = ps.management_information_from_proxy(html)
assert "John Doe" in out
def test_management_information_from_proxy_no_section_returns_empty(self):
html = ""
out = ps.management_information_from_proxy(html)
assert out == ""
def test_management_information_from_proxy_empty_table_skipped(self):
html = (
""
"| Name | Age | Position |
"
"| John Doe | 51 | Director |
"
)
out = ps.management_information_from_proxy(html)
assert "John Doe" in out
def test_management_information_from_proxy_returns_empty_when_converter_empty(self):
html = ""
with patch("openbb_sec.utils.html2markdown.html_to_markdown", return_value=""):
out = ps.management_information_from_proxy(html)
assert out == ""
def test_management_information_from_proxy_skips_toc_and_uses_nominee_section(self):
html = (
"| Notice of Annual Meeting of Shareholders | 3 |
"
"| Proxy Statement Summary | 7 |
"
"| Corporate Governance | 13 |
"
"| Board Meetings and Attendance | 20 |
"
"| Related Party Policy and Transactions | 20 |
"
"| Shareholder Engagement | 9 |
"
"Nominees to Apple's Board of Directors
"
"Jane Doe has served as director since 2020.
"
)
out = ps.management_information_from_proxy(html)
assert "Nominees to Apple's Board of Directors" in out
assert "Proxy Statement Summary" not in out
def test_management_information_from_proxy_returns_full_section_not_single_table(
self,
):
html = (
"Nominees to Apple's Board of Directors
"
"Director profiles appear below.
"
"| Name | Age | Position |
"
"| Jane Doe | 62 | Director |
"
"Additional narrative about committee service.
"
"Executive Compensation
"
"Comp section starts here.
"
)
out = ps.management_information_from_proxy(html)
assert "Nominees to Apple's Board of Directors" in out
assert "Director profiles appear below." in out
assert "Jane Doe" in out
assert "Additional narrative about committee service." in out
assert "Comp section starts here." not in out
def test_management_information_from_proxy_skips_front_matter_and_toc(self):
html = (
"UNITED STATES SECURITIES AND EXCHANGE COMMISSION
"
"SCHEDULE 14A
"
"| Notice of 2026 Annual Meeting of Shareholders | 3 |
"
"| Proxy Statement Summary | 7 |
"
"| Nominees to Apple's Board of Directors | 10 |
"
"Nominees to Apple's Board of Directors
"
"Jane Doe has served as director since 2020.
"
"John Doe is chief executive officer.
"
"Executive Compensation
"
"Comp section starts here.
"
)
out = ps.management_information_from_proxy(html)
assert "UNITED STATES SECURITIES" not in out
assert "Proxy Statement Summary" not in out
assert "Nominees to Apple's Board of Directors" in out
assert "Jane Doe has served as director since 2020." in out
assert "Comp section starts here." not in out
def test_management_information_from_proxy_ignores_intro_paragraph_mentions(self):
html = (
"In the Proxy Statement, references to our directors and executive officers are informational only.
"
"Nominees to Apple's Board of Directors
"
"Jane Doe has served as director since 2020.
"
"Executive Compensation
"
"Comp section starts here.
"
)
out = ps.management_information_from_proxy(html)
assert "references to our directors and executive officers" not in out
assert "Nominees to Apple's Board of Directors" in out
assert "Jane Doe has served as director since 2020." in out
def test_management_information_from_proxy_uses_toc_boundaries(self):
html = (
""
"| Proxy Statement Summary | 7 |
"
"| Nominees for Election as Directors | 10 |
"
"| Executive Compensation | 20 |
"
"
"
"In the Proxy Statement, references to our directors and executive officers are informational only.
"
"Nominees for Election as Directors
"
"Jane Doe biography.
"
"Executive Compensation
"
"Compensation content should not be included.
"
)
out = ps.management_information_from_proxy(html)
assert "Nominees for Election as Directors" in out
assert "Jane Doe biography." in out
assert "Compensation content should not be included." not in out
assert "references to our directors and executive officers" not in out
def test_management_information_from_proxy_toc_exact_heading_match(self):
html = (
""
"| Executive Officers | 33 |
"
"| Executive Compensation | 35 |
"
"
"
"In the Proxy Statement, references to our directors and executive officers are informational only.
"
"Executive Officers
"
"Tim Cook serves as chief executive officer.
"
"Executive Compensation
"
"Compensation content should not be included.
"
)
out = ps.management_information_from_proxy(html)
assert "references to our directors and executive officers" not in out
assert "Executive Officers" in out
assert "Tim Cook serves as chief executive officer." in out
assert "Compensation content should not be included." not in out
def test_management_information_from_proxy_anchor_start_missing_on_second_lookup(
self,
):
html = (
""
"Directors
"
"Jane Doe biography.
"
"Executive Compensation
"
)
real_soup = BeautifulSoup(html, "html.parser")
original_find = real_soup.find
state = {"dir_calls": 0}
def flaky_find(*args, **kwargs):
if kwargs.get("id") == "dir":
state["dir_calls"] += 1
if state["dir_calls"] == 1:
return object()
return None
return original_find(*args, **kwargs)
with (
patch("bs4.BeautifulSoup", return_value=real_soup),
patch(
"openbb_sec.utils.html2markdown.html_to_markdown",
return_value="",
),
patch.object(real_soup, "find", side_effect=flaky_find),
):
out = ps.management_information_from_proxy(html)
assert out == ""
def test_management_information_from_proxy_anchor_toc_boundaries(self):
html = (
""
"Proxy Statement Summary
"
"Directors
"
"Jane Doe biography.
"
"Executive Compensation
"
"Compensation content should not be included.
"
)
out = ps.management_information_from_proxy(html)
assert "Directors" in out
assert "Jane Doe biography." in out
assert "Compensation content should not be included." not in out
def test_management_information_from_proxy_section_markdown_prefers_exact_title(
self,
):
html = (
""
"| Executive Officers | 33 |
"
"| Executive Compensation | 35 |
"
"
"
"Executive Officers
"
"Officer biography.
"
"Executive Compensation
"
"Compensation content should not be included.
"
)
with patch(
"openbb_sec.utils.html2markdown.html_to_markdown",
side_effect=[
"Completely unrelated preprocessed markdown.",
"Executive Officers\nOfficer biography.",
],
):
out = ps.management_information_from_proxy(html)
assert "Executive Officers" in out
assert "Officer biography." in out
def test_management_information_from_proxy_section_markdown_heading_fallback(self):
html = (
""
"| Proxy Statement Summary | 7 |
"
"
"
"Nominees for Election as Directors
"
"Jane Doe biography.
"
"Executive Compensation
"
"Compensation content should not be included.
"
)
with patch(
"openbb_sec.utils.html2markdown.html_to_markdown",
side_effect=[
"Completely unrelated preprocessed markdown.",
"Nominees for Election as Directors\nJane Doe biography.",
],
):
out = ps.management_information_from_proxy(html)
assert "Nominees for Election as Directors" in out
assert "Jane Doe biography." in out
def test_management_information_from_proxy_skips_toc_like_table_in_preferred_scan(
self,
):
html = (
""
"| Proxy Statement Summary | 7 |
"
"| Corporate Governance | 13 |
"
"| Directors | 23 |
"
"| Executive Compensation | 35 |
"
"| Shareholder Proposals | 70 |
"
"| Other Information | 80 |
"
"
"
""
"| Name | Director Since | Age |
"
"| Jane Doe | 2020 | 62 |
"
"
"
)
out = ps.management_information_from_proxy(html)
assert "Jane Doe" in out
def test_management_information_from_proxy_markdown_path_covers_toc_row_filters(
self,
):
html = (
""
"| Only One Cell |
"
" | 10 |
"
"| Short | 11 |
"
"| Nominees for Election as Directors | 12 |
"
"
"
)
long_heading = "A" * 141
with patch(
"openbb_sec.utils.html2markdown.html_to_markdown",
return_value=(
"Nominees for Election as Directors | 12 |\n"
f"{long_heading}\n"
"Nominees for Election as Directors, committee update\n"
"Nominees for Election as Directors Jane Doe biography.\n"
"Executive Compensation"
),
):
out = ps.management_information_from_proxy(html)
assert "Jane Doe biography." in out
def test_no_match_returns_empty(self):
html = ""
assert ps.beneficial_owners_table(html) == ""
def test_table_from_section_returns_first_table_when_no_predicate(self):
html = (
"Item 10. Directors
"
""
"Item 11. Executive Compensation
"
)
out = ps._table_from_section(html, r"item\s+10")
assert "A" in out
def test_table_from_section_returns_matching_predicate(self):
html = (
"Item 10. Directors
"
""
""
"Item 11. Executive Compensation
"
)
out = ps._table_from_section(
html,
r"item\s+10",
table_predicate=lambda t: "target table" in t,
)
assert "target table" in out
def test_table_from_section_returns_fallback_first_table(self):
html = (
"Item 10. Directors
"
""
""
"Item 11. Executive Compensation
"
)
out = ps._table_from_section(
html,
r"item\s+10",
table_predicate=lambda t: "never present" in t,
)
assert "first table" in out
def test_table_from_section_skips_node_without_parent(self):
fake_soup = MagicMock()
fake_soup.find_all.return_value = [SimpleNamespace(parent=None)]
with patch("bs4.BeautifulSoup", return_value=fake_soup):
out = ps._table_from_section("", r"item\s+10")
assert out == ""
def test_ownership_section_with_no_table_returns_empty(self):
html = "E. Share Ownership
F. Related Party Transactions
"
out = ps._ownership_table_from_share_section(html)
assert out == ""
def test_ownership_section_skips_non_main_then_uses_main_table(self):
html = (
"E. Share Ownership
"
""
"| 5% Shareholders | Voting Power |
"
"| Fund X | 7% |
"
"F. Related Party Transactions
"
)
out = ps._ownership_table_from_share_section(html)
assert "Fund X" in out
def test_ownership_section_skips_node_without_parent(self):
fake_soup = MagicMock()
fake_soup.find_all.return_value = [SimpleNamespace(parent=None)]
with patch("bs4.BeautifulSoup", return_value=fake_soup):
out = ps._ownership_table_from_share_section("")
assert out == ""
class TestIxNumber:
"""proxy_statement._ix_number parsing branches."""
def test_scale_and_sign(self):
assert ps._ix_number(_ix_tag('1,234.5')) == -1234500.0
def test_plain_currency(self):
assert ps._ix_number(_ix_tag("$1,000")) == 1000.0
def test_emdash_is_none(self):
assert ps._ix_number(_ix_tag("—")) is None
def test_na_is_none(self):
assert ps._ix_number(_ix_tag("N/A")) is None
def test_non_numeric_is_none(self):
assert ps._ix_number(_ix_tag("abc")) is None
class TestPayVersusPerformance:
"""proxy_statement.pay_versus_performance XBRL parsing."""
def test_parses_facts_by_year(self):
html = (
''
"2024-12-31"
""
''
"100"
'5'
'Revenue'
)
rows = ps.pay_versus_performance(html)
assert rows == [
{
"year": 2024,
"net_income": 100000000.0,
"peo_total_compensation": 5.0,
"company_selected_measure_name": "Revenue",
}
]
def test_dimensioned_and_periodless_contexts_skipped(self):
html = (
''
"x"
""
''
'9'
""
)
assert ps.pay_versus_performance(html) == []
def test_non_digit_date_ignored(self):
html = (
''
"n/a"
""
'1'
)
assert ps.pay_versus_performance(html) == []
def test_net_income_only_is_not_pvp(self):
html = (
''
"2024-12-31"
""
'100'
)
assert ps.pay_versus_performance(html) == []
_CONTENT_MODELS = [
(SecBeneficialOwnershipFetcher, "beneficial_owners_table"),
(SecExecutiveCompensationFetcher, "summary_compensation_table"),
]
def _adownload(value):
"""Patch Filing._adownload_file to return ``value``."""
return patch(
"openbb_sec.models.sec_filing.Filing._adownload_file",
new=AsyncMock(return_value=value),
)
@pytest.mark.parametrize("fetcher,extractor", _CONTENT_MODELS)
class TestContentFetchers:
"""The three markdown-table DEF 14A fetchers (shared structure)."""
def test_query_validators(self, fetcher, extractor):
q = fetcher.transform_query({"symbol": "aapl", "calendar_year": ""})
assert q.symbol == "AAPL"
assert q.calendar_year is None
def test_success_and_transform(self, fetcher, extractor):
q = fetcher.transform_query({"symbol": "AAPL"})
with (
patch.object(
ps, "resolve_proxy_url", new=AsyncMock(return_value="http://u")
),
patch.object(ps, extractor, return_value="| table |"),
_adownload("