Skip to content

Commit 8a079d8

Browse files
dgunningclaude
andcommitted
fix(ownership,search): per-transaction footnote attribution + table snippet
Two follow-up fixes from the #862/#863 review. edgartools-t043 — Form 4/5 per-transaction footnote attribution: Footnote references attach to many transaction sub-elements (security title, date, shares, price, post-transaction amounts), but the extractor collected them only from <transactionCoding>, so TransactionActivity. footnote_ids/.footnotes_text came through empty for most filings (e.g. a 10b5-1 disclosure on <securityTitle>). The extractor now gathers footnote IDs from the whole transaction; get_footnotes dedupes (order-preserving) so a footnote referenced twice in one transaction isn't resolved twice. This makes per-transaction is_10b5_1_plan work directly rather than via the filing-wide fallback added in #863. edgartools-fi4h — table-search snippet over-highlight: The semantic table:<term> search set end_offset to the full table length against a context truncated to ~200 chars, so SearchResult.snippet wrapped the entire truncated context in ** ** for any table >200 chars. The match is now located within the table and a correct context window is produced via _get_context_with_offsets, consistent with text/regex search. Adds fast (no-network) regression tests for both using the 374Water fixture and an HTML table; updates the #863 AAPL end-to-end test to assert the now-correct per-transaction attribution. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 461b6cf commit 8a079d8

7 files changed

Lines changed: 176 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
### Fixed
1515

16+
- **Form 4/5 transaction footnotes are now attributed per transaction** — footnote references attach to many transaction sub-elements (security title, transaction date, shares, price, post-transaction amounts), but the extractor previously collected them only from `<transactionCoding>`, so `TransactionActivity.footnote_ids` / `.footnotes_text` came through empty for most filings. The extractor now gathers footnote IDs from the whole transaction (deduplicated), so per-transaction footnote reasoning — including `is_10b5_1_plan` — works directly rather than relying on the filing-wide fallback.
17+
- **Table search snippets highlight the match, not the whole table** — the semantic `table:<term>` search built a result whose end offset was the full table length against a context truncated to ~200 chars, so `SearchResult.snippet` wrapped the entire truncated context in `**…**` for any table longer than 200 characters. The match is now located within the table and a correct context window is produced, consistent with text and regex search.
1618
- **Rule 10b5-1 plan detection now uses the official Form 4/5 checkbox when present** — ownership summaries honor the structured `aff10b5One` value before falling back to footnote text. When the checkbox is absent (e.g. pre-2023 filings), `has_10b5_1_plan` now scans the filing's full footnote set instead of relying solely on per-transaction footnote attribution, which is frequently empty. The fallback matcher no longer confuses the separate anti-fraud Rule 10b-5 with Rule 10b5-1 trading plans while recognizing common spacing and dash variants. ([#863](https://github.com/dgunning/edgartools/issues/863))
1719
- **`edgar` package logger no longer leaks log output in unconfigured applications** — the package-root logger had no `NullHandler`, so when an application had not configured logging itself, edgartools warnings fell back to Python's `logging.lastResort` handler and were written to stderr. This was especially harmful in MCP / stdio environments, where stray stderr output corrupts the protocol stream. A `NullHandler` is now attached to the `edgar` logger per the Python logging HOWTO for libraries, keeping edgartools silent until the application opts into logging. ([#856](https://github.com/dgunning/edgartools/issues/856))
1820
- **BDC data sets download again after SEC URL move** — the SEC relocated the DERA BDC data-set files from `/files/structureddata/data/` to `/files/datastandardsinnovation/data/`, which made `fetch_bdc_dataset()` and related functions fail with 404; the base URL now points to the new location.

edgar/documents/search.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -287,14 +287,23 @@ def _semantic_search(self, query: str) -> List[SearchResult]:
287287
if isinstance(node, TableNode):
288288
# Search in table content
289289
table_text = node.text()
290-
if table_text and search_text.lower() in table_text.lower():
291-
results.append(SearchResult(
292-
node=node,
293-
text=f"Table: {node.caption or 'Untitled'}",
294-
start_offset=0,
295-
end_offset=len(table_text),
296-
context=table_text[:200] + "..." if len(table_text) > 200 else table_text
297-
))
290+
if table_text:
291+
match_pos = table_text.lower().find(search_text.lower())
292+
if match_pos != -1:
293+
# Build a context window around the actual match with
294+
# offsets relative to that window, so snippet highlights
295+
# the match rather than the whole truncated table text
296+
# (edgartools-fi4h).
297+
context, adj_start, adj_end = self._get_context_with_offsets(
298+
table_text, match_pos, match_pos + len(search_text)
299+
)
300+
results.append(SearchResult(
301+
node=node,
302+
text=f"Table: {node.caption or 'Untitled'}",
303+
start_offset=adj_start,
304+
end_offset=adj_end,
305+
context=context
306+
))
298307

