diff --git a/CHANGELOG.md b/CHANGELOG.md
index fff5b385f..c69d2dc26 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,8 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **`edgar.exceptions` — one exception vocabulary, four branches.** There were 27 exception classes across ten packages with no shared base and no cross-package inheritance, of which exactly two were reachable from the top level, so `except` had to name a type from whichever module happened to raise. There is now a root, `EdgarError`, and four branches that answer the question a caller actually has: `TransportError` (we could not get an answer from SEC), `NotFoundError` (you named a thing and it does not exist), `ParsingError` (we got bytes and could not build the object), `ValidationError` (your input was wrong before we asked). The distinction between the first two is the one that matters most — an outage and an empty result must never arrive as the same value. **Nothing changed about what is raised today**: every existing class was re-based into the tree or kept as a deprecated alias for the same object, so `except StatementNotFound:` and `pytest.raises(SECFilingNotFoundError)` still work. The branches also inherit the builtin they replace — `ValidationError` is a `ValueError`, `NotFoundError` is a `LookupError` — so the `except ValueError:` you wrote against our 135 raw `ValueError` raises keeps working as those convert. Deprecated spellings warn and are removed in 6.0: `StatementNotFound`, `NoCompanyFactsFound`, `SECFilingNotFoundError`, `InvalidDateException`, `IdentityNotSetException`, `TooManyRequestsException`, `DataObjectException`.
+
+- **A missing-attachment lookup raises `AttachmentNotFoundError`** rather than a bare `KeyError`. It *is* a `KeyError`, so existing handlers are unaffected.
+
- **`edgartools` ships a PEP 561 `py.typed` marker, so its type hints now reach your type checker.** The README has said "type hints throughout" for a long time and it was true of the source and false of the installed package: without the marker, mypy refuses to look inside `edgar` at all — `Skipping analyzing "edgar": module is installed, but missing library stubs or py.typed marker` — and every symbol degrades to `Any`. `Company(cik_or_ticker=[1, 2, 3])` type-checked clean against 5.47.0; it now reports `Argument "cik_or_ticker" to "Company" has incompatible type "list[int]"; expected "str | int"`. Nothing in the library changed — this makes the annotations already there visible, and it is why the typing work behind them was worth doing. Pyright users saw types already, because it reads library source by default; mypy and stub-strict configurations did not.
+### Fixed
+
+- **`NoCompanyFactsFound` carried a message nobody could read.** Its `__init__` called `super().__init__()` with no arguments and set `self.message` instead, so `str(exc)` was the empty string — three raise sites whose message never reached a traceback, a log line, or a user. It is now `CompanyFactsNotFoundError` and builds its message through the base class, which makes the empty case unrepresentable rather than merely fixed.
+
## [5.47.0] - 2026-08-10
### Added
diff --git a/edgar/__init__.py b/edgar/__init__.py
index 4bc693cd9..4033e0c24 100644
--- a/edgar/__init__.py
+++ b/edgar/__init__.py
@@ -3,6 +3,7 @@
# SPDX-License-Identifier: MIT
import logging
import re
+import warnings
from functools import lru_cache, partial
from typing import List, Optional, Union
@@ -21,6 +22,22 @@
)
from edgar.context import HasContext, compose_context
from edgar.core import CAUTION, CRAWL, NORMAL, edgar_mode, get_identity, listify, set_identity
+from edgar.exceptions import (
+ AttachmentNotFoundError,
+ CompanyFactsNotFoundError,
+ CompanyNotFoundError,
+ DataObjectError,
+ EdgarError,
+ FilingNotFoundError,
+ IdentityNotSetError,
+ NotFoundError,
+ ParsingError,
+ SectionNotFoundError,
+ StatementNotFoundError,
+ TooManyRequestsError,
+ TransportError,
+ ValidationError,
+)
from edgar.current_filings import CurrentFilings, get_all_current_filings, get_current_filings, iter_current_filings_pages
# SSL diagnostic function
@@ -201,7 +218,17 @@
"Company", "CompanyData", "CompanyFiling", "CompanyFilings",
"CompanySearchResults", "Entity", "EntityData",
"Attachment", "Attachments", "FilingHomepage", "FilingHeader",
- "CompanyNotFoundError", "DataObjectException",
+ # -- Errors (edgar.exceptions) -------------------------------------------
+ # The four branches plus the concretes users need by name. Everything else
+ # in the tree is importable from edgar.exceptions.
+ "EdgarError",
+ "TransportError", "TooManyRequestsError", "IdentityNotSetError",
+ "NotFoundError", "CompanyNotFoundError", "FilingNotFoundError",
+ "CompanyFactsNotFoundError", "StatementNotFoundError",
+ "SectionNotFoundError", "AttachmentNotFoundError",
+ "ParsingError", "DataObjectError",
+ "ValidationError",
+ "DataObjectException", # deprecated alias, removed in 6.0
# -- Financial statements ------------------------------------------------
"Financials", "MultiFinancials", "XBRL",
@@ -295,11 +322,23 @@ def matches_form(sec_filing: Filing,
return False
-class DataObjectException(Exception):
+class DataObjectException(DataObjectError):
+ """Deprecated: use edgar.exceptions.DataObjectError. Removed in 6.0.
+
+ Kept as a subclass rather than a plain alias because this one takes a
+ Filing, and DataObjectError takes primitives — edgar.exceptions imports
+ nothing from edgar. It was never raised anywhere, so the constructor is
+ almost certainly unused, but a name in __all__ gets the full treatment.
+ """
def __init__(self, filing: Filing):
- self.message = f"Could not create a data object for Form {filing.form} filing: {filing.accession_no}"
- super().__init__(self.message)
+ warnings.warn(
+ "DataObjectException is deprecated and will be removed in v6.0. "
+ "Use DataObjectError instead (from edgar.exceptions import DataObjectError).",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ super().__init__(form=filing.form, accession_no=filing.accession_no)
def get_obj_info(form: str) -> tuple[bool, Optional[str], Optional[str]]:
diff --git a/edgar/_compat.py b/edgar/_compat.py
new file mode 100644
index 000000000..b57967901
--- /dev/null
+++ b/edgar/_compat.py
@@ -0,0 +1,67 @@
+"""Deprecated names that still resolve, and warn when you use them.
+
+Bead: edgartools-07lk.10.
+
+Renaming a public class has two failure modes and this module exists to avoid
+both. Aliasing the new name onto the old one leaves the deprecated spelling as
+the real implementation, so the rename never actually happens (the trap
+recorded in edgartools-07lk.23). Assigning `OldName = NewName` at module level
+does perform the rename, but silently — nobody finds out until 6.0 deletes it.
+
+`deprecated_alias` gives the third behaviour: the canonical class is the real
+one, the old name resolves to *the same object* — so `except OldName:` and
+`isinstance(x, OldName)` keep working — and touching the old name warns once
+per call site.
+
+ # edgar/dates.py
+ from edgar._compat import deprecated_alias
+ from edgar.exceptions import InvalidDateError
+
+ __getattr__ = deprecated_alias(InvalidDateException=InvalidDateError)
+
+PEP 562 module `__getattr__` covers both `module.OldName` and
+`from module import OldName`. It does not cover static analysis: mypy will not
+see these names. That is a feature for deprecated spellings — a type checker
+pointing users at the canonical name is the outcome we want.
+"""
+from __future__ import annotations
+
+import warnings
+from typing import Any, Callable, Dict
+
+__all__ = ["deprecated_alias"]
+
+
+def deprecated_alias(__module_getattr__: Callable[[str], Any] = None,
+ **aliases: Any) -> Callable[[str], Any]:
+ """Build a module-level `__getattr__` that resolves deprecated names.
+
+ Args:
+ __module_getattr__: an existing module `__getattr__` to fall through to,
+ for modules that already define one. Optional.
+ **aliases: `OldName=CanonicalObject` pairs.
+
+ Returns:
+ A function to assign to the module's `__getattr__`.
+ """
+ def __getattr__(name: str) -> Any: # noqa: N807 - it IS a module __getattr__
+ if name in aliases:
+ target = aliases[name]
+ canonical = getattr(target, "__name__", str(target))
+ warnings.warn(
+ f"{name} is deprecated and will be removed in v6.0. "
+ f"Use {canonical} instead (from edgar.exceptions import {canonical}).",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ return target
+ if __module_getattr__ is not None:
+ return __module_getattr__(name)
+ raise AttributeError(name)
+
+ return __getattr__
+
+
+def alias_map(**aliases: Any) -> Dict[str, Any]:
+ """The alias mapping on its own, for tests that assert what is deprecated."""
+ return dict(aliases)
diff --git a/edgar/_filings.py b/edgar/_filings.py
index 5c4dc7320..2e8236db5 100644
--- a/edgar/_filings.py
+++ b/edgar/_filings.py
@@ -51,7 +51,7 @@
parallel_thread_map,
quarters_in_year,
)
-from edgar.dates import InvalidDateException
+from edgar.dates import InvalidDateError
from edgar.display.formatting import accession_number_text, display_size
from edgar.display.styles import print_info, print_warning
from edgar.documents import HTMLParser, ParserConfig
@@ -720,7 +720,7 @@ def filter(self, *,
latest_date = self.date_range[1]
if latest_date is not None and _get_data_staleness_days(latest_date) >= 1:
_warn_use_current_filings("", latest_date)
- except InvalidDateException as e:
+ except InvalidDateError as e:
log.error(e)
return Filings(_empty_filing_index())
@@ -1994,10 +1994,11 @@ def sgml(self) -> FilingSGML:
try:
self._sgml = FilingSGML.from_filing(self)
except (ValueError, Exception) as e:
- from edgar.sgml.sgml_parser import SECIdentityError, SECFilingNotFoundError, SECHTMLResponseError
- from edgar.httprequests import IdentityNotSetException
+ from edgar.sgml.sgml_parser import SECHTMLResponseError, SECIdentityError
+ from edgar.exceptions import FilingNotFoundError, IdentityNotSetError
+ from edgar.httprequests import IdentityNotSetError
# Don't fall back on permanent errors — propagate them
- if isinstance(e, (SECIdentityError, SECFilingNotFoundError, IdentityNotSetException)):
+ if isinstance(e, (SECIdentityError, FilingNotFoundError, IdentityNotSetError)):
raise
# Don't fall back on network errors — propagate them so callers
# (e.g. xbrl()) can show local-storage-aware error messages
diff --git a/edgar/ai/helpers.py b/edgar/ai/helpers.py
index 6ff5c15ae..4dcf0f5e0 100644
--- a/edgar/ai/helpers.py
+++ b/edgar/ai/helpers.py
@@ -146,7 +146,7 @@ def get_revenue_trend(
Raises:
ValueError: If ticker is invalid or company not found
HTTPError: If SEC Company Facts API request fails
- NoCompanyFactsFound: If company has no financial data
+ CompanyFactsNotFoundError: If company has no financial data
Examples:
>>> # Get 3 fiscal years of revenue data (default)
diff --git a/edgar/ai/mcp/tools/base.py b/edgar/ai/mcp/tools/base.py
index a23eb8499..b273afd5d 100644
--- a/edgar/ai/mcp/tools/base.py
+++ b/edgar/ai/mcp/tools/base.py
@@ -475,8 +475,8 @@ def classify_error(exc: Exception) -> dict[str, Any]:
pass
try:
- from edgar.httprequests import IdentityNotSetException
- if isinstance(exc, IdentityNotSetException):
+ from edgar.exceptions import IdentityNotSetError
+ if isinstance(exc, IdentityNotSetError):
return {
"error_code": "IDENTITY_NOT_SET",
"message": "SEC identity not configured. Set the EDGAR_IDENTITY environment variable.",
@@ -508,8 +508,8 @@ def classify_error(exc: Exception) -> dict[str, Any]:
pass
try:
- from edgar.entity.entity_facts import NoCompanyFactsFound
- if isinstance(exc, NoCompanyFactsFound):
+ from edgar.exceptions import CompanyFactsNotFoundError
+ if isinstance(exc, CompanyFactsNotFoundError):
return {
"error_code": "NO_FACTS_DATA",
"message": getattr(exc, 'message', str(exc)),
@@ -530,8 +530,8 @@ def classify_error(exc: Exception) -> dict[str, Any]:
pass
try:
- from edgar.sgml.sgml_parser import SECFilingNotFoundError
- if isinstance(exc, SECFilingNotFoundError):
+ from edgar.exceptions import FilingNotFoundError
+ if isinstance(exc, FilingNotFoundError):
return {
"error_code": "FILING_NOT_FOUND",
"message": f"SEC filing not found: {exc}",
diff --git a/edgar/attachments.py b/edgar/attachments.py
index 8aca3f2ca..2888a7f30 100644
--- a/edgar/attachments.py
+++ b/edgar/attachments.py
@@ -30,6 +30,7 @@
from rich.text import Text
from edgar.config import SEC_BASE_URL
+from edgar.exceptions import AttachmentNotFoundError
from edgar.core import binary_extensions, has_html_content, text_extensions
from edgar.files._deprecation import PAGE_BREAK_DEPRECATION
from edgar.files.html_documents import get_clean_html
@@ -660,7 +661,7 @@ def __getitem__(self, item: Union[int, str]):
for doc in self._attachments:
if doc.document == item:
return doc
- raise KeyError(f"Document not found: {item}")
+ raise AttachmentNotFoundError(f"Document not found: {item}")
def get_by_sequence(self, sequence: Union[str, int]):
"""
@@ -670,7 +671,7 @@ def get_by_sequence(self, sequence: Union[str, int]):
for doc in self._attachments:
if doc.sequence_number == str(sequence):
return doc
- raise KeyError(f"Document not found: {sequence}")
+ raise AttachmentNotFoundError(f"Document not found: {sequence}")
def get_by_index(self, index: int):
"""
diff --git a/edgar/config.py b/edgar/config.py
index c0c8ecf99..f6a9635bc 100644
--- a/edgar/config.py
+++ b/edgar/config.py
@@ -11,7 +11,7 @@
Behavior Configuration (via environment variables):
- EDGAR_VERBOSE_EXCEPTIONS: Enable verbose logging for caught exceptions (default: False)
Set to 'true', '1', 'yes', or 'on' to enable detailed exception logging for debugging.
- By default, caught exceptions (like StatementNotFound) don't spam the console,
+ By default, caught exceptions (like StatementNotFoundError) don't spam the console,
following the Python idiom that caught exceptions should be silent.
Example:
diff --git a/edgar/core.py b/edgar/core.py
index 37b8916ca..c48154c8b 100644
--- a/edgar/core.py
+++ b/edgar/core.py
@@ -261,9 +261,13 @@ def get_bool(value: Optional[str] = None) -> Optional[bool]:
class Result:
- """
- This class represents the result of an operation which can succeed or fail.
- It allows for handling the failures more gracefully that using error handling
+ """Deprecated, removed in 6.0. Nothing imports this.
+
+ It was scaffolding for a flagged-result pattern that never got adopted —
+ zero importers anywhere in the codebase. The pattern that *did* get adopted
+ is NonAccrualResult (edgar/bdc/nonaccrual.py): a frozen dataclass carrying
+ the value plus its provenance and warnings, so a caller can tell "genuinely
+ zero" from "we may have failed to parse". Use that shape instead.
"""
def __init__(self,
@@ -324,10 +328,10 @@ def get_edgar_data_directory() -> Path:
return get_data_directory(create=True)
-class TooManyRequestsException(Exception):
-
- def __init__(self, message: str):
- super().__init__(message)
+# TooManyRequestsException was a dead duplicate of
+# edgar.exceptions.TooManyRequestsError: never raised anywhere, different
+# suffix, same meaning. Kept as a deprecated alias (see the module __getattr__
+# at the end of this file); removed in 6.0.
def filing_date_to_year_quarters(filing_date: str) -> List[Tuple[int, int]]:
@@ -695,3 +699,12 @@ def initialize_rich_logging():
# Turn on rich logging if the environment variable is set
if os.getenv('EDGAR_USE_RICH_LOGGING', '0') == '1':
initialize_rich_logging()
+
+
+# ---------------------------------------------------------------------------
+# Deprecated names (bead edgartools-07lk.10), removed in 6.0.
+# ---------------------------------------------------------------------------
+from edgar._compat import deprecated_alias # noqa: E402
+from edgar.exceptions import TooManyRequestsError # noqa: E402
+
+__getattr__ = deprecated_alias(TooManyRequestsException=TooManyRequestsError)
diff --git a/edgar/dates.py b/edgar/dates.py
index 028736509..ffffde40d 100644
--- a/edgar/dates.py
+++ b/edgar/dates.py
@@ -1,15 +1,19 @@
import datetime
from typing import Optional, Sequence, Tuple, Union
+from edgar._compat import deprecated_alias
+from edgar.exceptions import InvalidDateError
+
__all__ = [
"extract_dates",
- "InvalidDateException"
+ "InvalidDateError",
+ "InvalidDateException", # deprecated alias, removed in 6.0
]
-class InvalidDateException(Exception):
-
- def __init__(self, message: str):
- super().__init__(message)
+# InvalidDateException is now InvalidDateError in edgar.exceptions, under the
+# ValidationError branch (bead edgartools-07lk.10) — so it is now also a
+# ValueError, which is what a bad date string always was.
+__getattr__ = deprecated_alias(InvalidDateException=InvalidDateError)
def extract_dates(
date_str: Union[str, Sequence[Optional[str]]]
@@ -39,23 +43,23 @@ def extract_dates(
and is_range indicates if this was a date range query
Raises:
- InvalidDateException: If the date string cannot be parsed
+ InvalidDateError: If the date string cannot be parsed
"""
if not date_str:
- raise InvalidDateException("Empty date string provided")
+ raise InvalidDateError("Empty date string provided")
# Normalize tuple/list form into the colon-separated string form so the
# rest of the parser stays a single code path. None in either slot is
# treated as the "open" side of a range (same semantics as "start:" / ":end").
if isinstance(date_str, (tuple, list)):
if len(date_str) != 2:
- raise InvalidDateException(
+ raise InvalidDateError(
"Date range tuple must have exactly two elements (start, end); "
f"got {len(date_str)}"
)
start_part, end_part = date_str
if start_part is None and end_part is None:
- raise InvalidDateException(
+ raise InvalidDateError(
"Date range tuple must have at least one non-None bound"
)
date_str = f"{start_part or ''}:{end_part or ''}"
@@ -67,7 +71,7 @@ def extract_dates(
# Handle invalid formats
if len(parts) != (2 if has_colon else 1):
- raise InvalidDateException("Invalid date range format")
+ raise InvalidDateError("Invalid date range format")
# Parse start date
if not has_colon or parts[0]:
@@ -85,7 +89,7 @@ def extract_dates(
# Validate date order if both dates are present
if has_colon and end_date and start_date > end_date:
- raise InvalidDateException(
+ raise InvalidDateError(
f"Invalid date range: start date ({start_date.date()}) "
f"cannot be after end date ({end_date.date()})"
)
@@ -93,7 +97,7 @@ def extract_dates(
return start_date, end_date, has_colon
except ValueError as e:
- raise InvalidDateException(f"""
+ raise InvalidDateError(f"""
Cannot extract a date or date range from string {date_str}
Provide either
1. A date in the format "YYYY-MM-DD" e.g. "2022-10-27"
diff --git a/edgar/documents/exceptions.py b/edgar/documents/exceptions.py
index df8cbb874..bff9d4e5f 100644
--- a/edgar/documents/exceptions.py
+++ b/edgar/documents/exceptions.py
@@ -2,28 +2,13 @@
Custom exceptions for the HTML parser.
"""
-from typing import Any, Dict, Optional
-
-
-class ParsingError(Exception):
- """Base exception for parsing errors."""
-
- def __init__(self,
- message: str,
- context: Optional[Dict[str, Any]] = None,
- suggestions: Optional[list] = None):
- super().__init__(message)
- self.message = message
- self.context = context or {}
- self.suggestions = suggestions or []
-
- def __str__(self):
- result = self.message
- if self.context:
- result += f"\nContext: {self.context}"
- if self.suggestions:
- result += f"\nSuggestions: {', '.join(self.suggestions)}"
- return result
+# ParsingError is one of the four branches of the tree in edgar.exceptions
+# (bead edgartools-07lk.10). Its message/context/suggestions shape moved up into
+# EdgarError, so the subclasses below re-base with no change to their
+# signatures. Imported rather than redefined: same object, so every existing
+# `except ParsingError:` and `from edgar.documents.exceptions import
+# ParsingError` keeps working.
+from edgar.exceptions import ParsingError
class HTMLParsingError(ParsingError):
diff --git a/edgar/entity/__init__.py b/edgar/entity/__init__.py
index a16ca71d0..40f566b20 100644
--- a/edgar/entity/__init__.py
+++ b/edgar/entity/__init__.py
@@ -18,7 +18,7 @@
from edgar.entity.data import Address, CompanyData, EntityData, parse_entity_submissions
from edgar.entity.entity_facts import (
EntityFacts,
- NoCompanyFactsFound,
+ CompanyFactsNotFoundError,
get_company_facts,
)
from edgar.entity.filings import EntityFiling, EntityFilings
@@ -89,7 +89,8 @@
# Exceptions
'CompanyNotFoundError',
- 'NoCompanyFactsFound',
+ 'CompanyFactsNotFoundError',
+ 'NoCompanyFactsFound', # deprecated alias, removed in 6.0
# Constants and utilities
'COMPANY_FORMS',
@@ -100,3 +101,14 @@
'CompanyFiling',
'CompanyFilings',
]
+
+
+# ---------------------------------------------------------------------------
+# Deprecated name (bead edgartools-07lk.10): NoCompanyFactsFound is now
+# CompanyFactsNotFoundError. Same object, so `except NoCompanyFactsFound:`
+# still works. Removed in 6.0.
+# ---------------------------------------------------------------------------
+from edgar._compat import deprecated_alias # noqa: E402
+from edgar.exceptions import CompanyFactsNotFoundError as _CompanyFactsNotFoundError # noqa: E402
+
+__getattr__ = deprecated_alias(NoCompanyFactsFound=_CompanyFactsNotFoundError)
diff --git a/edgar/entity/core.py b/edgar/entity/core.py
index 3a797bf2f..8b634c171 100644
--- a/edgar/entity/core.py
+++ b/edgar/entity/core.py
@@ -31,7 +31,8 @@
from edgar.company_reports import TenK, TenQ
from edgar.display.styles import get_style, SYMBOLS
from edgar.entity.data import Address, CompanyData, EntityData
-from edgar.entity.entity_facts import EntityFacts, NoCompanyFactsFound, get_company_facts
+from edgar.entity.entity_facts import EntityFacts, get_company_facts
+from edgar.exceptions import CompanyFactsNotFoundError, CompanyNotFoundError
from edgar.entity.tickers import get_icon_from_ticker
from edgar.financials import Financials
from edgar.display.formatting import cik_text, datefmt, reverse_name
@@ -62,29 +63,17 @@
'ConceptList',
'get_entity',
'get_company',
- 'NoCompanyFactsFound',
+ 'CompanyFactsNotFoundError',
+ 'NoCompanyFactsFound', # deprecated alias, removed in 6.0
'has_company_filings',
'COMPANY_FORMS',
]
-class CompanyNotFoundError(Exception):
- """Raised when a company cannot be found by ticker, CIK, or name."""
-
- def __init__(self, identifier, suggestions=None):
- self.identifier = identifier
- self.suggestions = suggestions or []
- super().__init__(str(self))
-
- def __str__(self):
- msg = f"Company not found: '{self.identifier}'"
- if self.suggestions:
- suggestions_str = ", ".join(
- f"'{s['ticker']}' ({s['company']})" for s in self.suggestions[:3]
- )
- msg += f"\n Similar: {suggestions_str}"
- msg += "\n Tip: Search by name with find_company(\"...\") or pass a CIK directly."
- return msg
+# CompanyNotFoundError is defined in edgar.exceptions under the NotFoundError
+# branch (bead edgartools-07lk.10) and imported above. It keeps its identifier,
+# its fuzzy suggestions and its message verbatim — it is the one exception this
+# library already documented publicly (docs/api/company.md).
def _get_suggestions(identifier: str, max_suggestions: int = 3):
@@ -481,7 +470,7 @@ def get_facts(self, period_type: Optional[Union[str, 'PeriodType']] = None) -> O
# Apply period type filtering to the facts
return facts.filter_by_period_type(period_type)
return facts
- except NoCompanyFactsFound:
+ except CompanyFactsNotFoundError:
return None
def get_structured_statement(self,
@@ -1815,3 +1804,14 @@ def public_companies() -> Iterable[Company]:
yield c
+
+
+# ---------------------------------------------------------------------------
+# Deprecated name (bead edgartools-07lk.10): NoCompanyFactsFound is now
+# CompanyFactsNotFoundError. Same object, so `except NoCompanyFactsFound:`
+# still works. Removed in 6.0.
+# ---------------------------------------------------------------------------
+from edgar._compat import deprecated_alias # noqa: E402
+from edgar.exceptions import CompanyFactsNotFoundError as _CompanyFactsNotFoundError # noqa: E402
+
+__getattr__ = deprecated_alias(NoCompanyFactsFound=_CompanyFactsNotFoundError)
diff --git a/edgar/entity/data.py b/edgar/entity/data.py
index 793c033ba..cd2ee98dc 100644
--- a/edgar/entity/data.py
+++ b/edgar/entity/data.py
@@ -12,7 +12,7 @@
import pyarrow.compute as pc
from edgar.core import listify, log
-from edgar.dates import InvalidDateException
+from edgar.dates import InvalidDateError
from edgar.entity.filings import EntityFilings, empty_company_filings
from edgar.filtering import filter_by_date, filter_by_form, filter_by_year_quarter
from edgar.display.formatting import reverse_name
@@ -462,7 +462,7 @@ def get_filings(self,
if filing_date:
try:
company_filings = filter_by_date(company_filings, filing_date, 'filing_date')
- except InvalidDateException as e:
+ except InvalidDateError as e:
log.error(e)
return None
diff --git a/edgar/entity/entity_facts.py b/edgar/entity/entity_facts.py
index bfaedbc84..e1a7a4262 100644
--- a/edgar/entity/entity_facts.py
+++ b/edgar/entity/entity_facts.py
@@ -40,12 +40,15 @@
from edgar.storage import get_edgar_data_directory, is_using_local_storage
-class NoCompanyFactsFound(Exception):
- """Exception raised when no company facts are found for a given CIK."""
+# NoCompanyFactsFound is now CompanyFactsNotFoundError in edgar.exceptions
+# (bead edgartools-07lk.10). Its __init__ called super().__init__() with no
+# arguments and set self.message instead, so str(exc) was '' and the message
+# never reached a traceback or a log. The canonical class builds the message
+# and passes it up. Old name kept as a deprecated alias below.
+from edgar._compat import deprecated_alias
+from edgar.exceptions import CompanyFactsNotFoundError
- def __init__(self, cik: int):
- super().__init__()
- self.message = f"""No Company facts found for cik {cik}"""
+__getattr__ = deprecated_alias(NoCompanyFactsFound=CompanyFactsNotFoundError)
def download_company_facts_from_sec(cik: int) -> Dict[str, Any]:
@@ -59,7 +62,7 @@ def download_company_facts_from_sec(cik: int) -> Dict[str, Any]:
except httpx.HTTPStatusError as err:
if err.response.status_code == 404:
log.warning(f"No company facts found on url {company_facts_url}")
- raise NoCompanyFactsFound(cik=cik) from None
+ raise CompanyFactsNotFoundError(cik=cik) from None
else:
raise
@@ -70,11 +73,11 @@ def load_company_facts_from_local(cik: int) -> Dict[str, Any]:
"""
company_facts_dir = get_edgar_data_directory() / "companyfacts"
if not company_facts_dir.exists():
- raise NoCompanyFactsFound(cik=cik)
+ raise CompanyFactsNotFoundError(cik=cik)
cik_int = int(cik) if isinstance(cik, str) else cik
company_facts_file = company_facts_dir / f"CIK{cik_int:010}.json"
if not company_facts_file.exists():
- raise NoCompanyFactsFound(cik=cik)
+ raise CompanyFactsNotFoundError(cik=cik)
return json.loads(company_facts_file.read_text())
@@ -105,7 +108,7 @@ def get_company_facts(cik: int):
CompanyFacts: The company facts
Raises:
- NoCompanyFactsFound: If no facts are found for the given CIK
+ CompanyFactsNotFoundError: If no facts are found for the given CIK
"""
cached = _company_facts_cache.get(cik)
if cached is not None:
diff --git a/edgar/enums.py b/edgar/enums.py
index 05d4fcd6d..307564a60 100644
--- a/edgar/enums.py
+++ b/edgar/enums.py
@@ -359,15 +359,12 @@ def __bool__(self) -> bool:
StatementInput = Union[StatementType, str]
-# FEAT-004: Enhanced Parameter Validation Framework
-class ValidationError(ValueError):
- """Enhanced validation error with suggestions and context."""
-
- def __init__(self, message: str, parameter: str, invalid_value: Any, suggestions: Optional[List[str]] = None):
- self.parameter = parameter
- self.invalid_value = invalid_value
- self.suggestions = suggestions or []
- super().__init__(message)
+# FEAT-004: Enhanced Parameter Validation Framework.
+# ValidationError now lives in edgar.exceptions as the fourth branch of the
+# tree (bead edgartools-07lk.10), with `parameter` and `invalid_value` made
+# optional so a plain bad-input raise can use it too. Still a ValueError, same
+# positional order; re-exported here so existing imports keep working.
+from edgar.exceptions import ValidationError # noqa: E402
def fuzzy_match(value: str, valid_options: Set[str], threshold: float = 0.6) -> List[str]:
diff --git a/edgar/exceptions.py b/edgar/exceptions.py
new file mode 100644
index 000000000..a98c50910
--- /dev/null
+++ b/edgar/exceptions.py
@@ -0,0 +1,447 @@
+"""The one exception vocabulary for edgartools.
+
+Bead: edgartools-07lk.10. Design:
+docs-internal/planning/active-tasks/2026-08-11-07lk10-error-hierarchy-design.md
+
+A root and four branches, which is the whole tree:
+
+ EdgarError
+ ├── TransportError we could not get an answer from SEC
+ ├── NotFoundError you named a thing and it does not exist
+ ├── ParsingError we got bytes and could not build the promised object
+ └── ValidationError your input was wrong before we ever asked
+
+Before this module there were 27 exception classes across ten packages with no
+shared base and no cross-package inheritance, of which exactly two were
+reachable from the top-level namespace. `except` clauses had to name types from
+whichever module happened to raise, and the most common thing we raised was a
+bare `ValueError` (135 of them).
+
+WHY THE BRANCHES INHERIT BUILTINS. `ValidationError` is a `ValueError` and
+`NotFoundError` is a `LookupError`, so converting a raw `raise ValueError` to a
+typed error does not break the `except ValueError:` written against it — the
+conversion is additive rather than a break, which is what lets it ship in 5.x
+instead of waiting for 6.0. The root stays clean: `except ValueError` catching a
+network timeout would be absurd.
+
+RULES FOR THIS MODULE:
+ - stdlib imports only, at module level. Every other edgar module must be able
+ to import this one, so this one may import none of them.
+ - No third-party type (httpx above all) appears in a signature, base class or
+ annotation here. That is what makes a future httpx swap a non-event.
+ - A class whose construction needs edgar internals or a third-party object
+ stays defined in its own module and subclasses a branch from here. Today
+ that means `SSLVerificationError` (it categorizes an httpx error to build
+ its message) and the eight `edgar.documents` parser subclasses.
+"""
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional
+
+__all__ = [
+ # root
+ "EdgarError",
+ # transport
+ "TransportError",
+ "TooManyRequestsError",
+ "IdentityError",
+ "IdentityNotSetError",
+ "SECIdentityError",
+ # not found
+ "NotFoundError",
+ "CompanyNotFoundError",
+ "FilingNotFoundError",
+ "CompanyFactsNotFoundError",
+ "StatementNotFoundError",
+ "SectionNotFoundError",
+ "AttachmentNotFoundError",
+ # parsing
+ "ParsingError",
+ "XBRLProcessingError",
+ "DataObjectError",
+ # validation
+ "ValidationError",
+ "InvalidDateError",
+]
+
+
+class EdgarError(Exception):
+ """Base for every edgartools exception.
+
+ Carries the house message style structurally rather than by good intentions:
+ a message, optional `context` (facts about what happened), optional
+ `suggestions` (what the caller can do), and an optional `docs_url`. All are
+ optional and `EdgarError("something broke")` works, so converting a raise
+ site costs exactly one class name — which is the point, at 154 of them.
+
+ The signature is positionally compatible with the `ParsingError` this
+ replaces (`message, context, suggestions`), so the nine parser subclasses
+ and their call sites re-base without edits.
+ """
+
+ def __init__(self,
+ message: str = "",
+ context: Optional[Dict[str, Any]] = None,
+ suggestions: Optional[List[str]] = None,
+ *,
+ docs_url: Optional[str] = None):
+ super().__init__(message)
+ self.message = message
+ self.context = context or {}
+ self.suggestions = suggestions or []
+ self.docs_url = docs_url
+
+ def __str__(self) -> str:
+ # Rendering is byte-identical to the ParsingError this promotes, so no
+ # existing message changes; docs_url is new and only renders when set.
+ result = self.message
+ if self.context:
+ result += f"\nContext: {self.context}"
+ if self.suggestions:
+ result += f"\nSuggestions: {', '.join(self.suggestions)}"
+ if self.docs_url:
+ result += f"\nDetails: {self.docs_url}"
+ return result
+
+ def __reduce__(self):
+ # Exceptions cross process boundaries whenever a user runs us under
+ # multiprocessing. BaseException.__reduce__ replays `args` through the
+ # constructor, which loses every attribute a subclass set from a
+ # different signature (TooManyRequestsError(url, retry_after) keeps only
+ # the built message in args). Restoring __dict__ afterwards fixes the
+ # family in one place. test_exceptions.py round-trips every class.
+ return (self.__class__, self.args, self.__dict__)
+
+
+# --------------------------------------------------------------------------
+# Transport — we could not get an answer from SEC
+# --------------------------------------------------------------------------
+
+class TransportError(EdgarError):
+ """We could not get an answer from SEC EDGAR.
+
+ Raise when the request never completed or was rejected at the door: network
+ failure, SSL interception, rate limiting, a missing or rejected identity, or
+ an HTTP error status. `status_code` is None for a pure network failure.
+
+ This is the distinction that matters most to callers: a `TransportError`
+ means *we could not ask*, which is never the same answer as "there is no
+ such thing" — see `NotFoundError`.
+ """
+
+ def __init__(self,
+ message: str = "",
+ context: Optional[Dict[str, Any]] = None,
+ suggestions: Optional[List[str]] = None,
+ *,
+ url: Optional[str] = None,
+ status_code: Optional[int] = None,
+ docs_url: Optional[str] = None):
+ super().__init__(message, context, suggestions, docs_url=docs_url)
+ self.url = url
+ self.status_code = status_code
+
+
+class TooManyRequestsError(TransportError):
+ """SEC returned HTTP 429 (Too Many Requests).
+
+ The SEC limits requests to 10 per second. When exceeded, your IP is blocked
+ for approximately 10 minutes. Continuing to send requests during this period
+ will extend the block duration.
+
+ Important: Do NOT retry immediately - wait for the block to expire.
+ """
+
+ BLOCK_DURATION_MINUTES = 10
+
+ def __init__(self, url=None, retry_after: Optional[int] = None):
+ # Message built exactly as it was in edgar/httprequests.py — this class
+ # is the house style's best example and moving it must not dilute it.
+ header = f"""
+SEC Rate Limit Exceeded (HTTP 429)
+==================================
+
+URL: {url}"""
+
+ if retry_after:
+ wait_info = f"""
+Retry-After: {retry_after} seconds (from SEC response header)"""
+ else:
+ wait_info = f"""
+Estimated Wait: ~{self.BLOCK_DURATION_MINUTES} minutes"""
+
+ cause = """
+
+What happened:
+ Your request rate exceeded the SEC's limit of 10 requests/second.
+ Your IP address has been temporarily blocked."""
+
+ warning = """
+
+{warning} Important: Do NOT retry immediately!
+ Continuing to send requests during the block period will EXTEND it.
+ The SEC penalizes continued requests during timeout.""".format(warning="⚠")
+
+ solution = f"""
+
+What to do:
+ 1. Wait at least {self.BLOCK_DURATION_MINUTES} minutes before retrying
+ 2. Reduce your request rate (edgartools defaults to 9 req/sec)
+ 3. Consider using local storage: download_edgar_data()
+
+To adjust rate limit:
+ import os
+ os.environ['EDGAR_RATE_LIMIT_PER_SEC'] = '5' # More conservative"""
+
+ footer = """
+
+Details: https://www.sec.gov/os/webmaster-faq#developers"""
+
+ message = f"{header}{wait_info}{cause}{warning}{solution}{footer}"
+ super().__init__(message, url=url, status_code=429)
+ self.retry_after = retry_after
+
+ def __str__(self) -> str:
+ # The message is already a full banner; the base's context/suggestions
+ # rendering would only append noise to it.
+ return self.message
+
+
+class IdentityError(TransportError):
+ """EDGAR_IDENTITY is missing or was rejected, so no request can succeed.
+
+ A transport error rather than a validation one on purpose: nothing the
+ caller passed to *this* call was wrong, and every subsequent request fails
+ the same way until the identity is fixed.
+ """
+
+
+class IdentityNotSetError(IdentityError):
+ """Client-side pre-check: no identity is configured.
+
+ Was `IdentityNotSetException` in edgar.httprequests, which said only
+ "User-Agent identity is not set" and left the reader to discover
+ `set_identity()` on their own.
+ """
+
+ def __init__(self, message: str = "", **kwargs):
+ if not message:
+ message = "SEC requires a User-Agent identifying you, and none is set."
+ kwargs.setdefault("suggestions", [
+ 'set_identity("Your Name your.email@example.com")',
+ "or set the EDGAR_IDENTITY environment variable to the same string",
+ ])
+ kwargs.setdefault("docs_url",
+ "https://www.sec.gov/os/webmaster-faq#developers")
+ super().__init__(message, **kwargs)
+
+
+class SECIdentityError(IdentityError):
+ """Server-side: SEC rejected the request because of its identity.
+
+ Distinct from `IdentityNotSetError` by *who noticed* — we checked before
+ sending, or SEC told us after. Same root cause, same fix, so they share a
+ parent and `except IdentityError` catches both.
+ """
+
+
+# --------------------------------------------------------------------------
+# Not found — you named a thing and it does not exist
+# --------------------------------------------------------------------------
+
+class NotFoundError(EdgarError, LookupError):
+ """You asked for a specific named thing and it does not exist.
+
+ Raise when a *dereference* fails: an identifier, key, section name or
+ statement name that resolves to nothing. Do not raise it for a probe whose
+ documented answer may legitimately be "no" — `Filing.xbrl()` returning None
+ for a filing without XBRL is an answer, not a failure.
+
+ Inherits `LookupError` so that this reads as what it is to any Python
+ programmer, and so converting a `KeyError` raise site is additive.
+ """
+
+
+def _company_not_found_message(identifier, suggestions) -> str:
+ msg = f"Company not found: '{identifier}'"
+ if suggestions:
+ suggestions_str = ", ".join(
+ f"'{s['ticker']}' ({s['company']})" for s in suggestions[:3]
+ )
+ msg += f"\n Similar: {suggestions_str}"
+ msg += "\n Tip: Search by name with find_company(\"...\") or pass a CIK directly."
+ return msg
+
+
+class CompanyNotFoundError(NotFoundError):
+ """A company could not be found by ticker, CIK, or name."""
+
+ def __init__(self, identifier, suggestions=None):
+ # `suggestions` here is the fuzzy-match structure (dicts of ticker and
+ # company), not the base class's list of strings — kept as it was,
+ # because this message is already public and documented. Building it
+ # through a module function rather than `str(self)` keeps `.suggestions`
+ # a single assignment; calling up with `str(self)` would have the base
+ # reset it from its own parameter.
+ super().__init__(_company_not_found_message(identifier, suggestions or []))
+ self.identifier = identifier
+ self.suggestions = suggestions or []
+
+ def __str__(self) -> str:
+ return _company_not_found_message(self.identifier, self.suggestions)
+
+
+class FilingNotFoundError(NotFoundError):
+ """SEC has no filing at that accession number.
+
+ Canonical name for `SECFilingNotFoundError`.
+ """
+
+
+class CompanyFactsNotFoundError(NotFoundError):
+ """The SEC Facts API has no data for this CIK.
+
+ Was `NoCompanyFactsFound`, whose `__init__` called `super().__init__()` with
+ no arguments and set `self.message` instead — so `str(exc)` was `''` and the
+ message never reached a traceback or a log. Building the message and passing
+ it up makes that unrepresentable.
+ """
+
+ def __init__(self, cik=None, message: str = "", **kwargs):
+ self.cik = cik
+ if not message:
+ message = f"No Company facts found for cik {cik}"
+ super().__init__(message, **kwargs)
+
+
+class StatementNotFoundError(NotFoundError):
+ """A financial statement could not be resolved with sufficient confidence.
+
+ Canonical name for `StatementNotFound`. Keeps that class's keyword
+ signature and its rendered message exactly; it is no longer a dataclass,
+ because a dataclass cannot pass a built message up to the base.
+ """
+
+ def __init__(self,
+ statement_type: str = "",
+ confidence: float = 0.0,
+ found_statements: Optional[List[str]] = None,
+ entity_name: str = "Unknown",
+ cik: str = "Unknown",
+ period_of_report: str = "Unknown",
+ reason: str = ""):
+ self.statement_type = statement_type
+ self.confidence = confidence
+ self.found_statements = found_statements if found_statements is not None else []
+ self.entity_name = entity_name
+ self.cik = cik
+ self.period_of_report = period_of_report
+ self.reason = reason
+ super().__init__(str(self))
+
+ def __str__(self) -> str:
+ base_msg = (f"Failed to resolve {self.statement_type} for {self.entity_name} "
+ f"(CIK: {self.cik}, Period: {self.period_of_report})")
+ if self.confidence > 0:
+ confidence_msg = f"Low confidence match: {self.confidence:.2f}"
+ else:
+ confidence_msg = "No matching statements found"
+
+ if self.found_statements:
+ found_msg = f"Found statements: {self.found_statements}"
+ else:
+ found_msg = "No statements available"
+
+ details = f"{base_msg}. {confidence_msg}. {found_msg}"
+ if self.reason:
+ details += f". {self.reason}"
+
+ return details
+
+
+class SectionNotFoundError(NotFoundError, KeyError):
+ """A named section of a report does not exist (e.g. `tenk['Item 7A']`).
+
+ Also a `KeyError`, so the `except KeyError:` a caller already wrote around
+ item access keeps working when this starts being raised in 6.0. `EdgarError`
+ precedes `KeyError` in the MRO, so `str(exc)` is the message rather than
+ KeyError's repr-quoted form.
+
+ Raised from the `__getitem__` flip (PR3 of this bead); defined here so the
+ vocabulary lands in one piece.
+ """
+
+
+class AttachmentNotFoundError(NotFoundError, KeyError):
+ """No attachment matches that key or sequence number.
+
+ Also a `KeyError` — `Attachments.__getitem__` already raised one, and this
+ narrows that to a typed error without breaking the handler.
+ """
+
+
+# --------------------------------------------------------------------------
+# Parsing — we got bytes and could not build the promised object
+# --------------------------------------------------------------------------
+
+class ParsingError(EdgarError):
+ """We fetched the data and could not build the object we promised.
+
+ This is `edgar.documents.exceptions.ParsingError` promoted to a branch: its
+ message/context/suggestions shape moved up into `EdgarError`, so the parser
+ subclasses that lived under it re-base with no change to their signatures.
+ """
+
+
+class XBRLProcessingError(ParsingError):
+ """An error occurred while processing XBRL."""
+
+
+class DataObjectError(ParsingError):
+ """`filing.obj()` could not build a data object for a form it supports.
+
+ Canonical name for `DataObjectException`. Takes primitives rather than a
+ Filing, because this module imports nothing from edgar; the deprecated
+ alias keeps the Filing-taking constructor.
+ """
+
+ def __init__(self, message: str = "", *, form=None, accession_no=None, **kwargs):
+ self.form = form
+ self.accession_no = accession_no
+ if not message:
+ message = f"Could not create a data object for Form {form} filing: {accession_no}"
+ super().__init__(message, **kwargs)
+
+
+# --------------------------------------------------------------------------
+# Validation — your input was wrong before we ever asked
+# --------------------------------------------------------------------------
+
+class ValidationError(EdgarError, ValueError):
+ """The caller's input was invalid before any request was made.
+
+ Hoisted from `edgar.enums` with `parameter` and `invalid_value` made
+ optional, so the enum validation framework and a plain bad-input raise
+ share one class. Positional order is unchanged
+ (`message, parameter, invalid_value, suggestions`).
+
+ IS-A `ValueError`, which is what makes converting the 135 raw `ValueError`
+ raises additive rather than a break.
+ """
+
+ def __init__(self,
+ message: str,
+ parameter: Optional[str] = None,
+ invalid_value: Any = None,
+ suggestions: Optional[List[str]] = None,
+ **kwargs):
+ super().__init__(message, suggestions=suggestions, **kwargs)
+ self.parameter = parameter
+ self.invalid_value = invalid_value
+
+
+class InvalidDateError(ValidationError):
+ """A date or date range could not be understood.
+
+ Canonical name for `InvalidDateException`.
+ """
diff --git a/edgar/httprequests.py b/edgar/httprequests.py
index 24d3be234..7b12e966c 100644
--- a/edgar/httprequests.py
+++ b/edgar/httprequests.py
@@ -23,6 +23,7 @@
logging.getLogger("stamina").setLevel(logging.ERROR)
from edgar.core import get_edgar_data_directory, text_extensions
+from edgar.exceptions import IdentityNotSetError, TooManyRequestsError, TransportError
from edgar.httpclient import async_http_client, http_client
"""
@@ -136,70 +137,10 @@ def should_retry(exc: Exception) -> bool:
return isinstance(exc, RETRYABLE_EXCEPTIONS)
-class TooManyRequestsError(Exception):
- """
- Raised when SEC returns HTTP 429 (Too Many Requests).
-
- The SEC limits requests to 10 per second. When exceeded, your IP is blocked
- for approximately 10 minutes. Continuing to send requests during this period
- will extend the block duration.
-
- Important: Do NOT retry immediately - wait for the block to expire.
- """
-
- BLOCK_DURATION_MINUTES = 10
-
- def __init__(self, url, retry_after: int = None):
- self.url = url
- self.retry_after = retry_after # From Retry-After header, if present
-
- # Build informative error message
- header = f"""
-SEC Rate Limit Exceeded (HTTP 429)
-==================================
-
-URL: {self.url}"""
-
- if retry_after:
- wait_info = f"""
-Retry-After: {retry_after} seconds (from SEC response header)"""
- else:
- wait_info = f"""
-Estimated Wait: ~{self.BLOCK_DURATION_MINUTES} minutes"""
-
- cause = """
-
-What happened:
- Your request rate exceeded the SEC's limit of 10 requests/second.
- Your IP address has been temporarily blocked."""
-
- warning = """
-
-{warning} Important: Do NOT retry immediately!
- Continuing to send requests during the block period will EXTEND it.
- The SEC penalizes continued requests during timeout.""".format(warning="\u26A0")
-
- solution = f"""
-
-What to do:
- 1. Wait at least {self.BLOCK_DURATION_MINUTES} minutes before retrying
- 2. Reduce your request rate (edgartools defaults to 9 req/sec)
- 3. Consider using local storage: download_edgar_data()
-
-To adjust rate limit:
- import os
- os.environ['EDGAR_RATE_LIMIT_PER_SEC'] = '5' # More conservative"""
-
- footer = """
-
-Details: https://www.sec.gov/os/webmaster-faq#developers"""
-
- message = f"{header}{wait_info}{cause}{warning}{solution}{footer}"
- super().__init__(message)
-
-
-class IdentityNotSetException(Exception):
- pass
+# TooManyRequestsError and IdentityNotSetError are defined in edgar.exceptions
+# (bead edgartools-07lk.10) and re-exported here, so `from edgar.httprequests
+# import TooManyRequestsError` keeps working. IdentityNotSetException is the
+# deprecated spelling \u2014 see the module __getattr__ at the end of this file.
# =============================================================================
@@ -537,7 +478,7 @@ def _build_solution_section(category: SSLErrorCategory, diag: SSLDiagnostic) ->
configure_http(verify_ssl=False)"""
-class SSLVerificationError(Exception):
+class SSLVerificationError(TransportError):
"""
Raised when SSL certificate verification fails.
@@ -597,14 +538,14 @@ def __init__(self, original_error, url):
# edgartools-07lk.10 — this is the vocabulary those call sites need today, and it
# is deliberately a tuple of existing types so it introduces no new public class.
#
-# IdentityNotSetException belongs here despite not being a transport error: it
+# IdentityNotSetError belongs here despite not being a transport error: it
# means no request can be made at all, so reporting it as "not found" is the same
# lie in a different costume.
TRANSPORT_ERRORS = (
HTTPError, # httpx base: connect, read, timeout, protocol, status
TooManyRequestsError, # SEC rate limit, after retries are exhausted
SSLVerificationError,
- IdentityNotSetException,
+ IdentityNotSetError,
)
@@ -656,7 +597,7 @@ def wrapper(url, identity=None, identity_callable=None, *args, **kwargs):
else:
identity = os.environ.get("EDGAR_IDENTITY")
if identity is None:
- raise IdentityNotSetException("User-Agent identity is not set")
+ raise IdentityNotSetError()
headers = kwargs.get("headers", {})
headers["User-Agent"] = identity
@@ -676,7 +617,7 @@ def wrapper(client, url, identity=None, identity_callable=None, *args, **kwargs)
else:
identity = os.environ.get("EDGAR_IDENTITY")
if identity is None:
- raise IdentityNotSetException("User-Agent identity is not set")
+ raise IdentityNotSetError()
headers = kwargs.get("headers", {})
headers["User-Agent"] = identity
@@ -1413,3 +1354,13 @@ def download_datafile(data_url: str, local_directory: Optional[Path] = None) ->
download_filename = local_directory / filename
download_file(data_url, path=download_filename)
return download_filename
+
+
+# ---------------------------------------------------------------------------
+# Deprecated names (bead edgartools-07lk.10). Same objects as the canonical
+# classes, so `except IdentityNotSetException:` still works; touching the name
+# warns. Removed in 6.0.
+# ---------------------------------------------------------------------------
+from edgar._compat import deprecated_alias # noqa: E402
+
+__getattr__ = deprecated_alias(IdentityNotSetException=IdentityNotSetError)
diff --git a/edgar/sgml/sgml_parser.py b/edgar/sgml/sgml_parser.py
index 3669ddd6e..408f357b6 100644
--- a/edgar/sgml/sgml_parser.py
+++ b/edgar/sgml/sgml_parser.py
@@ -16,7 +16,9 @@
# Some real SEC filings (e.g., 10-Ks with embedded images) can exceed 300MB.
_MAX_CONTENT_SIZE = 500 * 1024 * 1024
-__all__ = ['SGMLParser', 'SGMLFormatType', 'SGMLDocument', 'SECIdentityError', 'SECFilingNotFoundError', 'SECHTMLResponseError']
+__all__ = ['SGMLParser', 'SGMLFormatType', 'SGMLDocument', 'SECIdentityError',
+ 'FilingNotFoundError', 'SECHTMLResponseError',
+ 'SECFilingNotFoundError'] # last is a deprecated alias, removed in 6.0
# Pre-compiled patterns for content extraction
_TEXT_RE = re.compile(r'([\s\S]*?)', re.DOTALL | re.IGNORECASE)
@@ -33,19 +35,19 @@
)
-class SECIdentityError(Exception):
- """Raised when SEC rejects request due to invalid or missing EDGAR_IDENTITY"""
- pass
+# These three are branches of the tree in edgar.exceptions (bead
+# edgartools-07lk.10): SECIdentityError joins IdentityNotSetError under
+# IdentityError (same root cause, noticed at different layers), and a filing
+# that does not exist is a NotFoundError like any other missing thing.
+from edgar._compat import deprecated_alias
+from edgar.exceptions import FilingNotFoundError, SECIdentityError, TransportError
-class SECFilingNotFoundError(Exception):
- """Raised when SEC returns error for non-existent filing"""
- pass
+class SECHTMLResponseError(TransportError):
+ """Raised when SEC returns HTML content instead of expected SGML"""
-class SECHTMLResponseError(Exception):
- """Raised when SEC returns HTML content instead of expected SGML"""
- pass
+__getattr__ = deprecated_alias(SECFilingNotFoundError=FilingNotFoundError)
class SGMLFormatType(Enum):
SEC_DOCUMENT = "sec_document" # ... style
@@ -185,7 +187,7 @@ def _raise_sec_html_error(content: str):
Raises:
SECIdentityError: For identity-related errors
- SECFilingNotFoundError: For missing filing errors
+ FilingNotFoundError: For missing filing errors
SECHTMLResponseError: For other HTML/XML responses
"""
# Check for identity error
@@ -198,14 +200,14 @@ def _raise_sec_html_error(content: str):
# Check for AWS S3 NoSuchKey error (XML format)
if "NoSuchKey" in content and "The specified key does not exist." in content:
- raise SECFilingNotFoundError(
+ raise FilingNotFoundError(
"SEC filing not found - the specified key does not exist in EDGAR archives. "
"Check that the accession number and filing date are correct."
)
# Check for general not found errors
if "Not Found" in content or "404" in content:
- raise SECFilingNotFoundError(
+ raise FilingNotFoundError(
"SEC filing not found. Check that the accession number and filing date are correct."
)
diff --git a/edgar/storage/_local.py b/edgar/storage/_local.py
index 7ffdc9538..b94d67339 100644
--- a/edgar/storage/_local.py
+++ b/edgar/storage/_local.py
@@ -11,6 +11,8 @@
import pandas as pd
from bs4 import BeautifulSoup
+
+from edgar.exceptions import EdgarError
from httpx import AsyncClient, HTTPStatusError
from tqdm.auto import tqdm
@@ -59,7 +61,8 @@ def _run_coroutine(coroutine):
'parse_file_size',
'latest_filing_date']
-class DirectoryBrowsingNotAllowed(Exception):
+
+class DirectoryBrowsingNotAllowed(EdgarError):
def __init__(self, url: str, message: str = "Directory browsing is not allowed for this URL."):
super().__init__(f"{message} \nurl: {url}")
diff --git a/edgar/xbrl/current_period.py b/edgar/xbrl/current_period.py
index d1dc14d93..38dd76c84 100644
--- a/edgar/xbrl/current_period.py
+++ b/edgar/xbrl/current_period.py
@@ -23,7 +23,7 @@
from edgar.core import log
from edgar.richtools import repr_rich
from edgar.xbrl.dimensions import is_breakdown_dimension
-from edgar.xbrl.exceptions import StatementNotFound
+from edgar.exceptions import StatementNotFoundError
from edgar.xbrl.statements import is_xbrl_structural_element
if TYPE_CHECKING:
@@ -443,7 +443,7 @@ def _get_statement_dataframe(self, statement_type: str, raw_concepts: bool = Fal
- balance, weight, preferred_sign, parent_concept, parent_abstract_concept
Raises:
- StatementNotFound: If the requested statement type is not available
+ StatementNotFoundError: If the requested statement type is not available
"""
try:
# Select appropriate period based on statement type
@@ -458,7 +458,7 @@ def _get_statement_dataframe(self, statement_type: str, raw_concepts: bool = Fal
if not statement_data:
entity_name = getattr(self.xbrl, 'entity_name', 'Unknown')
- raise StatementNotFound(
+ raise StatementNotFoundError(
statement_type=statement_type,
confidence=0.0,
found_statements=[],
@@ -557,7 +557,7 @@ def _get_statement_dataframe(self, statement_type: str, raw_concepts: bool = Fal
if VERBOSE_EXCEPTIONS:
log.error(f"Error retrieving {statement_type} for current period: {str(e)}")
entity_name = getattr(self.xbrl, 'entity_name', 'Unknown')
- raise StatementNotFound(
+ raise StatementNotFoundError(
statement_type=statement_type,
confidence=0.0,
found_statements=[],
@@ -578,7 +578,7 @@ def _get_statement_object(self, statement_type: str, include_dimensions: bool =
Statement object with current period filtering applied
Raises:
- StatementNotFound: If the requested statement type is not available
+ StatementNotFoundError: If the requested statement type is not available
"""
try:
# Import here to avoid circular imports
@@ -591,7 +591,7 @@ def _get_statement_object(self, statement_type: str, include_dimensions: bool =
if not found_role:
entity_name = getattr(self.xbrl, 'entity_name', 'Unknown')
- raise StatementNotFound(
+ raise StatementNotFoundError(
statement_type=statement_type,
confidence=0.0,
found_statements=[],
@@ -616,7 +616,7 @@ def _get_statement_object(self, statement_type: str, include_dimensions: bool =
if VERBOSE_EXCEPTIONS:
log.error(f"Error retrieving {statement_type} statement object for current period: {str(e)}")
entity_name = getattr(self.xbrl, 'entity_name', 'Unknown')
- raise StatementNotFound(
+ raise StatementNotFoundError(
statement_type=statement_type,
confidence=0.0,
found_statements=[],
@@ -777,7 +777,7 @@ def to_dict(self) -> Dict[str, Any]:
if not df.empty:
# Convert DataFrame to list of dicts for JSON serialization
result['statements'][stmt_type] = df.to_dict('records')
- except StatementNotFound:
+ except StatementNotFoundError:
result['statements'][stmt_type] = None
return result
diff --git a/edgar/xbrl/exceptions.py b/edgar/xbrl/exceptions.py
index c7afc8b3f..5a34902c8 100644
--- a/edgar/xbrl/exceptions.py
+++ b/edgar/xbrl/exceptions.py
@@ -1,36 +1,18 @@
"""
XBRL-specific exceptions.
-"""
-
-from dataclasses import dataclass
-from typing import List
-
-@dataclass
-class StatementNotFound(Exception):
- """Exception raised when a statement cannot be resolved with sufficient confidence."""
- statement_type: str
- confidence: float
- found_statements: List[str]
- entity_name: str = "Unknown"
- cik: str = "Unknown"
- period_of_report: str = "Unknown"
- reason: str = ""
+`StatementNotFound` is now `StatementNotFoundError` in `edgar.exceptions`
+(bead edgartools-07lk.10), under the `NotFoundError` branch. It keeps the same
+keyword signature and renders the same message; it is no longer a dataclass,
+because a dataclass cannot pass its built message up to the base class.
- def __str__(self):
- base_msg = f"Failed to resolve {self.statement_type} for {self.entity_name} (CIK: {self.cik}, Period: {self.period_of_report})"
- if self.confidence > 0:
- confidence_msg = f"Low confidence match: {self.confidence:.2f}"
- else:
- confidence_msg = "No matching statements found"
-
- if self.found_statements:
- found_msg = f"Found statements: {self.found_statements}"
- else:
- found_msg = "No statements available"
+The old name below is a deprecated alias for the same object, so
+`except StatementNotFound:` and `pytest.raises(StatementNotFound)` still work.
+Removed in 6.0.
+"""
+from edgar._compat import deprecated_alias
+from edgar.exceptions import StatementNotFoundError
- details = f"{base_msg}. {confidence_msg}. {found_msg}"
- if self.reason:
- details += f". {self.reason}"
+__all__ = ["StatementNotFoundError"]
- return details
+__getattr__ = deprecated_alias(StatementNotFound=StatementNotFoundError)
diff --git a/edgar/xbrl/models.py b/edgar/xbrl/models.py
index 01a0ac4a9..879e532a1 100644
--- a/edgar/xbrl/models.py
+++ b/edgar/xbrl/models.py
@@ -8,6 +8,8 @@
from pydantic import BaseModel, Field
+from edgar.exceptions import XBRLProcessingError
+
# Constants for label roles
STANDARD_LABEL = "http://www.xbrl.org/2003/role/label"
TERSE_LABEL = "http://www.xbrl.org/2003/role/terseLabel"
@@ -348,6 +350,5 @@ class Table(BaseModel):
context_element: str = "segment"
-class XBRLProcessingError(Exception):
- """Exception raised for errors during XBRL processing."""
- pass
+# Defined in edgar.exceptions under the ParsingError branch (07lk.10);
+# re-exported here so existing imports keep working.
diff --git a/edgar/xbrl/statement_resolver.py b/edgar/xbrl/statement_resolver.py
index 22fa4005c..8a5525fec 100644
--- a/edgar/xbrl/statement_resolver.py
+++ b/edgar/xbrl/statement_resolver.py
@@ -12,7 +12,7 @@
from edgar.config import VERBOSE_EXCEPTIONS
from edgar.core import log
-from edgar.xbrl.exceptions import StatementNotFound
+from edgar.exceptions import StatementNotFoundError
from edgar.xbrl.statements import statement_to_concepts
@@ -1286,7 +1286,7 @@ def find_statement(self, statement_type: str, is_parenthetical: bool = False,
period_of_report = getattr(self.xbrl, 'period_of_report', 'Unknown')
if len(statements) == 0:
- raise StatementNotFound(
+ raise StatementNotFoundError(
statement_type=statement_type,
confidence=conf,
found_statements=[],
@@ -1297,7 +1297,7 @@ def find_statement(self, statement_type: str, is_parenthetical: bool = False,
)
elif conf < 0.3:
found_statements = [s['definition'] for s in statements]
- raise StatementNotFound(
+ raise StatementNotFoundError(
statement_type=statement_type,
confidence=conf,
found_statements=found_statements,
diff --git a/edgar/xbrl/statements.py b/edgar/xbrl/statements.py
index ab01cd7fb..0be63e50b 100644
--- a/edgar/xbrl/statements.py
+++ b/edgar/xbrl/statements.py
@@ -16,7 +16,7 @@
from edgar.richtools import repr_rich
from edgar.xbrl.dimensions import is_breakdown_dimension
-from edgar.xbrl.exceptions import StatementNotFound
+from edgar.exceptions import ParsingError, StatementNotFoundError
from edgar.xbrl.presentation import StatementView, ViewType, normalize_view
# XBRL structural element patterns (Issue #03zg)
@@ -344,7 +344,9 @@ class ExtensionArc:
}
-class StatementValidationError(Exception):
+
+
+class StatementValidationError(ParsingError):
"""Raised when statement validation fails."""
pass
@@ -467,12 +469,12 @@ def extension_arcs(self, include_values: bool = False) -> List[ExtensionArc]:
from edgar.xbrl.core import STANDARD_TAXONOMIES, STANDARD_LABEL, split_element_id
# Resolve to the statement's role URI using the same path render() uses.
- # find_statement() raises StatementNotFound for unresolvable inputs;
+ # find_statement() raises StatementNotFoundError for unresolvable inputs;
# this method should fail silent and return [] instead.
lookup_key = self.canonical_type if self.canonical_type else self.role_or_type
try:
_, role_uri, _ = self.xbrl.find_statement(lookup_key)
- except StatementNotFound:
+ except StatementNotFoundError:
return []
if not role_uri:
return []
@@ -2435,7 +2437,7 @@ def _handle_statement_error(self, e: Exception, statement_type: str) -> Optional
"""
from edgar.core import log
- if isinstance(e, StatementNotFound):
+ if isinstance(e, StatementNotFoundError):
# Custom exception already has detailed context
log.warning(str(e))
else:
diff --git a/edgar/xbrl/stitching/core.py b/edgar/xbrl/stitching/core.py
index c72db8b6d..265185ca1 100644
--- a/edgar/xbrl/stitching/core.py
+++ b/edgar/xbrl/stitching/core.py
@@ -11,7 +11,7 @@
from typing import Any, Dict, List, Optional, Set, Tuple, Union
from edgar.xbrl.core import format_date, parse_date
-from edgar.xbrl.exceptions import StatementNotFound
+from edgar.exceptions import StatementNotFoundError
from edgar.xbrl.standardization import standardize_statement
from edgar.xbrl.stitching.ordering import StatementOrderingManager
from edgar.xbrl.stitching.periods import determine_optimal_periods
@@ -1015,7 +1015,7 @@ def stitch_statements(
# cash flow presentation role). Issue #683.
try:
statement = xbrl.get_statement_by_type(statement_type, include_dimensions=include_dimensions)
- except StatementNotFound:
+ except StatementNotFoundError:
continue
if statement:
# Only include the specific period from this statement
diff --git a/edgar/xbrl/xbrl.py b/edgar/xbrl/xbrl.py
index 5487c07b5..a9ad743d4 100644
--- a/edgar/xbrl/xbrl.py
+++ b/edgar/xbrl/xbrl.py
@@ -35,11 +35,14 @@
from edgar.xbrl.period_selector import select_periods
from edgar.xbrl.periods import get_period_views
from edgar.xbrl.rendering import RenderedStatement, generate_rich_representation, render_statement
+from edgar.exceptions import NotFoundError
from edgar.xbrl.statement_resolver import StatementResolver
from edgar.xbrl.statements import statement_to_concepts
-class XBRLFilingWithNoXbrlData(Exception):
+
+
+class XBRLFilingWithNoXbrlData(NotFoundError):
"""Exception raised when a filing does not contain XBRL data."""
def __init__(self, message: str):
diff --git a/tests/issues/regression/test_exceptions_tree.py b/tests/issues/regression/test_exceptions_tree.py
new file mode 100644
index 000000000..de5e1ee29
--- /dev/null
+++ b/tests/issues/regression/test_exceptions_tree.py
@@ -0,0 +1,301 @@
+"""
+The exception tree: one vocabulary, and the promises that make it usable.
+
+Bead: edgartools-07lk.10
+
+There were 27 exception classes across ten packages with no shared base and no
+cross-package inheritance, of which exactly two were reachable from the
+top-level namespace. You could not write `except ` without knowing
+which module happened to raise, and the most common thing we raised was a bare
+`ValueError` — 135 of them.
+
+WHAT THESE TESTS PROTECT, in order of how quietly each could break:
+
+ 1. The builtin bases. `ValidationError` IS-A `ValueError` and
+ `NotFoundError` IS-A `LookupError` — that is the whole reason converting
+ raise sites is additive rather than a break, and it would be undone by a
+ one-word edit to a base class list.
+ 2. The MRO order. `EdgarError` must precede `KeyError` so `str(exc)` is the
+ message, not KeyError's repr-quoted form.
+ 3. That importing edgar emits no DeprecationWarning. This is the one that
+ bites: it fires the moment our own code imports a deprecated alias, which
+ would spray warnings at users for something they cannot fix.
+ 4. That every alias resolves to the *same object* as its canonical class, so
+ `except OldName:` and `pytest.raises(OldName)` still work.
+"""
+import ast
+import pickle
+import warnings
+from pathlib import Path
+
+import pytest
+
+import edgar
+import edgar.exceptions as ex
+
+# Constructor arguments for each public class, so these tests can instantiate
+# the whole tree. A new public exception without an entry here fails
+# test_every_public_class_is_constructible — deliberately, because an exception
+# nobody can construct in a test is one nobody has looked at.
+CONSTRUCTOR_ARGS = {
+ "EdgarError": ("boom",),
+ "TransportError": ("boom",),
+ "TooManyRequestsError": ("https://www.sec.gov/x",),
+ "IdentityError": ("boom",),
+ "IdentityNotSetError": (),
+ "SECIdentityError": ("boom",),
+ "NotFoundError": ("boom",),
+ "CompanyNotFoundError": ("NOSUCHTICKER",),
+ "FilingNotFoundError": ("boom",),
+ "CompanyFactsNotFoundError": (99999999,),
+ "StatementNotFoundError": (),
+ "SectionNotFoundError": ("boom",),
+ "AttachmentNotFoundError": ("boom",),
+ "ParsingError": ("boom",),
+ "XBRLProcessingError": ("boom",),
+ "DataObjectError": ("boom",),
+ "ValidationError": ("boom",),
+ "InvalidDateError": ("boom",),
+}
+
+# Deprecated spelling -> (module it must still be importable from, canonical class)
+DEPRECATED_ALIASES = {
+ "InvalidDateException": ("edgar.dates", ex.InvalidDateError),
+ "StatementNotFound": ("edgar.xbrl.exceptions", ex.StatementNotFoundError),
+ "NoCompanyFactsFound": ("edgar.entity.entity_facts", ex.CompanyFactsNotFoundError),
+ "TooManyRequestsException": ("edgar.core", ex.TooManyRequestsError),
+ "SECFilingNotFoundError": ("edgar.sgml.sgml_parser", ex.FilingNotFoundError),
+ "IdentityNotSetException": ("edgar.httprequests", ex.IdentityNotSetError),
+}
+
+
+def _instance(name):
+ return getattr(ex, name)(*CONSTRUCTOR_ARGS[name])
+
+
+def test_every_public_class_is_constructible():
+ """Guard the guard: the tests below all instantiate the tree."""
+ missing = sorted(set(ex.__all__) - set(CONSTRUCTOR_ARGS))
+ assert not missing, (
+ f"{missing} are exported from edgar.exceptions but have no constructor "
+ f"arguments in this file, so nothing below exercises them. Add an entry."
+ )
+
+
+@pytest.mark.parametrize("name", sorted(ex.__all__))
+def test_every_public_exception_is_an_edgar_error(name):
+ """One root, or `except EdgarError` is a lie."""
+ cls = getattr(ex, name)
+ assert issubclass(cls, ex.EdgarError), f"{name} is outside the tree"
+
+
+def test_the_tree_has_four_branches():
+ """The shape is the documentation. A fifth branch is a design decision."""
+ branches = {c for c in (ex.TransportError, ex.NotFoundError,
+ ex.ParsingError, ex.ValidationError)}
+ direct_children = {getattr(ex, n) for n in ex.__all__
+ if ex.EdgarError in getattr(ex, n).__bases__}
+ assert direct_children == branches, (
+ f"direct children of EdgarError are {sorted(c.__name__ for c in direct_children)}; "
+ f"expected exactly the four branches. Adding one is a decision to make "
+ f"deliberately — see the design doc for why four."
+ )
+
+
+def test_validation_error_is_a_value_error():
+ """The reason converting 135 raw `raise ValueError` sites is additive.
+
+ Without this, every `except ValueError:` written against those call sites
+ stops catching them.
+ """
+ assert issubclass(ex.ValidationError, ValueError)
+ assert issubclass(ex.InvalidDateError, ValueError)
+ with pytest.raises(ValueError):
+ raise ex.ValidationError("bad input")
+
+
+def test_not_found_is_a_lookup_error():
+ assert issubclass(ex.NotFoundError, LookupError)
+ with pytest.raises(LookupError):
+ raise ex.CompanyNotFoundError("NOSUCHTICKER")
+
+
+@pytest.mark.parametrize("name", ["SectionNotFoundError", "AttachmentNotFoundError"])
+def test_getitem_errors_are_key_errors(name):
+ """`except KeyError:` around item access keeps working when these are raised."""
+ cls = getattr(ex, name)
+ assert issubclass(cls, KeyError)
+ with pytest.raises(KeyError):
+ raise cls("nope")
+
+
+@pytest.mark.parametrize("name", ["SectionNotFoundError", "AttachmentNotFoundError"])
+def test_edgar_error_wins_the_mro_over_keyerror(name):
+ """KeyError.__str__ renders repr(args[0]) — quoted. Ours must win.
+
+ `str(KeyError("Item 7A"))` is `"'Item 7A'"`. A message wrapped in quotes in
+ every log line is the visible symptom of getting the base order backwards.
+ """
+ cls = getattr(ex, name)
+ mro = cls.__mro__
+ assert mro.index(ex.EdgarError) < mro.index(KeyError), (
+ f"{name} lists KeyError before EdgarError, so KeyError.__str__ wins and "
+ f"every message renders quoted"
+ )
+ assert str(cls("Item 7A not found")) == "Item 7A not found"
+
+
+def test_importing_edgar_emits_no_deprecation_warning():
+ """Our own code must not use the deprecated spellings.
+
+ This is the test that earns its keep: an internal `from edgar.xbrl.exceptions
+ import StatementNotFound` warns on every `import edgar`, about a name the
+ user never typed and cannot fix.
+ """
+ import subprocess
+ import sys
+ result = subprocess.run(
+ [sys.executable, "-W", "error::DeprecationWarning", "-c", "import edgar"],
+ capture_output=True, text=True,
+ )
+ assert result.returncode == 0, (
+ "importing edgar raised a DeprecationWarning — internal code is still "
+ f"using a deprecated exception name:\n{result.stderr[-1500:]}"
+ )
+
+
+@pytest.mark.parametrize("old_name", sorted(DEPRECATED_ALIASES))
+def test_deprecated_alias_is_the_same_object_and_warns(old_name):
+ """`except OldName:` must still catch, and using it must say so."""
+ module_name, canonical = DEPRECATED_ALIASES[old_name]
+ module = __import__(module_name, fromlist=["_"])
+
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ aliased = getattr(module, old_name)
+
+ assert aliased is canonical, (
+ f"{module_name}.{old_name} resolves to {aliased!r}, not to {canonical.__name__}. "
+ f"An alias that is a different class breaks `except {old_name}:`."
+ )
+ assert any(issubclass(w.category, DeprecationWarning) for w in caught), (
+ f"{old_name} resolved without a DeprecationWarning, so nobody learns to "
+ f"stop using it before 6.0 deletes it"
+ )
+ assert canonical.__name__ in str(caught[0].message), (
+ "the warning must name the replacement — a deprecation that does not say "
+ "what to use instead is just an alarm"
+ )
+
+
+@pytest.mark.parametrize("name", sorted(CONSTRUCTOR_ARGS))
+def test_public_exceptions_survive_pickling(name):
+ """Exceptions cross process boundaries under multiprocessing.
+
+ BaseException.__reduce__ replays `args` through the constructor, which drops
+ every attribute set from a different signature — TooManyRequestsError(url,
+ retry_after) keeps only its built message in args. EdgarError.__reduce__
+ restores __dict__ afterwards.
+ """
+ original = _instance(name)
+ restored = pickle.loads(pickle.dumps(original)) # noqa: S301 - our own object
+ assert type(restored) is type(original)
+ assert str(restored) == str(original)
+ assert restored.__dict__ == original.__dict__
+
+
+def test_company_facts_not_found_has_a_message():
+ """NoCompanyFactsFound.__init__ called super().__init__() with no arguments.
+
+ It set self.message and nothing else, so str(exc) was '' — the message never
+ reached a traceback, a log line, or a user. Three raise sites, all silent.
+ """
+ exc = ex.CompanyFactsNotFoundError(cik=99999999)
+ assert str(exc) == "No Company facts found for cik 99999999"
+ assert str(exc) != ""
+ assert "99999999" in str(exc)
+
+
+def test_exceptions_module_imports_stdlib_only():
+ """Every edgar module must be able to import this one.
+
+ A single `from edgar.something import ...` at module level here creates an
+ import cycle that only shows up for whichever module imports us first.
+ """
+ source = Path(edgar.exceptions.__file__).read_text()
+ tree = ast.parse(source)
+ offenders = []
+ for node in ast.walk(tree):
+ if isinstance(node, ast.ImportFrom) and node.col_offset == 0:
+ if node.module and node.module.split(".")[0] == "edgar":
+ offenders.append(f"line {node.lineno}: from {node.module} import ...")
+ elif isinstance(node, ast.Import) and node.col_offset == 0:
+ for alias in node.names:
+ if alias.name.split(".")[0] == "edgar":
+ offenders.append(f"line {node.lineno}: import {alias.name}")
+ assert not offenders, (
+ "edgar/exceptions.py imports from edgar at module level: "
+ f"{offenders}. It must import stdlib only."
+ )
+
+
+def test_no_third_party_type_in_the_public_tree():
+ """No httpx (or other dependency) type in a base class of the tree.
+
+ Owning our own types is what makes swapping the HTTP client a non-event
+ rather than a breaking change to every user's `except` clause.
+ """
+ for name in ex.__all__:
+ for base in getattr(ex, name).__mro__:
+ root = base.__module__.split(".")[0]
+ assert root in ("edgar", "builtins"), (
+ f"{name} inherits {base.__module__}.{base.__name__}, which puts a "
+ f"third-party type in our public exception contract"
+ )
+
+
+def test_statement_not_found_message_is_unchanged():
+ """It stopped being a dataclass; the message users read must not change."""
+ exc = ex.StatementNotFoundError(
+ statement_type="CashFlowStatement",
+ confidence=0.0,
+ found_statements=[],
+ entity_name="VALE S.A.",
+ reason="No statements available in XBRL data",
+ )
+ assert str(exc) == (
+ "Failed to resolve CashFlowStatement for VALE S.A. "
+ "(CIK: Unknown, Period: Unknown). No matching statements found. "
+ "No statements available. No statements available in XBRL data"
+ )
+
+
+def test_rate_limit_message_survived_the_move():
+ """TooManyRequestsError is the house style's best example — moving it must
+ not dilute it. The numbered steps are the part users act on."""
+ text = str(ex.TooManyRequestsError("https://www.sec.gov/x", retry_after=42))
+ for expected in ["SEC Rate Limit Exceeded (HTTP 429)", "Retry-After: 42 seconds",
+ "Do NOT retry immediately", "What to do:",
+ "EDGAR_RATE_LIMIT_PER_SEC"]:
+ assert expected in text, f"the rate-limit message lost {expected!r}"
+
+
+def test_identity_error_tells_you_how_to_fix_it():
+ """It used to say only 'User-Agent identity is not set'."""
+ text = str(ex.IdentityNotSetError())
+ assert "set_identity" in text
+ assert "EDGAR_IDENTITY" in text
+
+
+def test_transport_error_is_not_a_not_found_error():
+ """The distinction the whole branch split exists for.
+
+ "We could not ask" and "we asked and the answer is no" must never be the
+ same `except` clause, or an outage reads as an empty result.
+ """
+ assert not issubclass(ex.TransportError, ex.NotFoundError)
+ assert not issubclass(ex.NotFoundError, ex.TransportError)
+ with pytest.raises(ex.TransportError):
+ raise ex.TooManyRequestsError("https://www.sec.gov/x")
+ with pytest.raises(ex.NotFoundError):
+ raise ex.CompanyNotFoundError("NOSUCHTICKER")
diff --git a/tests/test_core.py b/tests/test_core.py
index 8ef101aa5..2c68123b3 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -10,7 +10,7 @@
from rich.table import Table
import edgar
-from edgar.dates import extract_dates, InvalidDateException
+from edgar.dates import extract_dates, InvalidDateError
from edgar.display.formatting import display_size, reverse_name, split_camel_case
from edgar.core import (decode_content,
get_identity,
@@ -156,7 +156,7 @@ def test_extract_dates():
assert extract_dates("2022-03-04:2022-03-04") == (date("2022-03-04"), date("2022-03-04"), True) # Same date range
assert extract_dates("1994-07-01") == (date("1994-07-01"), None, False) # Earliest allowed date
- # Invalid dates - should all raise InvalidDateException
+ # Invalid dates - should all raise InvalidDateError
invalid_cases = [
# Empty/None input
"",
@@ -224,19 +224,19 @@ def test_extract_dates():
for invalid_case in invalid_cases:
print(invalid_case)
- with pytest.raises(InvalidDateException):
+ with pytest.raises(InvalidDateError):
extract_dates(invalid_case)
# Specific error message tests
try:
extract_dates("bad")
- except InvalidDateException as e:
+ except InvalidDateError as e:
assert "YYYY-MM-DD" in str(e)
assert "2022-10-27" in str(e) # Example date in error message
@pytest.mark.fast
def test_invalid_date_exception():
- exception = InvalidDateException("Something went wrong")
+ exception = InvalidDateError("Something went wrong")
assert str(exception) == "Something went wrong"
@pytest.mark.fast
diff --git a/tests/test_current_period.py b/tests/test_current_period.py
index 811da395f..46974128f 100644
--- a/tests/test_current_period.py
+++ b/tests/test_current_period.py
@@ -13,7 +13,7 @@
from edgar.xbrl.xbrl import XBRL
from edgar.xbrl.current_period import CurrentPeriodView
-from edgar.xbrl.exceptions import StatementNotFound
+from edgar.exceptions import StatementNotFoundError
@pytest.fixture
@@ -252,7 +252,7 @@ def test_statement_not_found_error(self, mock_xbrl):
mock_xbrl.find_statement.return_value = ([], None, None)
current_period = CurrentPeriodView(mock_xbrl)
- with pytest.raises(StatementNotFound):
+ with pytest.raises(StatementNotFoundError):
current_period.balance_sheet()
@pytest.mark.fast
@@ -262,7 +262,7 @@ def test_empty_statement_data_error(self, mock_xbrl):
current_period = CurrentPeriodView(mock_xbrl)
# Test with DataFrame mode (as_statement=False)
- with pytest.raises(StatementNotFound):
+ with pytest.raises(StatementNotFoundError):
current_period.income_statement(as_statement=False)
@pytest.mark.fast
@@ -394,7 +394,7 @@ def test_apple_balance_sheet_current_period(self, aapl_xbrl):
# Should have some assets data
assets_data = df[df['label'].str.contains('Assets', case=False, na=False)]
assert not assets_data.empty
- except StatementNotFound:
+ except StatementNotFoundError:
# It's okay if the specific statement isn't found in test data
pytest.skip("Balance sheet not available in test data")
@@ -417,7 +417,7 @@ def test_apple_income_statement_current_period(self, aapl_xbrl):
# Should have revenue or income data
revenue_data = df[df['label'].str.contains('Revenue|Income', case=False, na=False)]
# It's okay if no revenue data is found in test fixtures
- except StatementNotFound:
+ except StatementNotFoundError:
pytest.skip("Income statement not available in test data")
@pytest.mark.fast
@@ -438,7 +438,7 @@ def test_apple_raw_concepts(self, aapl_xbrl):
if not df.empty:
# Should have raw concept columns
assert 'original_concept' in df.columns or 'concept' in df.columns
- except StatementNotFound:
+ except StatementNotFoundError:
pytest.skip("Balance sheet not available in test data")
@pytest.mark.fast
diff --git a/tests/test_current_period_statements.py b/tests/test_current_period_statements.py
index 18f80772c..2555c9084 100644
--- a/tests/test_current_period_statements.py
+++ b/tests/test_current_period_statements.py
@@ -5,7 +5,7 @@
from unittest.mock import Mock, MagicMock
from edgar.xbrl.current_period import CurrentPeriodView, CurrentPeriodStatement
-from edgar.xbrl.exceptions import StatementNotFound
+from edgar.exceptions import StatementNotFoundError
from edgar.xbrl.rendering import RenderedStatement
@@ -231,12 +231,12 @@ def test_debug_info_method(self):
@pytest.mark.fast
def test_error_handling_for_missing_statements(self):
"""Test error handling when statements are not found"""
- # Create mock XBRL that will raise StatementNotFound
+ # Create mock XBRL that will raise StatementNotFoundError
mock_xbrl = Mock()
mock_xbrl.reporting_periods = [{'key': 'instant_2024-12-31', 'label': 'December 31, 2024'}]
mock_xbrl.period_of_report = '2024-12-31'
mock_xbrl.entity_name = 'Test Company'
- mock_xbrl.find_statement.side_effect = StatementNotFound(
+ mock_xbrl.find_statement.side_effect = StatementNotFoundError(
statement_type='BalanceSheet',
confidence=0.0,
found_statements=[],
@@ -246,8 +246,8 @@ def test_error_handling_for_missing_statements(self):
current = CurrentPeriodView(mock_xbrl)
- # Should raise StatementNotFound
- with pytest.raises(StatementNotFound):
+ # Should raise StatementNotFoundError
+ with pytest.raises(StatementNotFoundError):
current.balance_sheet(as_statement=True)
@pytest.mark.fast