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/1382.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**`TenK` keeps both items of a combined heading such as "Items 1 and 2. Business and Properties".** A TOC link reading "Items 1 and 2" (or "Items 1. and 2.", "Items 7. and 7A.") was dropped, so `tenk.business` was None on Viper, Devon, Freeport and Cheniere. The section is now keyed as the first item, lists both in `Section.covered_items`, and answers for either. Talos no longer shows a 1,311-character "Properties" fragment as Item 2. (GH #1382, #1383)
5 changes: 5 additions & 0 deletions edgar/ai/skills/reports/skill.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ patterns:

tenk.items # List: ['Item 1', 'Item 1A', 'Item 2', ...]

# Combined headings ("Items 1 and 2. Business and Properties") are ONE section,
# keyed as the first item; both items return it.
tenk['Item 2'] == tenk.business # True when the filer combines Items 1 and 2
tenk.sections['part_i_item_1'].covered_items # ('1', '2'); () for a one-item section

tenk.auditor # AuditorInfo: .name .firm_id .location .icfr_attestation
tenk.subsidiaries # SubsidiaryList from EX-21 (TenK only), .to_dataframe()
tenk.reports # Reports from FilingSummary.xml (statements, notes, details)
Expand Down
19 changes: 16 additions & 3 deletions edgar/company_reports/ten_k.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,8 @@ def _canonical(raw_items):
# Check if section has an item attribute
if hasattr(section, 'item') and section.item:
items.append(f"Item {section.item}")
# "Items 1 and 2" is one section holding both (GH #1382)
items.extend(f"Item {item}" for item in getattr(section, 'covered_items', ()))
# Map friendly names to Item numbers
elif key in section_to_item:
items.append(section_to_item[key])
Expand Down Expand Up @@ -596,9 +598,20 @@ def __getitem__(self, item_or_part: str):
if text and text.strip():
return text

# PRIORITY 1.5: Try combined-items keys (e.g., "Items 1 and 2. Business and Properties")
# Some filings (energy, MLP, REIT) combine items under a single heading.
# Match whether the item is the first or second number: items_1_and_2 or items_2_and_3
# PRIORITY 1.5: A section under a combined heading ("Items 1 and 2.
# Business and Properties") holds both items. Some filings (energy,
# MLP, REIT) combine items this way; the section is keyed as the
# first item and lists both in covered_items (GH #710, #1382).
if canonical_part:
for section in self.sections.values():
covered = [item.lower() for item in getattr(section, 'covered_items', ())]
if item_num in covered and (section.part or '').lower() == canonical_part:
text = section.text()
if text and text.strip():
return text

# Legacy combined-items keys (part_i_items_1_and_2_...) from releases
# that built them from the heading text.
inum = re.escape(item_num)
combined_pattern = re.compile(rf'part_[iv]+_items_(?:{inum}_and_\d+|\d+_and_{inum})')
for key in self.sections:
Expand Down
6 changes: 5 additions & 1 deletion edgar/documents/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import logging
import re
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, cast
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Tuple, cast

if TYPE_CHECKING:
import pandas as pd
Expand Down Expand Up @@ -111,6 +111,9 @@ class Section:
validated: Whether section has been cross-validated
part: Optional part identifier for 10-Q filings ("I", "II", or None for 10-K)
item: Optional item identifier (e.g., "1", "1A", "2")
covered_items: Every item this section holds when the filer combines items
under one heading ("Items 1 and 2. Business and Properties" gives
``('1', '2')``); empty for an ordinary one-item section
_text_extractor: Optional callback for lazy text extraction (for TOC-based sections)
_html_source: Optional HTML source for TOC-based table extraction
_section_extractor: Optional section extractor for TOC-based sections
Expand All @@ -129,6 +132,7 @@ class Section:
_text_extractor: Optional[Any] = field(default=None, repr=False) # Callback for lazy text extraction
_html_source: Optional[str] = field(default=None, repr=False) # HTML source for TOC table extraction
_section_extractor: Optional[Any] = field(default=None, repr=False) # Section extractor for TOC sections
covered_items: Tuple[str, ...] = () # All items under a combined heading, e.g. ('1', '2')

@property
def kind(self) -> str:
Expand Down
12 changes: 10 additions & 2 deletions edgar/documents/extractors/hybrid_section_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,17 @@ def _augment_with_pattern_sections(
# whose key string carries neither part nor item.
canonical = {schema.resolve_section_key(key) for key in schema.section_patterns}
canonical = {(part, item) for part, item in canonical if item}
found_items = {sec.item for sec in toc_sections.values() if sec.item}
# A section under a combined heading ("Items 1 and 2. Business and
# Properties") has found both items. Without this the gate saw Item 2
# as missing and admitted a bare "Properties" sub-heading from inside
# Item 1 as a second, fragmentary Item 2 (Talos, GH #1383).
found_items = {
item for sec in toc_sections.values()
for item in (sec.covered_items or (sec.item,)) if item
}
found_part_items = {
(sec.part, sec.item) for sec in toc_sections.values() if sec.item
(sec.part, item) for sec in toc_sections.values()
for item in (sec.covered_items or (sec.item,)) if item
}
if not canonical or canonical <= found_part_items:
return toc_sections # The TOC named everything the form defines.
Expand Down
34 changes: 33 additions & 1 deletion edgar/documents/extractors/toc_section_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,44 @@
"""

import logging
from typing import Dict, Optional
import re
from typing import Dict, Optional, Tuple

from edgar.documents.document import Document, Section
from edgar.documents.nodes import SectionNode
from edgar.documents.utils.toc_analyzer import TOCAnalyzer

logger = logging.getLogger(__name__)

# The section's heading is near its start but not always first: Viper's TOC link
# lands on a 2,681-character preamble paragraph ahead of its "ITEMS 1 and 2."
# heading. A heading is a short line; prose that happens to open with "Items 1
# and 2" is not.
_HEADING_SCAN_CHARS = 5000
_HEADING_MAX_LEN = 250 # Freeport's "Items 7. and 7A." heading is 170


def _covered_items(item: Optional[str], section_text: Optional[str]) -> Tuple[str, ...]:
"""Both items of a section that opens on a combined "Items N and M" heading.

The heading is the filer saying one span holds two items, so Item 2 of
"Items 1 and 2. Business and Properties" is this section, not something to
look for elsewhere (GH #1382, #1383). It is read from the section's own
opening lines, not the TOC row, because some TOCs split the label and the
link across cells (Talos) and the row's text never names both items.
Only a heading whose first item is this section's item counts.
"""
if not item or not section_text:
return ()
for line in section_text[:_HEADING_SCAN_CHARS].splitlines():
line = re.sub(r'^[\s*#_>|]+', '', line).rstrip()
if not line or len(line) > _HEADING_MAX_LEN:
continue
combined = TOCAnalyzer.combined_item_numbers(line)
if combined and combined[0] == item.upper():
return combined
return ()


class TOCSectionDetector:
"""
Expand Down Expand Up @@ -122,6 +153,7 @@ def extract_text(section_name=None, **kwargs):

# Create Section with TOC confidence
section = Section(
covered_items=_covered_items(item, section_text),
name=section_name,
title=section_info.get('canonical_name', section_name),
node=section_node,
Expand Down
32 changes: 32 additions & 0 deletions edgar/documents/utils/toc_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1325,6 +1325,29 @@ def _combined_part_item_label(cls, text: str) -> Optional[Tuple[str, str]]:
part, num, letter = match.groups()
return f"Part {part.upper()}", f"Item {num}{(letter or '').upper()}"

# "Items 1 and 2. Business and Properties", "Items 1. and 2.", "Items\xa01. and 2.",
# "Items 7. and 7A." — two items a filer files under one heading (GH #710, #1382).
# `\s` matches the non-breaking space these TOCs use after "Items".
_COMBINED_ITEMS = re.compile(
r'items\s+(\d+)([A-Za-z])?\s*\.?\s*(?:and|&)\s*(\d+)([A-Za-z])?(?=\W|$|[A-Z][a-z])',
re.IGNORECASE)

@classmethod
def combined_item_numbers(cls, text: str) -> Optional[Tuple[str, str]]:
"""The two item numbers of a combined "Items N and M" label, e.g. ``('7', '7A')``.

Returns ``None`` unless the text opens with such a label. A letter glued
to a following title word is not a suffix, as in :meth:`_item_label_from_text`.
"""
match = cls._COMBINED_ITEMS.match((text or '').strip())
if not match:
return None
first_num, first_letter, second_num, second_letter = match.groups()
if second_letter and text.strip()[match.end():][:1].islower():
second_letter = None
return (f"{first_num}{(first_letter or '').upper()}",
f"{second_num}{(second_letter or '').upper()}")

@staticmethod
def _item_label_from_text(text: str) -> Optional[str]:
"""Normalize a leading ``Item N`` label, ignoring a title glued onto it.
Expand All @@ -1348,8 +1371,17 @@ def _item_label_from_text(text: str) -> Optional[str]:
body-scan counter already allows for Caterpillar's Item 1D) — and it
keeps a genuinely glued suffix working: "Item 1ARisk Factors" -> Item 1A.

A combined label ("Items 1 and 2. Business and Properties") names its
first item: the section is keyed as that item and records the second in
``Section.covered_items``. Rejecting the plural dropped the whole row, so
Items 1 and 2 had no section at all on Viper, Devon, Freeport and
Cheniere (GH #1382).

Returns ``None`` when the text does not open with an item label. (GH #923)
"""
combined = TOCAnalyzer.combined_item_numbers(text)
if combined:
return f"Item {combined[0]}"
match = re.match(r'item\s+(\d+)([A-Za-z])?', text, re.IGNORECASE)
if not match:
return None
Expand Down
8 changes: 8 additions & 0 deletions tests/fixtures/html/fcx/10k/fcx-10-k-2025-02-14.html

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions tests/fixtures/html/lng/10k/lng-10-k-2026-02-26.html

Large diffs are not rendered by default.

13,705 changes: 13,705 additions & 0 deletions tests/fixtures/html/talo/10k/talo-10-k-2026-02-25.html

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions tests/fixtures/html/vnom/10k/vnom-10-k-2026-02-25.html

Large diffs are not rendered by default.

144 changes: 144 additions & 0 deletions tests/issues/regression/test_issue_1382_combined_item_headings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Regression: two items filed under one heading keep both items (GH #1382, #1383).

GitHub Issues: https://github.com/dgunning/edgartools/issues/1382
https://github.com/dgunning/edgartools/issues/1383
Bead: edgartools-644g

Energy filers combine items under one heading: "Items 1 and 2. Business and
Properties", "Items 1. and 2.", "Items&#160;1. and 2.", "Items 7. and 7A.".
When that heading is the TOC link text, every TOC parser rejected it: the item
label regex needs "Item" followed by whitespace, so the plural's "s" failed it,
and the row was dropped. Part I then started at Item 1A, ``tenk.business`` was
None and the Business and Properties text sat in no section (Viper and Devon
regressed in 5.29.0 / 5.34.0; the #710 test injected ready-made keys and never
ran detection). Freeport's "Items 7. and 7A." kept Item 7 and lost 7A.

On Talos the TOC puts "Items 1 and 2." and the link in separate cells, so Item 1
survived, but Item 2 looked missing and the pattern pass admitted a bare
"Properties" sub-heading from inside Item 1 as a 1,311-character Item 2 (#1383).

The fix reads a combined label as its first item and records both on
``Section.covered_items`` from the section's own heading; ``TenK`` resolves the
second item to that section and the pattern pass counts it as found.

Offline (local fixtures, parsed end to end):
Viper Energy 10-K 0002074176-26-000010 tests/fixtures/html/vnom/10k/
Cheniere Energy 10-K 0000003570-26-000005 tests/fixtures/html/lng/10k/
Freeport-McMoRan 10-K 0000831259-25-000006 tests/fixtures/html/fcx/10k/
Talos Energy 10-K 0001193125-26-067807 tests/fixtures/html/talo/10k/
"""
import pathlib

import pytest

from edgar.company_reports.ten_k import TenK
from edgar.documents.utils.toc_analyzer import TOCAnalyzer

FIXTURES = pathlib.Path(__file__).resolve().parents[2] / "fixtures" / "html"

VIPER = FIXTURES / "vnom" / "10k" / "vnom-10-k-2026-02-25.html"
CHENIERE = FIXTURES / "lng" / "10k" / "lng-10-k-2026-02-26.html"
FREEPORT = FIXTURES / "fcx" / "10k" / "fcx-10-k-2025-02-14.html"
TALOS = FIXTURES / "talo" / "10k" / "talo-10-k-2026-02-25.html"


class FixtureFiling:
"""The minimum surface TenK touches, backed by a local file."""

filing_date = None
form = "10-K"
company = "fixture"

def __init__(self, path: pathlib.Path):
self._path = path
self.accession_number = path.stem
self.base_dir = str(path.parent)

def html(self):
return self._path.read_text(encoding="utf-8", errors="replace")


@pytest.fixture(scope="module", params=[VIPER, CHENIERE, FREEPORT, TALOS], ids=lambda p: p.parts[-3])
def tenk(request):
return TenK(FixtureFiling(request.param))


@pytest.mark.fast
def test_the_fixtures_are_present():
"""Absent is not passing."""
for path in (VIPER, CHENIERE, FREEPORT, TALOS):
assert path.exists(), path


@pytest.mark.fast
def test_combined_items_1_and_2_are_one_section(tenk):
part_i = [key for key in tenk.sections if key.startswith("part_i_")]
assert part_i[:2] == ["part_i_item_1", "part_i_item_1a"]

section = tenk.sections["part_i_item_1"]
assert section.covered_items == ("1", "2")
assert section.detection_method == "toc"

business = tenk.business
assert business
assert tenk["Item 2"] == business
assert "Item 2" in tenk.items


@pytest.mark.fast
def test_business_opens_on_the_combined_heading():
"""Each variant of the heading resolves, including '1.' and a nbsp after 'Items'."""
assert TenK(FixtureFiling(CHENIERE)).business.startswith("ITEMS 1. AND 2.\xa0\xa0\xa0\xa0BUSINESS AND PROPERTIES")
assert TenK(FixtureFiling(FREEPORT)).business.startswith("Items 1. and 2. Business and Properties.")
# Viper's TOC link lands on a preamble paragraph ahead of its heading
viper = TenK(FixtureFiling(VIPER)).business
assert viper.startswith("On November 13, 2023, Viper Energy Partners LP converted")
assert "ITEMS 1 and 2. BUSINESS AND PROPERTIES" in viper[:3000]


@pytest.mark.fast
def test_combined_items_7_and_7a_resolve_7a():
"""Freeport's 'Items 7. and 7A.' used to leave Item 7A unreachable."""
freeport = TenK(FixtureFiling(FREEPORT))
section = freeport.sections["part_ii_item_7"]
assert section.covered_items == ("7", "7A")
assert "part_ii_item_7a" not in freeport.sections
assert freeport["Item 7A"] == freeport["Item 7"]
# A running header precedes the heading
assert freeport["Item 7A"].startswith(
"Table of Contents\n\nItems 7. and 7A.\xa0\xa0Management’s Discussion and Analysis")


@pytest.mark.fast
def test_properties_subheading_inside_item_1_is_not_a_second_item_2():
"""Talos: no 'properties' fragment claims Item 2 (GH #1383)."""
talos = TenK(FixtureFiling(TALOS))
assert "properties" not in talos.sections
assert [key for key, s in talos.sections.items() if s.item == "2"] == []
assert talos["Item 2"] == talos.business


@pytest.mark.fast
def test_ordinary_items_cover_nothing():
"""A one-item section records no combined coverage."""
viper = TenK(FixtureFiling(VIPER))
assert viper.sections["part_i_item_1a"].covered_items == ()
assert viper.sections["part_ii_item_7"].covered_items == ()


@pytest.mark.fast
@pytest.mark.parametrize("text,expected", [
("Items 1 and 2. Business and Properties", ("1", "2")),
("Items 1. and 2. Business and Properties", ("1", "2")),
("Items\xa01. and 2. Business and Properties", ("1", "2")),
("ITEMS 1. AND 2.\xa0\xa0\xa0\xa0BUSINESS AND PROPERTIES", ("1", "2")),
("Items 7. and 7A. Management's Discussion and Analysis", ("7", "7A")),
("Items 1 & 2 Business", ("1", "2")),
# Not two items under one heading
("Item 1. Business", None),
("Items 10, 11 and 12", None),
("Items 1, 1A and 2", None),
("See Items 1 and 2", None),
])
def test_combined_item_numbers(text, expected):
assert TOCAnalyzer.combined_item_numbers(text) == expected
Loading