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

## Release `2.0.1` - 2026-03-11

* Bugfix: `get_symbols_v2()`, `get_sp500_companies_txt_file()`, `get_events()`, and `get_exchanges_json()` now raise `HTTPError` on failed API responses instead of silently returning the raw response. Previously, non-200 responses were passed through to callers, which would then crash with `AttributeError` (e.g., calling `.split()` on a non-text response) or return corrupt data.
* Bugfix: `_get_events()` in `Company` now handles `HTTPError` (returns `[]` on 404), consistent with `get_transcript()`, `download_audio_file()`, and `download_slide_deck()`.
* Add return type hints to all public functions in `api.py`.

## Release `2.0.0` - 2026-03-04

* **Breaking**: Drop support for Python 3.8 and 3.9. Minimum required version is now Python 3.10.
Expand Down
40 changes: 19 additions & 21 deletions earningscall/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
}


def get_api_key():
def get_api_key() -> str:
if earningscall.api_key:
return earningscall.api_key
e_call_key = os.environ.get("ECALL_API_KEY")
Expand All @@ -35,11 +35,11 @@ def get_api_key():
return "demo"


def api_key_param():
def api_key_param() -> dict:
return {"apikey": get_api_key()}


def is_demo_account():
def is_demo_account() -> bool:
return get_api_key() == "demo"


Expand All @@ -53,22 +53,22 @@ def cache_session() -> CachedSession:
)


def cached_urls():
def cached_urls() -> list[str]:
return cache_session().cache.urls()


def purge_cache():
def purge_cache() -> None:
return cache_session().cache.clear()


def get_earnings_call_version():
def get_earnings_call_version() -> str | None:
try:
return importlib.metadata.version("earningscall")
except importlib.metadata.PackageNotFoundError:
return None


def get_user_agent():
def get_user_agent() -> str:
sdk_name = "EarningsCallPython"
sdk_version = get_earnings_call_version()
python_version = platform.python_version()
Expand All @@ -79,15 +79,17 @@ def get_user_agent():
return user_agent


def get_headers():
def get_headers() -> dict:
earnings_call_version = get_earnings_call_version()
return {
"User-Agent": get_user_agent(),
"X-EarningsCall-Version": earnings_call_version,
}


def _get_with_optional_cache(url: str, *, params: Optional[dict] = None, stream: Optional[bool] = None):
def _get_with_optional_cache(
url: str, *, params: Optional[dict] = None, stream: Optional[bool] = None
) -> requests.Response:
"""
Internal helper to GET an absolute URL, using the shared requests cache when enabled.
"""
Expand Down Expand Up @@ -186,14 +188,13 @@ def get_calendar_api_operation(year: int, month: int, day: int) -> dict:
return response.json()


def get_events(exchange: str, symbol: str) -> Optional[dict]:
def get_events(exchange: str, symbol: str) -> dict:
params = {
"exchange": exchange,
"symbol": symbol,
}
response = do_get("events", params=params)
if response.status_code != 200:
return None
response.raise_for_status()
return response.json()


