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
1 change: 1 addition & 0 deletions changelog.d/8k-signature-statement.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**An 8-K with no SIGNATURES heading no longer folds its signature block into the last item.** When the block opens straight with "Pursuant to the requirements of the Securities Exchange Act of 1934 ... caused this report to be signed", that sentence now ends the item. In a 90-document 8-K sample this cleaned the last item of 3 filings and changed nothing else.
40 changes: 40 additions & 0 deletions edgar/documents/extractors/pattern_section_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@
# officers appear below" is prose, not a boundary. Same test Strategy 5b uses.
_SIGNATURES_HEADER = re.compile(r'^\s*SIGNATURES?\s*$', re.IGNORECASE)

# The sentence Form 8-K's General Instructions prescribe to open the signature
# block, matched on whitespace-normalized paragraph text. Anchored at the start
# and required to reach "caused this report to be signed", so a paragraph that
# merely cites the Exchange Act is not a boundary. Strategy 5c.
_SIGNATURE_STATEMENT = re.compile(
r'^Pursuant to the requirements of the Securities Exchange Act of 1934,?'
r'.{0,80}?caused this (?:report|amendment) to be signed',
re.IGNORECASE,
)

# An item's own SUB-header: an item number, one or more parenthesized
# sub-designations, and nothing else — "Item 14(a)(1):", "Item 14 (a)(2):".
# Filings that itemize under Regulation S-K's lettered sub-paragraphs write
Expand Down Expand Up @@ -974,8 +984,38 @@ def _has_bold_descendant(n) -> bool:
continue
headers.append((node, stripped, position))
existing_positions.add(position)
has_sig_header = True
break # one SIGNATURES header is enough

# Strategy 5c: no SIGNATURES line at all. Some 8-Ks open the signature
# block straight with the statutory sentence ("Pursuant to the
# requirements of the Securities Exchange Act of 1934, the registrant
# has duly caused this report to be signed ..."), so without a
# boundary the block ran on into the last item. The paragraph itself
# becomes the terminal header, labelled SIGNATURES so every later
# stage treats it exactly like a real one. Only after the last Item
# header: the sentence is the signature block's and nothing else's,
# but the cover page carries a similar "Pursuant to Section 13 or
# 15(d)" line, and that must never end an item.
# AmeriServ's 2023-03-20 8-K (0001104659-23-034205) is the case;
# its test passed for months only because hard-wrapped text split
# the phrase it searched for (gh-1370 exposed it).
if not has_sig_header:
from edgar.documents.nodes import ParagraphNode
item_positions = [pos for _, text, pos in headers
if re.match(r'^\s*ITEM\s+\d', text, re.IGNORECASE)]
if item_positions:
last_item = max(item_positions)
for node in document.root.find(lambda n: isinstance(n, ParagraphNode)):
text = ' '.join((node.text() or '').split())
if not _SIGNATURE_STATEMENT.match(text):
continue
position = _node_position(node)
if position <= last_item:
continue
headers.append((node, 'SIGNATURES', position))
break

# Collapse the source's line wrapping, once, after every strategy has
# run. See _normalize_header_text for what it costs to skip this.
#
Expand Down
17 changes: 17 additions & 0 deletions scripts/check_offline_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,20 @@ def parse_report(text: str) -> set[str]:
(SUMMARY_RE.match(line) for line in text.splitlines()) if m}


def nothing_to_audit(text: str) -> bool:
"""True when pytest selected no `fast` test at all, which is a clean result.

A changed file whose tests are all `network`-marked is deselected in full by
`-m fast`: pytest prints "N deselected" and no "passed", exactly like a run
that collected nothing for a worse reason. Telling them apart by the
summary line keeps the "produced no result" guard for collection errors
while letting such a PR through. The first PR to edit only a network test
file (#1393, test_eightK.py) failed the gate here with exit 2.
"""
return (bool(re.search(r"\b\d+ deselected\b", text))
and not re.search(r"\b\d+ (?:passed|failed|errors?)\b", text))


def read_baseline() -> set[str]:
if not BASELINE_PATH.exists():
return set()
Expand Down Expand Up @@ -124,6 +138,9 @@ def main() -> int:

if not failing and not args.report:
# A run that collected nothing also reports no failures. Tell them apart.
if nothing_to_audit(text):
print("OK: no `fast` tests in the audited paths; nothing to audit.")
return 0
if "passed" not in text:
print("Audit produced no result — pytest output was:\n" + text[-2000:], file=sys.stderr)
return 2
Expand Down
13 changes: 10 additions & 3 deletions tests/company_reports/test_eightK.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,16 @@ def test_eightk_with_no_signature_header():
accession_no='0001104659-23-034205')
eightk = filing.obj()
assert eightk.items == ['Item 8.01', 'Item 9.01']
assert "Cover Page" in eightk['Item 9.01']
# We did not include the signature in the item
assert 'Pursuant to the requirements of the Securities Exchange Act' not in eightk['Item 9.01']
item_901 = eightk['Item 9.01']
assert "Cover Page" in item_901
# The signature block is not part of the item. Compared on normalized
# whitespace: this assertion passed for months on raw text only because a
# hard line break split the phrase, while the block sat inside the item all
# along (exposed by gh-1370; fixed by Strategy 5c in the pattern extractor).
words = " ".join(item_901.split())
assert 'Pursuant to the requirements of the Securities Exchange Act' not in words
assert 'Michael D. Lynch' not in words
assert words.endswith("File (embedded within the Inline XBRL document).")

def test_eightk_item502_parsed_correctly():
filing = Filing(form='8-K', filing_date='2023-03-20', company='4Front Ventures Corp.', cik=1783875,
Expand Down
13 changes: 13 additions & 0 deletions tests/meta/test_offline_audit_ratchet.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,16 @@ def test_every_baseline_entry_names_a_test_that_exists(audit):
missing = sorted({node.split("::", 1)[0] for node in audit.read_baseline()
if not (ROOT / node.split("::", 1)[0]).exists()})
assert missing == [], f"baseline names files that no longer exist: {missing}"


@pytest.mark.parametrize("summary,expected", [
# Every test in the file is network-marked: the PR #1393 case.
("21 deselected, 4 warnings in 0.04s", True),
("3 passed, 18 deselected in 1.20s", False),
("1 failed, 2 passed, 18 deselected in 1.20s", False),
# Deselection alongside a collection error is still a broken run.
("1 error, 21 deselected in 0.30s", False),
("no tests ran in 0.01s", False),
])
def test_an_all_network_file_is_nothing_to_audit_not_no_result(audit, summary, expected):
assert audit.nothing_to_audit(summary) is expected
Loading