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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Release `3.1.0` - 2026-10-18

* Switch to the new API endpoint, `https://api.earningscall.dev`. Override it with the `EARNINGSCALL_API_BASE` environment variable.
* Pin the API response schema to `v4` with the `X-API-Version-Schema` request header, so server-side schema upgrades can't change the responses this library parses.
* Schema v4 inlines speaker names and titles in transcripts. They are mapped onto the existing `Speaker.speaker`, `Speaker.speaker_info`, and `Transcript.speaker_name_map_v2` fields, so transcript results are unchanged.
* Schema v4 wraps calendar results as `{"events": [...]}`. `get_calendar()` handles both shapes.
* When the API is rate limited (HTTP 429) or unavailable (HTTP 5xx), retries now wait for the duration in the `Retry-After` header, falling back to the configured `retry_strategy` backoff when the header is missing.
* Bugfix: `download_audio_file()` and `download_slide_deck()` raised `KeyError` instead of `InsufficientApiAccessError` when a 403 response lacked the `X-Plan-Name` header. The plan name now falls back to `X-Plan-Type`.
* Bugfix: the invalid API key error message now includes the actual link instead of a literal `{DOMAIN}`.

## Release `3.0.0` - 2026-10-18

* **Breaking**: Drop support for Python 3.10, which reached end-of-life in October 2026. Minimum required version is now Python 3.11.
Expand Down
19 changes: 13 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@