299308
elif search_type == 'section':
300309
# Search sections

edgar/ownership/core.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,11 @@ def transaction_footnote_id(tag: Tag) -> Tuple[str, Optional[str]]:
7575

7676

7777
def get_footnotes(tag: Tag) -> str:
78-
footnotes = []
78+
# A single transaction can reference the same footnote id from several
79+
# sub-elements (e.g. securityTitle and shares), so dedupe while preserving
80+
# first-seen order to avoid resolving the same footnote text twice.
81+
footnotes: list[str] = []
82+
seen = set()
7983
for el in tag.find_all("footnoteId"):
8084
if isinstance(el, Tag):
8185
footnote_id = el.attrs.get('id')
@@ -84,7 +88,10 @@ def get_footnotes(tag: Tag) -> str:
8488
if isinstance(footnote_id, list):
8589
footnote_id = footnote_id[0] if footnote_id else None
8690
if footnote_id:
87-
footnotes.append(str(footnote_id))
91+
footnote_id = str(footnote_id)
92+
if footnote_id not in seen:
93+
seen.add(footnote_id)
94+
footnotes.append(footnote_id)
8895
return '\n'.join(footnotes)
8996

9097

edgar/ownership/table_containers.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,10 @@ def extract_transactions(table: Tag) -> NonDerivativeTransactions:
155155
('AcquiredDisposed', child_text(transaction_amt_tag, 'transactionAcquiredDisposedCode')),
156156
('DirectIndirect', child_text(ownership_nature_tag, 'directOrIndirectOwnership')),
157157
('NatureOfOwnership', child_text(ownership_nature_tag, 'natureOfOwnership')),
158+
# Collect footnoteId references from the whole transaction, not just
159+
# <transactionCoding>: footnotes attach to securityTitle, transaction
160+
# date, shares, price, etc. (edgartools-t043).
161+
('footnotes', get_footnotes(transaction_tag)),
158162
]
159163
)
160164
transaction_coding_tag = transaction_tag.find("transactionCoding")
@@ -164,7 +168,6 @@ def extract_transactions(table: Tag) -> NonDerivativeTransactions:
164168
('form', child_text(transaction_coding_tag, 'transactionFormType')),
165169
('Code', child_text(transaction_coding_tag, 'transactionCode')),
166170
('EquitySwap', get_bool(child_text(transaction_coding_tag, 'equitySwapInvolved'))),
167-
('footnotes', get_footnotes(transaction_coding_tag))
168171
]
169172
)
170173
transaction.update(transaction_coding)
@@ -276,6 +279,9 @@ def extract_transactions(table: Tag) -> DerivativeTransactions:
276279
('AcquiredDisposed', child_text(transaction_amt_tag, 'transactionAcquiredDisposedCode')),
277280
('Date', child_value(transaction_tag, 'transactionDate')),
278281
('Remaining', child_text(post_transaction_tag, 'sharesOwnedFollowingTransaction')),
282+
# Collect footnoteId references from the whole transaction, not just
283+
# <transactionCoding> (edgartools-t043).
284+
('footnotes', get_footnotes(transaction_tag)),
279285
]
280286
)
281287

@@ -287,7 +293,6 @@ def extract_transactions(table: Tag) -> DerivativeTransactions:
287293
('form', child_text(transaction_coding_tag, 'transactionFormType')),
288294
('Code', child_text(transaction_coding_tag, 'transactionCode')),
289295
('EquitySwap', get_bool(child_text(transaction_coding_tag, 'equitySwapInvolved'))),
290-
('footnotes', get_footnotes(transaction_coding_tag))
291296
]
292297
)
293298
transaction.update(transaction_coding)

tests/issues/regression/test_issue_863_10b5_plan_detection.py

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,19 @@ def test_aff10b5_one_checkbox_flows_through_xml_parsing_end_to_end():
134134

