Skip to content

feat(errors): one exception vocabulary — edgar.exceptions (07lk.10, PR1 of 3) - #1034

Merged
dgunning merged 1 commit into
mainfrom
feat/07lk10-exceptions-tree
Aug 11, 2026
Merged

feat(errors): one exception vocabulary — edgar.exceptions (07lk.10, PR1 of 3)#1034
dgunning merged 1 commit into
mainfrom
feat/07lk10-exceptions-tree

Conversation

@dgunning

Copy link
Copy Markdown
Owner

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 bare ValueError — 135 of them.

The 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 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, whose TRANSPORT_ERRORS tuple was added a day ago with a comment pointing at this bead.

The load-bearing choice

Branches inherit the builtin they replace. ValidationError IS-A ValueError; NotFoundError IS-A LookupError. That makes converting the 135 raw ValueError raises additive — every except 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 ValueError catching 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: and pytest.raises(SECFilingNotFoundError) still work. edgar/_compat.py::deprecated_alias is 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__ called super().__init__() with no arguments and set self.message instead — 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:

  • the builtin bases — delete ValueError from a base list and the additive migration silently becomes a breaking one
  • MRO orderEdgarError must precede KeyError, or every message renders repr-quoted ("'Item 7A'")
  • alias identity — each old name must be the same object, not a lookalike class
  • import edgar emits no DeprecationWarning — this one caught four internal modules still importing deprecated spellings, which would have sprayed warnings at users about names they never typed
  • pickle round-trip over every public class; edgar/exceptions.py imports stdlib only; no third-party type anywhere in the tree's MRO

Deviations from the design, each for a compatibility reason

  1. SSLVerificationError stays defined in httprequests.py — it categorizes an httpx error to build its message, and this module admits no third-party types.
  2. context/suggestions stay positional, matching the ParsingError signature they replace.
  3. The base __str__ keeps ParsingError'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 edgar clean under -W error::DeprecationWarning.

Next: PR2 wraps the network boundary (stamina re-raises httpx verbatim on exhaustion today), PR3 stages the silent-None flips behind FutureWarning.

🤖 Generated with Claude Code

…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>
@dgunning
dgunning merged commit e4e1615 into main Aug 11, 2026
9 of 10 checks passed
@dgunning
dgunning deleted the feat/07lk10-exceptions-tree branch August 11, 2026 15:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant