feat(errors): one exception vocabulary — edgar.exceptions (07lk.10, PR1 of 3) - #1034
Merged
Conversation
…R1 of 3)
There were 27 exception classes across ten packages with no shared base and no
cross-package inheritance of any kind, and exactly two were reachable from the
top-level namespace. You could not write `except <something>` without first
knowing which module happened to raise. Meanwhile the thing we actually raised
most was a bare `ValueError` — 135 of them.
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
The first two branches carry the distinction this is really for: an outage and
an empty result must never arrive as the same value. That is the defect class
behind edgartools-tg7y, and TRANSPORT_ERRORS — added a day ago with a comment
pointing at this bead — is the vocabulary those call sites needed in the
meantime.
THE BRANCHES INHERIT THE BUILTIN THEY REPLACE, and that choice is what makes
this shippable in 5.x. `ValidationError` IS-A `ValueError` and `NotFoundError`
IS-A `LookupError`, so converting the 135 raw `ValueError` raises is additive:
every `except ValueError:` written against those call sites keeps catching
them. A clean root would break user code twice — once per conversion, again at
6.0 — for no semantic gain. The root itself stays neutral, because
`except ValueError` catching a network timeout would be absurd.
NOTHING CHANGES ABOUT WHAT IS RAISED TODAY. Every existing class was re-based
into the tree or kept as a deprecated alias resolving to the *same object*, so
`except StatementNotFound:` and `pytest.raises(SECFilingNotFoundError)` still
work. `edgar/_compat.py::deprecated_alias` is the mechanism — a PEP 562 module
`__getattr__`, because the alternative (`OldName = NewName`) renames silently
and nobody finds out until 6.0 deletes it. Per the rename trap in 07lk.23, the
implementation moved to the canonical name and the deprecated spelling is the
alias, never the reverse.
ONE BUG FIXED, BY CONSTRUCTION. `NoCompanyFactsFound.__init__` called
`super().__init__()` with no arguments and set `self.message` instead, so
`str(exc)` was `''` — three raise sites whose message never reached a
traceback, a log, or a user. The canonical class builds its message and passes
it up, which makes the empty case unrepresentable rather than merely fixed.
WHAT THE TESTS ARE FOR, since most of them guard a one-word edit: the builtin
bases (delete `ValueError` from a base list and the additive migration silently
becomes a breaking one); the MRO order (`EdgarError` must precede `KeyError` or
every message renders repr-quoted); that every alias is the same object as its
canonical class; and that `import edgar` emits no DeprecationWarning — that
last one caught four internal modules still importing deprecated spellings,
which would have sprayed warnings at users about names they never typed.
Three deviations from the design doc, each for a compatibility reason:
SSLVerificationError stays defined in httprequests.py (it categorizes an httpx
error to build its message, and this module imports no third-party types);
`context`/`suggestions` stay positional, matching the ParsingError signature
they replace; and the base `__str__` keeps that class's exact rendering, so no
existing message text changes.
4,846 fast tests pass, the regression-skip gate is clean, ruff is clean on the
new files, and `import edgar` is clean under -W error::DeprecationWarning.
Bead: edgartools-07lk.10 (PR1 of 3 — the tree. PR2 wraps the network boundary,
PR3 stages the silent-None flips behind FutureWarning.)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First of three PRs for the 6.0 error policy. Design doc:
docs-internal/planning/active-tasks/2026-08-11-07lk10-error-hierarchy-design.md.The problem
27 exception classes across ten packages, no shared base, no cross-package inheritance anywhere, and exactly two reachable from the top-level namespace. You could not write
except <something>without knowing which module happened to raise. The thing we raised most was a bareValueError— 135 of them.The tree
The first two carry the distinction this is really for: an outage and an empty result must never arrive as the same value. That is the defect class behind
edgartools-tg7y, whoseTRANSPORT_ERRORStuple was added a day ago with a comment pointing at this bead.The load-bearing choice
Branches inherit the builtin they replace.
ValidationErrorIS-AValueError;NotFoundErrorIS-ALookupError. That makes converting the 135 rawValueErrorraises additive — everyexcept ValueError:written against them keeps working — which is what lets this ship in 5.x instead of waiting for the 6.0 break. A clean root would break user code twice, once per conversion and again at 6.0, for no semantic gain. The root stays neutral:except ValueErrorcatching a network timeout would be absurd.Nothing raised changes
Every existing class is re-based into the tree or kept as a deprecated alias resolving to the same object, so
except StatementNotFound:andpytest.raises(SECFilingNotFoundError)still work.edgar/_compat.py::deprecated_aliasis a PEP 562 module__getattr__— the alternative (OldName = NewName) renames silently and nobody finds out until 6.0 deletes it. Deprecated:StatementNotFound,NoCompanyFactsFound,SECFilingNotFoundError,InvalidDateException,IdentityNotSetException,TooManyRequestsException,DataObjectException.One bug fixed, by construction
NoCompanyFactsFound.__init__calledsuper().__init__()with no arguments and setself.messageinstead —str(exc)was'', so three raise sites had a message that never reached a traceback, a log, or a user. The canonical class builds its message and passes it up, making the empty case unrepresentable rather than merely fixed.Tests
Most guard a one-word edit:
ValueErrorfrom a base list and the additive migration silently becomes a breaking oneEdgarErrormust precedeKeyError, or every message renders repr-quoted ("'Item 7A'")import edgaremits no DeprecationWarning — this one caught four internal modules still importing deprecated spellings, which would have sprayed warnings at users about names they never typededgar/exceptions.pyimports stdlib only; no third-party type anywhere in the tree's MRODeviations from the design, each for a compatibility reason
SSLVerificationErrorstays defined inhttprequests.py— it categorizes an httpx error to build its message, and this module admits no third-party types.context/suggestionsstay positional, matching theParsingErrorsignature they replace.__str__keepsParsingError's exact rendering, so no existing message text changes.Verification
4,846 fast tests pass · regression-skip gate clean · ruff clean on the new files ·
import edgarclean under-W error::DeprecationWarning.Next: PR2 wraps the network boundary (stamina re-raises httpx verbatim on exhaustion today), PR3 stages the silent-
Noneflips behindFutureWarning.🤖 Generated with Claude Code