From c8b41e9a0ab2a64ed96ea555f7e9cb8f48a5d372 Mon Sep 17 00:00:00 2001 From: EarningsCall Date: Wed, 11 Mar 2026 08:28:21 -0500 Subject: [PATCH] fix: raise HTTPError on failed API responses instead of returning None Functions like get_symbols_v2(), get_sp500_companies_txt_file(), get_events(), and get_exchanges_json() silently returned the raw response on HTTP errors, causing downstream callers to crash with AttributeError when trying to use the result. Now all four use response.raise_for_status() consistently, matching the pattern already used by get_transcript() and download_audio_file(). Adds HTTPError handling to _get_events() in company.py so 404 returns [] instead of crashing (consistent with other methods). Also adds return type hints to all public functions in api.py, error-path unit tests (404/500) for all fixed functions, and fixes a missing @responses.activate decorator on an existing test. Bumps version to 2.0.1. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 6 +++ earningscall/api.py | 40 +++++++------- earningscall/company.py | 9 ++-- pyproject.toml | 2 +- tests/test_api.py | 87 +++++++++++++++++++++++++++++- tests/test_download_audio_files.py | 1 + tests/test_exports.py | 6 +-- tests/test_get_company_events.py | 45 ++++++++++++++-- 8 files changed, 164 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aec158..7d14d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/earningscall/api.py b/earningscall/api.py index 8e3ac89..20fa4aa 100644 --- a/earningscall/api.py +++ b/earningscall/api.py @@ -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") @@ -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" @@ -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() @@ -79,7 +79,7 @@ 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(), @@ -87,7 +87,9 @@ def get_headers(): } -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. """ @@ -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() @@ -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 @@ -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() diff --git a/earningscall/company.py b/earningscall/company.py index 3b8d34b..2f76b38 100644 --- a/earningscall/company.py +++ b/earningscall/company.py @@ -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]: diff --git a/pyproject.toml b/pyproject.toml index 923374f..840831b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" }] diff --git a/tests/test_api.py b/tests/test_api.py index 7a86bae..3d78d1b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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): @@ -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() diff --git a/tests/test_download_audio_files.py b/tests/test_download_audio_files.py index 611fcab..2ebefc1 100644 --- a/tests/test_download_audio_files.py +++ b/tests/test_download_audio_files.py @@ -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")) diff --git a/tests/test_exports.py b/tests/test_exports.py index a2fe490..8746a13 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -1,4 +1,5 @@ import pytest +import requests import responses import earningscall @@ -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()) diff --git a/tests/test_get_company_events.py b/tests/test_get_company_events.py index 83fd737..f1db40d 100644 --- a/tests/test_get_company_events.py +++ b/tests/test_get_company_events.py @@ -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 @@ -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")) @@ -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()