Expand Down Expand Up @@ -227,17 +228,15 @@ def get_transcript(
return response.json()


def get_symbols_v2():
def get_symbols_v2() -> str:
response = do_get("symbols-v2.txt", use_cache=True)
if response.status_code != 200:
return None
response.raise_for_status()
return response.text


def get_sp500_companies_txt_file():
def get_sp500_companies_txt_file() -> str:
response = do_get("symbols/sp500.txt", use_cache=True)
if response.status_code != 200:
return None
response.raise_for_status()
return response.text


Expand Down Expand Up @@ -307,13 +306,12 @@ def download_slide_deck(
return local_filename


def get_exchanges_json():
def get_exchanges_json() -> dict:
"""Fetch the public exchanges JSON from the website domain.

Uses the shared cache to avoid frequent network requests.
"""
url = f"https://{DOMAIN}/exchanges.json"
response = _get_with_optional_cache(url)
if response.status_code != 200:
return None
response.raise_for_status()
return response.json()
9 changes: 6 additions & 3 deletions earningscall/company.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,12 @@ def __str__(self):
def _get_events(self) -> List[EarningsEvent]:
if not self.company_info.exchange or not self.company_info.symbol:
return []
raw_response = api.get_events(self.company_info.exchange, self.company_info.symbol)
if not raw_response:
return []
try:
raw_response = api.get_events(self.company_info.exchange, self.company_info.symbol)
except requests.exceptions.HTTPError as error:
if error.response.status_code == 404:
return []
raise
return [EarningsEvent.from_dict(event) for event in raw_response["events"]] # type: ignore

def events(self) -> List[EarningsEvent]:
Expand Down
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 = "2.0.0"
version = "2.0.1"
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
87 changes: 86 additions & 1 deletion tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
import importlib.metadata

import pytest
import requests
import responses

import earningscall
from earningscall.api import get_api_key, get_earnings_call_version, get_user_agent
from earningscall.api import (
API_BASE,
DOMAIN,
get_api_key,
get_earnings_call_version,
get_events,
get_exchanges_json,
get_sp500_companies_txt_file,
get_symbols_v2,
get_user_agent,
purge_cache,
)


def test_get_api_key_returns_earningscall_api_key_when_set(monkeypatch):
Expand Down Expand Up @@ -115,3 +130,73 @@ def test_get_user_agent():
assert "EarningsCallPython/" in user_agent
assert "Python" in user_agent
assert "Requests" in user_agent


@pytest.fixture()
def setup_api():
earningscall.api_key = "foobar"
earningscall.retry_strategy = {
"strategy": "exponential",
"base_delay": 0.01,
"max_attempts": 1,
}
purge_cache()
yield
earningscall.api_key = None
earningscall.retry_strategy = None


@responses.activate
def test_get_symbols_v2_raises_on_500(setup_api):
responses.add(responses.GET, f"{API_BASE}/symbols-v2.txt", status=500)
with pytest.raises(requests.HTTPError, match="500 Server Error"):
get_symbols_v2()


@responses.activate
def test_get_symbols_v2_raises_on_404(setup_api):
responses.add(responses.GET, f"{API_BASE}/symbols-v2.txt", status=404)
with pytest.raises(requests.HTTPError, match="404 Client Error"):
get_symbols_v2()


@responses.activate
def test_get_sp500_companies_txt_file_raises_on_500(setup_api):
responses.add(responses.GET, f"{API_BASE}/symbols/sp500.txt", status=500)
with pytest.raises(requests.HTTPError, match="500 Server Error"):
get_sp500_companies_txt_file()


@responses.activate
def test_get_sp500_companies_txt_file_raises_on_404(setup_api):
responses.add(responses.GET, f"{API_BASE}/symbols/sp500.txt", status=404)
with pytest.raises(requests.HTTPError, match="404 Client Error"):
get_sp500_companies_txt_file()


@responses.activate
def test_get_events_raises_on_500(setup_api):
responses.add(responses.GET, f"{API_BASE}/events", status=500)
with pytest.raises(requests.HTTPError, match="500 Server Error"):
get_events("NASDAQ", "AAPL")


@responses.activate
def test_get_events_raises_on_404(setup_api):
responses.add(responses.GET, f"{API_BASE}/events", status=404)
with pytest.raises(requests.HTTPError, match="404 Client Error"):
get_events("NASDAQ", "AAPL")


@responses.activate
def test_get_exchanges_json_raises_on_500(setup_api):
responses.add(responses.GET, f"https://{DOMAIN}/exchanges.json", status=500)
with pytest.raises(requests.HTTPError, match="500 Server Error"):
get_exchanges_json()


@responses.activate
def test_get_exchanges_json_raises_on_404(setup_api):
responses.add(responses.GET, f"https://{DOMAIN}/exchanges.json", status=404)
with pytest.raises(requests.HTTPError, match="404 Client Error"):
get_exchanges_json()
1 change: 1 addition & 0 deletions tests/test_download_audio_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ def test_download_audio_file_event():
os.unlink(file_name)


@responses.activate
def test_download_audio_file_missing_params_raises_value_error():
##
responses._add_from_file(file_path=data_path("symbols-v2.yaml"))
Expand Down
6 changes: 3 additions & 3 deletions tests/test_exports.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
import requests
import responses

import earningscall
Expand Down Expand Up @@ -64,6 +65,5 @@ def test_get_sp_500_companies_failed_request():
responses._add_from_file(file_path=data_path("sp500-company-list-failed.yaml"))
##
earningscall.api_key = "foobar" # Bogus key to avoid check for "demo"
companies = [company for company in get_sp500_companies()]
##
assert len(companies) == 0
with pytest.raises(requests.HTTPError):
list(get_sp500_companies())
45 changes: 42 additions & 3 deletions tests/test_get_company_events.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import pytest
import requests
import responses

import earningscall
from earningscall import get_company
from earningscall.api import purge_cache
from earningscall.api import API_BASE, purge_cache
from earningscall.symbols import clear_symbols
from earningscall.utils import data_path

Expand All @@ -14,11 +17,24 @@
#


@pytest.fixture(autouse=True)
def run_before_and_after_tests():
earningscall.api_key = None
earningscall.retry_strategy = {
"strategy": "exponential",
"base_delay": 0.001,
"max_attempts": 1,
}
purge_cache()
clear_symbols()
yield
earningscall.api_key = None
earningscall.retry_strategy = None


@responses.activate
def test_get_demo_company():
##
purge_cache()
clear_symbols()
responses._add_from_file(file_path=data_path("symbols-v2.yaml"))
responses._add_from_file(file_path=data_path("msft-transcript-response.yaml"))
responses._add_from_file(file_path=data_path("msft-company-events.yaml"))
Expand All @@ -29,3 +45,26 @@ def test_get_demo_company():
assert len(events) == 20
assert events[0].year == 2024
assert events[0].quarter == 3


@responses.activate
def test_get_company_events_returns_empty_on_404():
earningscall.api_key = "foobar"
responses._add_from_file(file_path=data_path("symbols-v2.yaml"))
responses.add(responses.GET, f"{API_BASE}/events", status=404)
##
company = get_company("msft")
events = company.events()
##
assert events == []


@responses.activate
def test_get_company_events_raises_on_500():
earningscall.api_key = "foobar"
responses._add_from_file(file_path=data_path("symbols-v2.yaml"))
responses.add(responses.GET, f"{API_BASE}/events", status=500)
##
company = get_company("msft")
with pytest.raises(requests.HTTPError, match="500 Server Error"):
company.events()
Loading