The public Python client library for the EarningsCall developer API, published
to PyPI as [`earningscall`](https://pypi.org/project/earningscall/). It is a
thin `requests`-based SDK: all calls hit `https://v2.api.earningscall.biz` (domain
overridable via `EARNINGSCALL_DOMAIN`), plus `https://earningscall.biz/exchanges.json`
for the exchange list. Auth is an `apikey` query param resolved from
thin `requests`-based SDK: all calls hit `https://api.earningscall.dev`
(overridable via `EARNINGSCALL_API_BASE`), plus `https://earningscall.biz/exchanges.json`
for the exchange list (website domain overridable via `EARNINGSCALL_DOMAIN`).
Auth is an `apikey` query param resolved from
`earningscall.api_key`, then the `ECALL_API_KEY` / `EARNINGSCALL_API_KEY` env
vars, defaulting to `"demo"` (AAPL-only sample access; other symbols raise
vars, defaulting to `"demo"` (AAPL and MSFT sample access; other symbols raise
`InsufficientApiAccessError`). Public surface (see `earningscall/__init__.py`):
`get_company`, `get_all_companies`, `get_sp500_companies`, `get_calendar`,
`Symbols`, `load_symbols`, `exchanges`, and module-level config
Expand All @@ -18,8 +19,8 @@ vars, defaulting to `"demo"` (AAPL-only sample access; other symbols raise

- `earningscall/api.py` — HTTP layer. Endpoint wrappers (`events`,
`transcript`, `audio`, `slides`, `calendar`, `symbols-v2.txt`,
`symbols/sp500.txt`), retry with backoff on 429/5xx, 401 →
`InvalidApiKeyError`, optional `requests-cache` sqlite cache
`symbols/sp500.txt`), retry on 429/5xx (waits for `Retry-After`, else
backoff), 401 → `InvalidApiKeyError`, optional `requests-cache` sqlite cache
(`.earningscall_cache` in the temp dir, on by default).
- `earningscall/exports.py` — the top-level functions re-exported by
`__init__.py`.
Expand Down Expand Up @@ -58,3 +59,9 @@ vars, defaulting to `"demo"` (AAPL-only sample access; other symbols raise
and the on-disk requests cache persist stale data across calls; use
`clear_symbols()` / `api.purge_cache()` in tests (see the autouse fixture in
`tests/test_helper.py`).
- The API serves its newest response schema unless the request names one, so
`api.py` pins `X-API-Version-Schema` (`API_SCHEMA_VERSION`). Schema v4 inlines
speakers as `{id, name, title}` and wraps calendar results in `{"events": [...]}`;
`normalize_transcript_payload()` and `get_calendar()` map these onto the
public models. Bumping the pinned version means re-checking every response
shape. Unknown versions silently fall back to v2.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ earningscall.enable_requests_cache = False

### Retry Strategy

The library implements a flexible retry strategy to handle rate limiting and HTTP 5xx errors effectively. By default, it retries with increasing delays: 3 seconds, 6 seconds, 12 seconds, 24 seconds, and finally 48 seconds. If the request fails after five attempts, the library raises an exception.
The library retries requests that are rate limited (HTTP 429) or fail with an HTTP 5xx error. When the API says how long to wait, via the `Retry-After` header, the library waits exactly that long before retrying. Otherwise it backs off with increasing delays: by default 1 second, then 2, 4, 8, and so on, for up to 10 total attempts. If the request still fails, the library raises an exception.

#### Customizing the Retry Strategy

Expand All @@ -312,7 +312,7 @@ Depending on your specific requirements, you can adjust the retry strategy. For
To customize the retry behavior, set the `retry_strategy` variable with the desired parameters:

- **strategy**: "exponential" | "linear" — defines the type of retry strategy (default is "exponential").
- **base_delay**: float (in seconds) — specifies the delay between retries (default is 1 seconds).
- **base_delay**: float (in seconds) — specifies the delay between retries when the API does not send a `Retry-After` header (default is 1 second).
- **max_attempts**: int — sets the maximum number of total request attempts (default is 10).

#### Default Retry Strategy
Expand Down
46 changes: 42 additions & 4 deletions earningscall/api.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import email.utils
import importlib.metadata
import logging
import math
import os
import platform
import time
import urllib.parse
from datetime import datetime, timezone
from typing import Dict, Optional, Union

import requests
Expand All @@ -15,7 +18,10 @@
log = logging.getLogger(__file__)

DOMAIN = os.environ.get("EARNINGSCALL_DOMAIN", "earningscall.biz")
API_BASE = f"https://v2.api.{DOMAIN}"
API_BASE = os.environ.get("EARNINGSCALL_API_BASE", "https://api.earningscall.dev")
# The API serves its newest response schema unless a version is requested, so pin the
# schema this client parses to keep server-side schema upgrades from changing responses.
API_SCHEMA_VERSION = "v4"
DEFAULT_RETRY_STRATEGY: Dict[str, Union[str, int, float]] = {
"strategy": "exponential",
"base_delay": 1,
Expand Down Expand Up @@ -84,6 +90,7 @@ def get_headers() -> dict:
return {
"User-Agent": get_user_agent(),
"X-EarningsCall-Version": earnings_call_version,
"X-API-Version-Schema": API_SCHEMA_VERSION,
}


Expand All @@ -107,6 +114,32 @@ def can_retry(response: requests.Response) -> bool:
return False


def get_retry_after_seconds(response: requests.Response) -> Optional[float]:
"""
Parse the Retry-After header, which is either a number of seconds or an HTTP date.

Returns None when the header is missing or malformed.
"""
retry_after = response.headers.get("Retry-After")
if not retry_after:
return None
try:
seconds = float(retry_after)
except ValueError:
pass
else:
# float() also accepts "inf" and "nan", which time.sleep() rejects.
return max(seconds, 0.0) if math.isfinite(seconds) else None
try:
retry_at = email.utils.parsedate_to_datetime(retry_after)
except (TypeError, ValueError):
return None
if retry_at.tzinfo is None:
# HTTP dates are always GMT, but asctime-format and "-0000" dates parse without a timezone.
retry_at = retry_at.replace(tzinfo=timezone.utc)
return max((retry_at - datetime.now(timezone.utc)).total_seconds(), 0.0)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def is_success(response: requests.Response) -> bool:
# TODO: Do we need to check for 2xx status codes?
return response.status_code == 200
Expand Down Expand Up @@ -144,7 +177,7 @@ def do_get(
response: requests.Response
for attempt in range(max_attempts):
if use_cache and earningscall.enable_requests_cache:
response = cache_session().get(url, params=params)
response = cache_session().get(url, params=params, headers=get_headers())
else:
response = requests.get(
url,
Expand All @@ -157,7 +190,7 @@ def do_get(
return response

if response.status_code == 401:
raise InvalidApiKeyError("Your API key is invalid. You can get your API key at: https://{DOMAIN}/api-key")
raise InvalidApiKeyError(f"Your API key is invalid. You can get your API key at: https://{DOMAIN}/api-key")

if not can_retry(response):
return response
Expand All @@ -169,9 +202,14 @@ def do_get(
wait_time = delay * (attempt + 1) # Linear backoff: 1s -> 2s -> 3s -> 4s -> 5s -> 6s -> 7s
else:
raise ValueError("Invalid retry strategy. Must be one of: 'exponential', 'linear'")
# The server knows when the rate limit resets, so its Retry-After wins over our own backoff.
retry_after = get_retry_after_seconds(response)
if retry_after is not None:
wait_time = retry_after
# TODO: Should we log a warning here? Does the customer want to see this log?
log.warning(
f"Rate limited (429). Retrying in {wait_time} seconds... (Attempt {attempt + 1}/{max_attempts})"
f"Received HTTP {response.status_code}. Retrying in {wait_time} seconds... "
f"(Attempt {attempt + 1}/{max_attempts})"
)
time.sleep(float(wait_time))

Expand Down
17 changes: 12 additions & 5 deletions earningscall/company.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,18 @@
from earningscall.errors import InsufficientApiAccessError
from earningscall.event import EarningsEvent
from earningscall.symbols import CompanyInfo
from earningscall.transcript import Transcript
from earningscall.transcript import Transcript, normalize_transcript_payload

log = logging.getLogger(__file__)


def _plan_name(response: Optional[requests.Response]) -> str:
if response is None:
return "unknown"
# The v2 API sent X-Plan-Name; api.earningscall.dev only sends X-Plan-Type.
return response.headers.get("X-Plan-Name") or response.headers.get("X-Plan-Type") or "unknown"


class Company:
"""
A class representing a company.
Expand Down Expand Up @@ -89,7 +96,7 @@ def get_transcript(
)
# TODO: Investigate alternatives to @dataclass for level 3 transcripts, as this is
# extremely slow.
transcript = Transcript.from_dict(response_payload) # type: ignore
transcript = Transcript.from_dict(normalize_transcript_payload(response_payload)) # type: ignore
if level == 3:
for speaker in transcript.speakers:
speaker.text = " ".join(speaker.words)
Expand All @@ -108,7 +115,7 @@ def get_transcript(
if error.response.status_code == 404:
return None
if error.response.status_code == 403:
plan_name = error.response.headers.get("X-Plan-Name", "unknown")
plan_name = _plan_name(error.response)
if 2 <= level <= 4:
error_message = (
f"Your plan ({plan_name}) does not include Advanced Transcript Data. "
Expand Down Expand Up @@ -159,7 +166,7 @@ def download_audio_file(
if error.response.status_code == 404:
return None
if error.response.status_code == 403:
plan_name = error.response.headers["X-Plan-Name"]
plan_name = _plan_name(error.response)
error_message = (
f"Your plan ({plan_name}) does not include Audio Files. "
"Upgrade your plan here: https://earningscall.biz/api-pricing"
Expand Down Expand Up @@ -207,7 +214,7 @@ def download_slide_deck(
if error.response.status_code == 404:
return None
if error.response.status_code == 403:
plan_name = error.response.headers["X-Plan-Name"]
plan_name = _plan_name(error.response)
error_message = (
f"Your plan ({plan_name}) does not include Slide Decks. "
"Upgrade your plan here: https://earningscall.biz/api-pricing"
Expand Down
4 changes: 3 additions & 1 deletion earningscall/exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ def get_calendar(input_date: date) -> List[CalendarEvent]:
)
try:
json_data = api.get_calendar_api_operation(input_date.year, input_date.month, input_date.day)
return [CalendarEvent.from_dict(event) for event in json_data] # type: ignore
# Schema v4 wraps the list as {"events": [...]}; earlier schemas return the bare list.
events = json_data["events"] if isinstance(json_data, dict) else json_data
return [CalendarEvent.from_dict(event) for event in events] # type: ignore
except requests.exceptions.HTTPError as error:
if error.response.status_code == 404:
# Calendar Date not found, simply return an empty list
Expand Down
25 changes: 25 additions & 0 deletions earningscall/transcript.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,28 @@ class Transcript:
prepared_remarks: Optional[str] = field(default=None)
questions_and_answers: Optional[str] = field(default=None)
speaker_name_map_v2: Optional[Dict[str, SpeakerInfo]] = field(default=None)


def normalize_transcript_payload(payload: dict) -> dict:
"""
Convert schema v4 speakers to the shape the Transcript model exposes.

Schema v4 inlines each speaker as {"id", "name", "title"}, while the model keeps the
speaker id in Speaker.speaker and names/titles in speaker_name_map_v2. Payloads that
are already in that shape are returned unchanged.
"""
speakers = payload.get("speakers")
if not speakers or not any(isinstance(speaker.get("speaker"), dict) for speaker in speakers):
return payload
speaker_name_map = dict(payload.get("speaker_name_map_v2") or {})
normalized_speakers = []
for speaker in speakers:
speaker_info = speaker.get("speaker")
if isinstance(speaker_info, dict):
speaker_id = speaker_info.get("id")
speaker_name_map.setdefault(
speaker_id, {"name": speaker_info.get("name"), "title": speaker_info.get("title")}
)
speaker = {**speaker, "speaker": speaker_id}
normalized_speakers.append(speaker)
return {**payload, "speakers": normalized_speakers, "speaker_name_map_v2": speaker_name_map}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "earningscall"
version = "3.0.0"
version = "3.1.0"
description = "The EarningsCall Python library provides convenient access to the EarningsCall API. It includes a pre-defined set of classes for API resources that initialize themselves dynamically from API responses."
readme = "README.md"
authors = [{ name = "EarningsCall", email = "dev@earningscall.biz" }]
Expand Down
2 changes: 1 addition & 1 deletion tests/data/aapl-q1-2022-advanced-data-level-2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -624,4 +624,4 @@ responses:
x-amz-server-side-encryption: AES256
method: GET
status: 200
url: https://v2.api.earningscall.biz/transcript?apikey=demo&exchange=NASDAQ&symbol=AAPL&year=2022&quarter=1&level=2
url: https://api.earningscall.dev/transcript?apikey=demo&exchange=NASDAQ&symbol=AAPL&year=2022&quarter=1&level=2
2 changes: 1 addition & 1 deletion tests/data/aapl-q1-2022-advanced-data-level-3.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1866,4 +1866,4 @@ responses:
x-amz-server-side-encryption: AES256
method: GET
status: 200
url: https://v2.api.earningscall.biz/transcript?apikey=demo&exchange=NASDAQ&symbol=AAPL&year=2022&quarter=1&level=3
url: https://api.earningscall.dev/transcript?apikey=demo&exchange=NASDAQ&symbol=AAPL&year=2022&quarter=1&level=3
2 changes: 1 addition & 1 deletion tests/data/aapl-q1-2022-advanced-data-level-4.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -592,4 +592,4 @@ responses:
x-amz-server-side-encryption: AES256
method: GET
status: 200
url: https://v2.api.earningscall.biz/transcript?apikey=demo&exchange=NASDAQ&symbol=AAPL&year=2022&quarter=1&level=4
url: https://api.earningscall.dev/transcript?apikey=demo&exchange=NASDAQ&symbol=AAPL&year=2022&quarter=1&level=4
2 changes: 1 addition & 1 deletion tests/data/aapl-q1-2022-speaker-name-map-v2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -640,4 +640,4 @@ responses:
x-amz-server-side-encryption: AES256
method: GET
status: 200
url: https://v2.api.earningscall.biz/transcript?apikey=demo&exchange=NASDAQ&symbol=AAPL&year=2023&quarter=1&level=2
url: https://api.earningscall.dev/transcript?apikey=demo&exchange=NASDAQ&symbol=AAPL&year=2023&quarter=1&level=2
Loading
Loading