135135
@pytest.mark.network
136136
@pytest.mark.vcr
137-
def test_problem2_full_footnote_fallback_on_real_pre_checkbox_filing():
137+
def test_problem2_real_pre_checkbox_filing_resolves_via_per_transaction_footnotes():
138138
"""End-to-end on the reported Problem 2 filing: AAPL insider Form 4 (2022).
139139
140140
Pinned by accession (not ``.latest()``) and VCR-backed for determinism. This
141-
is a pre-2023-04 Form 4 that carries NO ``<aff10b5One>`` checkbox, and whose
142-
per-transaction footnote attribution is empty, yet whose filing-level
143-
footnotes describe a Rule 10b5-1 plan. Before the fix, ``has_10b5_1_plan``
144-
returned ``None``; the full-footnote-set fallback now returns ``True``.
141+
is a pre-2023-04 Form 4 that carries NO ``<aff10b5One>`` checkbox; its 10b5-1
142+
footnote is attached to ``<securityTitle>`` (not ``<transactionCoding>``).
143+
144+
Originally (#863 Problem 2) per-transaction footnote attribution came through
145+
empty, so ``has_10b5_1_plan`` returned ``None`` and only the full-footnote
146+
fallback could recover it. After edgartools-t043 fixed the attribution, each
147+
transaction carries its real footnote IDs and per-transaction detection works
148+
directly. (The full-footnote fallback remains a safety net, covered by the
149+
synthetic tests above.)
145150
146151
Reported in https://github.com/dgunning/edgartools/issues/863 (Problem 2).
147152
"""
@@ -151,10 +156,10 @@ def test_problem2_full_footnote_fallback_on_real_pre_checkbox_filing():
151156
filing_date='2022-05-06', accession_no='0000320193-22-000061')
152157
summary = filing.obj().get_ownership_summary()
153158

154-
# No structured checkbox on this pre-2023 filing — the fallback is what fires.
159+
# No structured checkbox on this pre-2023 filing.
155160
assert summary.aff10b5_one is None
156-
# Per-transaction footnote attribution is empty in the summary path...
157-
assert all(not t.footnotes_text for t in summary.transactions)
158-
# ...but the filing-level footnotes mention the plan, so the scan finds it.
159-
assert '10b5-1' in summary.all_footnotes_text
161+
# Per-transaction footnote attribution is now populated (edgartools-t043)...
162+
assert all(t.footnote_ids for t in summary.transactions)
163+
# ...and at least one transaction's own footnotes describe the 10b5-1 plan.
164+
assert any(t.is_10b5_1_plan is True for t in summary.transactions)
160165
assert summary.has_10b5_1_plan is True
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Regression test for edgartools-fi4h: table-search snippet over-highlight.
2+
3+
The semantic ``table:<term>`` search path built a SearchResult with
4+
``start_offset=0`` and ``end_offset=len(table_text)`` against a context
5+
truncated to ~200 chars. ``SearchResult.snippet`` highlights
6+
``context[start_offset:end_offset]``, so for any table longer than 200 chars the
7+
out-of-range end made it wrap the *entire* truncated context in ``** **`` rather
8+
than the matched term.
9+
10+
The fix locates the match within the table text and produces a context window
11+
with offsets relative to that window (same mechanism as text/regex search).
12+
13+
Discovered while reviewing PRs #862 / #860 (the text/regex offset fix), which
14+
did not touch the table path.
15+
"""
16+
17+
from edgar.documents import parse_html
18+
from edgar.documents.nodes import NodeType
19+
from edgar.documents.search import DocumentSearch, SearchMode
20+
21+
22+
def _doc_with_long_table(target: str = "REVENUE"):
23+
"""A table whose text() exceeds 200 chars, with `target` appearing late."""
24+
cells = "".join(
25+
f"<tr><td>filler cell {i} with several words here</td></tr>" for i in range(20)
26+
)
27+
html = (
28+
f"<html><body><table>{cells}"
29+
f"<tr><td>{target} total 12345</td></tr></table></body></html>"
30+
)
31+
return parse_html(html)
32+
33+
34+
def test_table_snippet_highlights_match_not_whole_table():
35+
doc = _doc_with_long_table()
36+
tables = [n for n in doc.root.walk() if n.type == NodeType.TABLE]
37+
assert len(tables[0].text()) > 200 # precondition: context would be truncated
38+
39+
result = DocumentSearch(doc).search("table:REVENUE", mode=SearchMode.SEMANTIC)[0]
40+
41+
# Offsets stay inside the context (the over-highlight bug ran end_offset past it).
42+
assert result.end_offset <= len(result.context)
43+
# The highlighted span is the matched term, not the entire table.
44+
assert result.context[result.start_offset:result.end_offset].lower() == "revenue"
45+
assert "**REVENUE**" in result.snippet
46+
# And the snippet does not wrap the whole context.
47+
assert not result.snippet.startswith("**...")
48+
49+
50+
def test_table_search_no_match_returns_no_result():
51+
doc = _doc_with_long_table()
52+
53+
results = DocumentSearch(doc).search("table:NONEXISTENT", mode=SearchMode.SEMANTIC)
54+
55+
assert results == []
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Regression test for edgartools-t043: per-transaction footnote attribution.
2+
3+
Footnote references in a Form 4/5 transaction attach to many sub-elements
4+
(securityTitle, transactionDate, transactionShares, transactionPricePerShare,
5+
postTransactionAmounts, ...), not only <transactionCoding>. The extractor
6+
previously collected footnoteIds from <transactionCoding> alone, so a footnote
7+
attached elsewhere (e.g. a 10b5-1 disclosure on <securityTitle>) was dropped and
8+
TransactionActivity.footnote_ids / .footnotes_text came through empty.
9+
10+
Follow-up to GitHub #863 (Problem 2): the summary-level has_10b5_1_plan had a
11+
full-footnote fallback, but per-transaction footnote reasoning was still blind.
12+
13+
The 374Water Form 4 fixture is a real filing whose only transaction footnote
14+
(F1) is attached to <securityTitle> with nothing under <transactionCoding> —
15+
exactly the dropped-attribution case — so this runs offline.
16+
"""
17+
18+
from pathlib import Path
19+
20+
import pytest
21+
22+
from edgar.ownership.core import get_footnotes
23+
from edgar.ownership.forms import Form4
24+
25+
FIXTURE = Path('data/ownership/374WaterForm4.xml')
26+
27+
pytestmark = pytest.mark.skipif(not FIXTURE.exists(), reason='fixture not available')
28+
29+
30+
def _summary():
31+
return Form4.parse_xml(FIXTURE.read_text()).get_ownership_summary()
32+
33+
34+
def test_transaction_footnote_ids_are_populated_from_whole_transaction():
35+
"""Each transaction surfaces the footnote attached to <securityTitle>."""
36+
summary = _summary()
37+
38+
assert summary.transactions, "fixture should produce transactions"
39+
# Every transaction in this filing references footnote F1 (vesting terms),
40+
# attached to <securityTitle> — not <transactionCoding>.
41+
for t in summary.transactions:
42+
assert 'F1' in (t.footnote_ids or ''), f"missing F1 on {t.footnote_ids!r}"
43+
assert t.footnotes_text, "footnote text should resolve from the IDs"
44+
assert 'Vesting commencement' in t.footnotes_text
45+
46+
47+
def test_footnote_ids_are_deduped_within_a_transaction():
48+
"""A footnote referenced twice in one transaction is not resolved twice."""
49+
summary = _summary()
50+
51+
for t in summary.transactions:
52+
ids = [i for i in (t.footnote_ids or '').split('\n') if i]
53+
assert len(ids) == len(set(ids)), f"duplicate footnote ids: {ids!r}"
54+
# Resolved text should not repeat the same footnote sentence.
55+
assert t.footnotes_text.count('Vesting commencement date') == 1
56+
57+
58+
def test_get_footnotes_dedupes_preserving_order():
59+
"""Unit check on the shared helper used by table extraction."""
60+
from bs4 import BeautifulSoup
61+
62+
xml = (
63+
"<root>"
64+
"<securityTitle><footnoteId id='F1'/></securityTitle>"
65+
"<transactionShares><footnoteId id='F2'/></transactionShares>"
66+
"<transactionPricePerShare><footnoteId id='F1'/></transactionPricePerShare>"
67+
"</root>"
68+
)
69+
tag = BeautifulSoup(xml, 'xml').find('root')
70+
71+
assert get_footnotes(tag) == 'F1\nF2'

0 commit comments

Comments
 (0)