Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 43 additions & 4 deletions edgar/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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]]:
Expand Down
67 changes: 67 additions & 0 deletions edgar/_compat.py
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 6 additions & 5 deletions edgar/_filings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion edgar/ai/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 6 additions & 6 deletions edgar/ai/mcp/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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)),
Expand All @@ -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}",
Expand Down
5 changes: 3 additions & 2 deletions edgar/attachments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]):
"""
Expand All @@ -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):
"""
Expand Down
2 changes: 1 addition & 1 deletion edgar/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
27 changes: 20 additions & 7 deletions edgar/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]]:
Expand Down Expand Up @@ -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)
28 changes: 16 additions & 12 deletions edgar/dates.py
Original file line number Diff line number Diff line change
@@ -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]]]
Expand Down Expand Up @@ -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 ''}"
Expand All @@ -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]:
Expand All @@ -85,15 +89,15 @@ 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()})"
)

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"
Expand Down
Loading