Skip to content

Commit ffd6ae0

Browse files
dgunningclaude
andauthored
fix(xbrl): an amended annual report is still an annual report; cash flow gets its named period views (#1311)
* fix(xbrl): an amended annual report is still an annual report; cash flow gets its named period views GH #1226 (edgartools-osrt) — entity_info classified the report by comparing the whole dei:DocumentType against '10-K' and '10-Q', while testing the amendment with a substring. A 10-K/A therefore failed both report tests and passed the amendment one, so Shopify's amended annual report came back amendment=True with annual_report=False — a classification that contradicts itself and the filing, which tags dei:DocumentAnnualReport true. The form list this decision needs already existed as its own tuple in period_selector.py (annual_form_types, /A variants included): one rule, two implementations, and the copy that never set the flag was the correct one. Both now call edgar.xbrl.core.is_annual_document_type and its siblings, which strip the suffix before classifying. That widens annual_report to 20-F, 40-F, 10-KT, 10-KSB and 11-K, where only '10-K' set it before. period_selector is the only consumer of the flag and it already ORed those forms in through its own tuple, so period selection does not move: entity_info is identical across all eight directories in data/xbrl/datafiles. GH #1253 (edgartools-qao5) — get_period_views("CashFlowStatement") returned an empty list because the statement type had no entry in STATEMENT_TYPE_CONFIG, so the named views every other primary statement offers were unreachable and to_dataframe(period_view=...) had no name to accept. determine_periods_to_display already handles cash flow and income statements in one branch, so the entry mirrors the income statement's. Across all eight fixtures the cash-flow view list now equals the income-statement view list, including the two where both are legitimately empty. hatch run test-fast: 7,217 passed. Fixes #1226 Fixes #1253 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(1253): read the fixture list from the checkout, not from a hand-written list data/xbrl/datafiles/aes is present locally and untracked, so naming the eight directories by hand passed here and failed CI with "Directory not found". The sweep now takes the directories that actually carry a presentation linkbase, and asserts there are at least five of them so it cannot quietly measure nothing — which is the failure mode an untracked fixture usually produces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1663303 commit ffd6ae0

8 files changed

Lines changed: 248 additions & 15 deletions

File tree

changelog.d/1226.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
**An amended annual report was not classified as an annual report.** `entity_info` compared the whole `dei:DocumentType` against `10-K`, so Shopify's 10-K/A reported `amendment=True` alongside `annual_report=False`, contradicting the filing's own `dei:DocumentAnnualReport`. The amendment suffix is now stripped before classifying, and 20-F/40-F annual reports are recognised too. (GH #1226)

changelog.d/1253.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
**Named period views for cash flow statements.** `get_period_views("CashFlowStatement")` returned an empty list because the statement type had no entry in the period-view configuration, so `to_dataframe(period_view=...)` had no name to accept. Cash flow now offers the same named views as the income statement, which selects from the same periods. (GH #1253)

edgar/xbrl/core.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,39 @@ def is_point_in_time(period_type: Optional[str]) -> Optional[bool]:
572572
return period_type == 'instant'
573573

574574

575+
#: Document types that are annual reports, without the amendment suffix.
576+
ANNUAL_REPORT_FORMS = frozenset({'10-K', '10-KT', '10-KSB', '20-F', '40-F', '11-K'})
577+
578+
#: Document types that are quarterly reports, without the amendment suffix.
579+
QUARTERLY_REPORT_FORMS = frozenset({'10-Q', '10-QT', '10-QSB'})
580+
581+
582+
def base_document_type(document_type: Optional[str]) -> str:
583+
"""The document type with its amendment suffix removed: '10-K/A' -> '10-K'."""
584+
if not document_type:
585+
return ''
586+
return document_type.split('/')[0].strip().upper()
587+
588+
589+
def is_amendment_document_type(document_type: Optional[str]) -> bool:
590+
"""Whether a dei:DocumentType carries the amendment suffix."""
591+
return bool(document_type) and '/A' in document_type.upper()
592+
593+
594+
def is_annual_document_type(document_type: Optional[str]) -> bool:
595+
"""Whether a dei:DocumentType is an annual report, amended or not.
596+
597+
An amended annual report is still an annual report. Comparing the whole
598+
string against '10-K' made Shopify's 10-K/A report amendment=True alongside
599+
annual_report=False, which contradicts its own dei:DocumentAnnualReport
600+
(GH #1226).
601+
"""
602+
return base_document_type(document_type) in ANNUAL_REPORT_FORMS
603+
604+
605+
def is_quarterly_document_type(document_type: Optional[str]) -> bool:
606+
"""Whether a dei:DocumentType is a quarterly report, amended or not."""
607+
return base_document_type(document_type) in QUARTERLY_REPORT_FORMS
575608
#: The XBRL ``decimals`` sentinel for a value reported exactly, to unlimited
576609
#: precision. It is not a number and is never interchangeable with 0, which
577610
#: says the value is rounded to the unit.

edgar/xbrl/parsers/instance.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,14 @@
1212
from lxml import etree as ET
1313

1414
from edgar.core import log
15-
from edgar.xbrl.core import NAMESPACES, classify_duration, duration_days
15+
from edgar.xbrl.core import (
16+
NAMESPACES,
17+
classify_duration,
18+
duration_days,
19+
is_amendment_document_type,
20+
is_annual_document_type,
21+
is_quarterly_document_type,
22+
)
1623
from edgar.xbrl.models import Context, Fact, XBRLProcessingError
1724

1825
from .base import BaseParser
@@ -838,11 +845,13 @@ def get_dei(*names):
838845
except Exception:
839846
pass
840847

841-
# Flags based on document_type
848+
# Flags based on document_type. An amended annual report is still
849+
# an annual report, so the suffix is stripped before classifying
850+
# rather than compared away (GH #1226).
842851
dt_val = self.entity_info['document_type'] or ''
843-
self.entity_info['annual_report'] = (dt_val == '10-K')
844-
self.entity_info['quarterly_report'] = (dt_val == '10-Q')
845-
self.entity_info['amendment'] = ('/A' in dt_val)
852+
self.entity_info['annual_report'] = is_annual_document_type(dt_val)
853+
self.entity_info['quarterly_report'] = is_quarterly_document_type(dt_val)
854+
self.entity_info['amendment'] = is_amendment_document_type(dt_val)
846855

847856
log.debug(f"Entity info: {self.entity_info}")
848857
except Exception as e:

edgar/xbrl/period_selector.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from datetime import date, datetime
1515
from typing import Any, Dict, List, Optional, Tuple
1616

17-
from edgar.xbrl.core import duration_days
17+
from edgar.xbrl.core import duration_days, is_annual_document_type
1818

1919
logger = logging.getLogger(__name__)
2020

@@ -257,15 +257,12 @@ def _select_duration_periods(periods: List[Dict], entity_info: Dict[str, Any], m
257257
# Some filings (like GE 2015 10-K) report fiscal_period='Q4' even for annual reports
258258
is_annual_report = entity_info.get('annual_report', False)
259259
document_type = entity_info.get('document_type', '')
260-
annual_form_types = (
261-
'10-K', '10-K/A', '10-KT', '10-KT/A', # Standard and transition annual reports
262-
'10-KSB', '10-KSB/A', # Small business (legacy)
263-
'20-F', '20-F/A', # Foreign private issuers
264-
'40-F', '40-F/A', # Canadian issuers
265-
)
266-
267-
# Consider it annual if: fiscal_period == 'FY' OR it's flagged as annual OR it's an annual form type
268-
is_annual = fiscal_period == 'FY' or is_annual_report or document_type in annual_form_types
260+
261+
# Consider it annual if: fiscal_period == 'FY' OR it's flagged as annual OR it's an annual form type.
262+
# The form list used to live here as its own tuple, which is the same rule
263+
# entity_info's annual_report flag is derived from (GH #1226).
264+
is_annual = (fiscal_period == 'FY' or is_annual_report
265+
or is_annual_document_type(document_type))
269266

270267
# Filter for annual periods if this is an annual report
271268
if is_annual:

edgar/xbrl/periods.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,31 @@
5858
}
5959
]
6060
},
61+
# A cash flow statement selects the same duration periods an income
62+
# statement does (determine_periods_to_display handles the two together),
63+
# but it had no entry here, so get_period_views() returned [] for it and
64+
# the named views were unreachable through to_dataframe(period_view=...)
65+
# (GH #1253).
66+
'CashFlowStatement': {
67+
'period_type': 'duration',
68+
'max_periods': 3,
69+
'allow_annual_comparison': True,
70+
'views': [
71+
{
72+
'name': 'Three Recent Periods',
73+
'description': 'Shows three most recent reporting periods',
74+
'max_periods': 3,
75+
'requires_min_periods': 3
76+
},
77+
{
78+
'name': 'YTD and Quarterly Breakdown',
79+
'description': 'Shows YTD figures and quarterly breakdown',
80+
'max_periods': 5,
81+
'requires_min_periods': 2,
82+
'mixed_view': True
83+
}
84+
]
85+
},
6186
'StatementOfEquity': {
6287
'period_type': 'duration',
6388
'max_periods': 3,
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Regression test for issue #1226.
2+
3+
GitHub Issue: https://github.com/dgunning/edgartools/issues/1226
4+
5+
`XBRL.entity_info` classified the report by comparing the whole
6+
`dei:DocumentType` against `'10-K'`, so Shopify's 10-K/A came back with
7+
`amendment=True` alongside `annual_report=False` — a classification that
8+
contradicts itself and the filing, which tags `dei:DocumentAnnualReport` true.
9+
10+
An amended annual report is still an annual report. The suffix is now stripped
11+
before classifying, and the form list `period_selector` kept as its own tuple
12+
for the same decision is now the one both use
13+
(`edgar.xbrl.core.is_annual_document_type`).
14+
"""
15+
16+
import pytest
17+
18+
from edgar.xbrl.core import (
19+
is_amendment_document_type,
20+
is_annual_document_type,
21+
is_quarterly_document_type,
22+
)
23+
from edgar.xbrl.parsers import XBRLParser
24+
25+
INSTANCE = """<?xml version="1.0" encoding="UTF-8"?>
26+
<xbrl xmlns="http://www.xbrl.org/2003/instance"
27+
xmlns:xbrli="http://www.xbrl.org/2003/instance"
28+
xmlns:dei="http://xbrl.sec.gov/dei/2024"
29+
xmlns:xlink="http://www.w3.org/1999/xlink">
30+
<context id="c1">
31+
<entity><identifier scheme="http://www.sec.gov/CIK">0001594805</identifier></entity>
32+
<period><startDate>2024-01-01</startDate><endDate>2024-12-31</endDate></period>
33+
</context>
34+
<dei:DocumentType contextRef="c1">{document_type}</dei:DocumentType>
35+
<dei:AmendmentFlag contextRef="c1">true</dei:AmendmentFlag>
36+
<dei:DocumentAnnualReport contextRef="c1">true</dei:DocumentAnnualReport>
37+
<dei:DocumentFiscalPeriodFocus contextRef="c1">FY</dei:DocumentFiscalPeriodFocus>
38+
<dei:DocumentFiscalYearFocus contextRef="c1">2024</dei:DocumentFiscalYearFocus>
39+
<dei:EntityRegistrantName contextRef="c1">SHOPIFY INC.</dei:EntityRegistrantName>
40+
</xbrl>
41+
"""
42+
43+
44+
def _entity_info(document_type):
45+
parser = XBRLParser()
46+
parser.parse_instance_content(INSTANCE.format(document_type=document_type))
47+
return parser.entity_info
48+
49+
50+
def test_amended_annual_report_keeps_both_halves_of_its_identity():
51+
"""The report's own case: Shopify's 10-K/A, accession 0001594805-25-000039."""
52+
info = _entity_info("10-K/A")
53+
54+
assert info["entity_name"] == "SHOPIFY INC."
55+
assert info["document_type"] == "10-K/A"
56+
assert info["annual_report"] is True
57+
assert info["amendment"] is True
58+
assert info["quarterly_report"] is False
59+
60+
61+
def test_an_unamended_annual_report_is_unchanged():
62+
info = _entity_info("10-K")
63+
64+
assert info["annual_report"] is True
65+
assert info["amendment"] is False
66+
67+
68+
def test_an_amended_quarterly_report_is_still_quarterly():
69+
info = _entity_info("10-Q/A")
70+
71+
assert info["quarterly_report"] is True
72+
assert info["amendment"] is True
73+
assert info["annual_report"] is False
74+
75+
76+
@pytest.mark.parametrize("document_type,annual,quarterly,amendment", [
77+
("10-K", True, False, False),
78+
("10-K/A", True, False, True),
79+
("10-KT", True, False, False),
80+
("20-F", True, False, False),
81+
("20-F/A", True, False, True),
82+
("40-F", True, False, False),
83+
("10-Q", False, True, False),
84+
("10-Q/A", False, True, True),
85+
("8-K", False, False, False),
86+
("", False, False, False),
87+
(None, False, False, False),
88+
])
89+
def test_document_type_classification(document_type, annual, quarterly, amendment):
90+
assert is_annual_document_type(document_type) is annual
91+
assert is_quarterly_document_type(document_type) is quarterly
92+
assert is_amendment_document_type(document_type) is amendment
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Regression test for issue #1253.
2+
3+
GitHub Issue: https://github.com/dgunning/edgartools/issues/1253
4+
5+
`get_period_views("CashFlowStatement")` returned `[]` even for a filing whose
6+
cash flow statement had been resolved and whose ordinary period selector had
7+
already chosen comparable duration columns. `CashFlowStatement` was simply
8+
absent from `STATEMENT_TYPE_CONFIG`, so the named views other primary
9+
statements offer were unreachable, and `to_dataframe(period_view=...)` had no
10+
name to accept.
11+
12+
`determine_periods_to_display` already handles cash flow and income statements
13+
in one branch, so the entry mirrors the income statement's: duration periods,
14+
three of them, with the same two named views.
15+
"""
16+
17+
from pathlib import Path
18+
19+
import pytest
20+
21+
from edgar.xbrl import XBRL
22+
23+
DATA = Path(__file__).resolve().parents[3] / "data" / "xbrl" / "datafiles"
24+
25+
# Not every directory under data/xbrl/datafiles is committed -- `aes` is
26+
# present locally and untracked, so naming the eight by hand passed here and
27+
# failed in CI. Read what the checkout actually has, and assert below that it
28+
# is enough for the sweep to mean something.
29+
FIXTURES = sorted(path.name for path in DATA.iterdir()
30+
if path.is_dir() and any(path.glob("*_pre.xml")))
31+
32+
33+
def test_the_fixture_sweep_is_not_empty():
34+
assert len(FIXTURES) >= 5, f"too few committed XBRL fixtures to sweep: {FIXTURES}"
35+
36+
37+
@pytest.mark.parametrize("fixture", FIXTURES)
38+
def test_cash_flow_offers_the_same_named_views_as_the_income_statement(fixture):
39+
"""The two statements select from the same periods, so they must agree."""
40+
xbrl = XBRL.from_directory(DATA / fixture)
41+
42+
income = [view["name"] for view in xbrl.get_period_views("IncomeStatement")]
43+
cash_flow = [view["name"] for view in xbrl.get_period_views("CashFlowStatement")]
44+
45+
assert cash_flow == income
46+
47+
48+
def test_a_named_view_can_be_passed_to_to_dataframe():
49+
xbrl = XBRL.from_directory(DATA / "aapl")
50+
51+
views = xbrl.get_period_views("CashFlowStatement")
52+
assert views, "expected named cash-flow views for the AAPL fixture"
53+
assert "Three Recent Periods" in [view["name"] for view in views]
54+
55+
statement = xbrl.statements.cash_flow_statement()
56+
frame = statement.to_dataframe(period_view=views[0]["name"])
57+
58+
period_columns = [column for column in frame.columns if "20" in str(column)]
59+
assert period_columns, "the named view produced no period columns"
60+
assert len(period_columns) <= views[0].get("max_periods", 3) + 1
61+
62+
# A view is only useful if the numbers come with it.
63+
operating = frame.loc[frame["concept"] == "us-gaap_NetCashProvidedByUsedInOperatingActivities"]
64+
assert not operating.empty
65+
assert operating[period_columns].notna().any().any()
66+
67+
68+
def test_every_view_reports_the_period_keys_it_selected():
69+
xbrl = XBRL.from_directory(DATA / "aapl")
70+
71+
for view in xbrl.get_period_views("CashFlowStatement"):
72+
assert view["name"]
73+
assert view["description"]
74+
assert view["period_keys"], f"{view['name']} selected no periods"
75+
assert all(key.startswith("duration_") for key in view["period_keys"])

0 commit comments

Comments
 (0)