fmp-data 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fmp_data/__init__.py +29 -0
- fmp_data/alternative/__init__.py +32 -0
- fmp_data/alternative/client.py +144 -0
- fmp_data/alternative/endpoints.py +359 -0
- fmp_data/alternative/models.py +267 -0
- fmp_data/base.py +289 -0
- fmp_data/client.py +256 -0
- fmp_data/company/__init__.py +20 -0
- fmp_data/company/client.py +119 -0
- fmp_data/company/endpoints.py +295 -0
- fmp_data/company/models.py +302 -0
- fmp_data/config.py +196 -0
- fmp_data/economics/__init__.py +16 -0
- fmp_data/economics/client.py +52 -0
- fmp_data/economics/endpoints.py +101 -0
- fmp_data/economics/models.py +66 -0
- fmp_data/exceptions.py +54 -0
- fmp_data/fundamental/__init__.py +40 -0
- fmp_data/fundamental/client.py +72 -0
- fmp_data/fundamental/endpoints.py +309 -0
- fmp_data/fundamental/models.py +887 -0
- fmp_data/institutional/__init__.py +26 -0
- fmp_data/institutional/client.py +59 -0
- fmp_data/institutional/endpoints.py +208 -0
- fmp_data/institutional/models.py +207 -0
- fmp_data/intelligence/__init__.py +34 -0
- fmp_data/intelligence/client.py +117 -0
- fmp_data/intelligence/endpoints.py +339 -0
- fmp_data/intelligence/models.py +356 -0
- fmp_data/investment/__init__.py +24 -0
- fmp_data/investment/client.py +90 -0
- fmp_data/investment/endpoints.py +241 -0
- fmp_data/investment/models.py +205 -0
- fmp_data/logger.py +351 -0
- fmp_data/market/__init__.py +26 -0
- fmp_data/market/client.py +93 -0
- fmp_data/market/endpoints.py +203 -0
- fmp_data/market/models.py +238 -0
- fmp_data/models.py +198 -0
- fmp_data/rate_limit.py +136 -0
- fmp_data/technical/__init__.py +28 -0
- fmp_data/technical/client.py +214 -0
- fmp_data/technical/endpoints.py +100 -0
- fmp_data/technical/models.py +78 -0
- fmp_data-0.1.0.dist-info/LICENSE +21 -0
- fmp_data-0.1.0.dist-info/METADATA +355 -0
- fmp_data-0.1.0.dist-info/NOTICE +37 -0
- fmp_data-0.1.0.dist-info/RECORD +50 -0
- fmp_data-0.1.0.dist-info/WHEEL +4 -0
- fmp_data-0.1.0.dist-info/entry_points.txt +9 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# fmp_data/alternative/models.py
|
|
2
|
+
import warnings
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
from zoneinfo import ZoneInfo
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
7
|
+
|
|
8
|
+
UTC = ZoneInfo("UTC")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Base Models
|
|
12
|
+
class PriceQuote(BaseModel):
|
|
13
|
+
"""Base model for price quotes"""
|
|
14
|
+
|
|
15
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
16
|
+
|
|
17
|
+
# Required fields
|
|
18
|
+
symbol: str = Field(description="Trading symbol")
|
|
19
|
+
price: float | None = Field(None, description="Current price")
|
|
20
|
+
change: float | None = Field(None, description="Price change")
|
|
21
|
+
change_percent: float = Field(
|
|
22
|
+
alias="changesPercentage", description="Percent change"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Optional fields
|
|
26
|
+
name: str | None = Field(None, description="Symbol name or pair name")
|
|
27
|
+
|
|
28
|
+
# Day range
|
|
29
|
+
day_low: float | None = Field(None, alias="dayLow", description="Day low price")
|
|
30
|
+
day_high: float | None = Field(None, alias="dayHigh", description="Day high price")
|
|
31
|
+
|
|
32
|
+
# Year range
|
|
33
|
+
year_high: float | None = Field(None, alias="yearHigh", description="52-week high")
|
|
34
|
+
year_low: float | None = Field(None, alias="yearLow", description="52-week low")
|
|
35
|
+
|
|
36
|
+
# Moving averages
|
|
37
|
+
price_avg_50: float | None = Field(
|
|
38
|
+
None, alias="priceAvg50", description="50-day average"
|
|
39
|
+
)
|
|
40
|
+
price_avg_200: float | None = Field(
|
|
41
|
+
None, alias="priceAvg200", description="200-day average"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Volume
|
|
45
|
+
volume: float | None = Field(None, description="Trading volume")
|
|
46
|
+
avg_volume: float | None = Field(
|
|
47
|
+
None, alias="avgVolume", description="Average volume"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Other price points
|
|
51
|
+
open: float | None = Field(None, description="Opening price")
|
|
52
|
+
previous_close: float | None = Field(
|
|
53
|
+
None, alias="previousClose", description="Previous close"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Market data
|
|
57
|
+
market_cap: float | None = Field(
|
|
58
|
+
None, alias="marketCap", description="Market capitalization"
|
|
59
|
+
)
|
|
60
|
+
eps: float | None = Field(None, description="Earnings per share")
|
|
61
|
+
pe: float | None = Field(None, description="Price to earnings ratio")
|
|
62
|
+
shares_outstanding: float | None = Field(
|
|
63
|
+
None, alias="sharesOutstanding", description="Shares outstanding"
|
|
64
|
+
)
|
|
65
|
+
earnings_announcement: datetime | None = Field(
|
|
66
|
+
None, alias="earningsAnnouncement", description="Next earnings date"
|
|
67
|
+
)
|
|
68
|
+
exchange: str | None = Field(None, description="Exchange name")
|
|
69
|
+
|
|
70
|
+
timestamp: datetime | None = Field(
|
|
71
|
+
None,
|
|
72
|
+
description="Quote timestamp",
|
|
73
|
+
json_schema_extra={"format": "unix-timestamp"},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@field_validator("timestamp", mode="before")
|
|
77
|
+
def parse_timestamp(cls, value):
|
|
78
|
+
"""Parse Unix timestamp to datetime"""
|
|
79
|
+
if value is None:
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
return datetime.fromtimestamp(value, tz=UTC)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
warnings.warn(f"Failed to parse timestamp {value}: {e}", stacklevel=2)
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class HistoricalPrice(BaseModel):
|
|
89
|
+
"""Base model for historical price data"""
|
|
90
|
+
|
|
91
|
+
price_date: date = Field(
|
|
92
|
+
description="The date of the historical record", alias="date"
|
|
93
|
+
)
|
|
94
|
+
open: float = Field(description="Opening price")
|
|
95
|
+
high: float = Field(description="Highest price of the day")
|
|
96
|
+
low: float = Field(description="Lowest price of the day")
|
|
97
|
+
close: float = Field(description="Closing price")
|
|
98
|
+
adj_close: float = Field(alias="adjClose", description="Adjusted closing price")
|
|
99
|
+
volume: int = Field(description="Volume traded")
|
|
100
|
+
unadjusted_volume: int = Field(
|
|
101
|
+
alias="unadjustedVolume", description="Unadjusted trading volume"
|
|
102
|
+
)
|
|
103
|
+
change: float = Field(description="Price change")
|
|
104
|
+
change_percent: float | None = Field(
|
|
105
|
+
None, alias="changePercent", description="Percentage change in price"
|
|
106
|
+
)
|
|
107
|
+
vwap: float | None = Field(None, description="Volume-weighted average price")
|
|
108
|
+
label: str | None = Field(None, description="Formatted label for the date")
|
|
109
|
+
change_over_time: float | None = Field(
|
|
110
|
+
None, alias="changeOverTime", description="Change over time as a percentage"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class IntradayPrice(BaseModel):
|
|
115
|
+
"""Base model for intraday prices"""
|
|
116
|
+
|
|
117
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
118
|
+
|
|
119
|
+
date: datetime = Field(description="Price date and time")
|
|
120
|
+
open: float = Field(description="Opening price")
|
|
121
|
+
high: float = Field(description="High price")
|
|
122
|
+
low: float = Field(description="Low price")
|
|
123
|
+
close: float = Field(description="Closing price")
|
|
124
|
+
volume: float | None = Field(None, description="Trading volume") # Changed to float
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# Cryptocurrency Models
|
|
128
|
+
class CryptoPair(BaseModel):
|
|
129
|
+
"""Cryptocurrency trading pair information"""
|
|
130
|
+
|
|
131
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
132
|
+
|
|
133
|
+
symbol: str = Field(description="Trading symbol")
|
|
134
|
+
name: str = Field(description="Cryptocurrency name")
|
|
135
|
+
currency: str = Field(description="Quote currency")
|
|
136
|
+
stock_exchange: str = Field(
|
|
137
|
+
alias="stockExchange", description="Full name of the stock exchange"
|
|
138
|
+
)
|
|
139
|
+
exchange_short_name: str = Field(
|
|
140
|
+
alias="exchangeShortName", description="Short name of the exchange"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class CryptoQuote(PriceQuote):
|
|
145
|
+
"""Cryptocurrency price quote"""
|
|
146
|
+
|
|
147
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
148
|
+
|
|
149
|
+
@field_validator("timestamp", mode="before")
|
|
150
|
+
def parse_timestamp(cls, value: int) -> datetime | None:
|
|
151
|
+
"""Convert Unix timestamp to datetime"""
|
|
152
|
+
try:
|
|
153
|
+
return datetime.fromtimestamp(value, tz=UTC)
|
|
154
|
+
except (ValueError, TypeError) as e:
|
|
155
|
+
warnings.warn(f"Failed to parse timestamp {value}: {e}", stacklevel=2)
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class CryptoHistoricalPrice(HistoricalPrice):
|
|
160
|
+
"""Cryptocurrency historical price"""
|
|
161
|
+
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class CryptoHistoricalData(BaseModel):
|
|
166
|
+
"""Historical price data wrapper"""
|
|
167
|
+
|
|
168
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
169
|
+
|
|
170
|
+
symbol: str = Field(description="Trading symbol")
|
|
171
|
+
historical: list[CryptoHistoricalPrice] = Field(
|
|
172
|
+
description="Historical price records"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class CryptoIntradayPrice(IntradayPrice):
|
|
177
|
+
"""Cryptocurrency intraday price"""
|
|
178
|
+
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# Forex Models
|
|
183
|
+
class ForexPair(BaseModel):
|
|
184
|
+
"""Forex trading pair information"""
|
|
185
|
+
|
|
186
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
187
|
+
|
|
188
|
+
symbol: str = Field(description="Trading symbol")
|
|
189
|
+
name: str = Field(description="Pair name")
|
|
190
|
+
currency: str = Field(description="Quote currency")
|
|
191
|
+
stock_exchange: str = Field(
|
|
192
|
+
alias="stockExchange", description="Stock exchange code"
|
|
193
|
+
)
|
|
194
|
+
exchange_short_name: str = Field(
|
|
195
|
+
alias="exchangeShortName", description="Exchange short name"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class ForexQuote(PriceQuote):
|
|
200
|
+
"""Forex price quote"""
|
|
201
|
+
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class ForexHistoricalPrice(HistoricalPrice):
|
|
206
|
+
"""Forex historical price"""
|
|
207
|
+
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class ForexPriceHistory(BaseModel):
|
|
212
|
+
"""Full forex price history"""
|
|
213
|
+
|
|
214
|
+
symbol: str = Field(description="Symbol for the currency pair")
|
|
215
|
+
historical: list[ForexHistoricalPrice] = Field(
|
|
216
|
+
description="List of historical price data for the forex pair"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class ForexIntradayPrice(IntradayPrice):
|
|
221
|
+
"""Forex intraday price"""
|
|
222
|
+
|
|
223
|
+
pass
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# Commodities Models
|
|
227
|
+
class Commodity(BaseModel):
|
|
228
|
+
"""Commodity information"""
|
|
229
|
+
|
|
230
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
231
|
+
|
|
232
|
+
symbol: str = Field(description="Trading symbol")
|
|
233
|
+
name: str = Field(description="Commodity name")
|
|
234
|
+
currency: str = Field(description="Trading currency")
|
|
235
|
+
stock_exchange: str = Field(
|
|
236
|
+
alias="stockExchange", description="Full name of the stock exchange"
|
|
237
|
+
)
|
|
238
|
+
exchange_short_name: str = Field(
|
|
239
|
+
alias="exchangeShortName", description="Short name of the exchange category"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class CommodityQuote(PriceQuote):
|
|
244
|
+
"""Commodity price quote"""
|
|
245
|
+
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class CommodityHistoricalPrice(HistoricalPrice):
|
|
250
|
+
"""Commodity historical price"""
|
|
251
|
+
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class CommodityPriceHistory(BaseModel):
|
|
256
|
+
"""Full commodity price history"""
|
|
257
|
+
|
|
258
|
+
symbol: str = Field(description="Symbol for the commodity")
|
|
259
|
+
historical: list[CommodityHistoricalPrice] = Field(
|
|
260
|
+
description="List of historical price data"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class CommodityIntradayPrice(IntradayPrice):
|
|
265
|
+
"""Commodity intraday price"""
|
|
266
|
+
|
|
267
|
+
pass
|
fmp_data/base.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# base.py
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
import warnings
|
|
6
|
+
from typing import Any, TypeVar
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
from tenacity import (
|
|
11
|
+
after_log,
|
|
12
|
+
before_sleep_log,
|
|
13
|
+
retry,
|
|
14
|
+
retry_if_exception_type,
|
|
15
|
+
stop_after_attempt,
|
|
16
|
+
wait_exponential,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from fmp_data.config import ClientConfig
|
|
20
|
+
from fmp_data.exceptions import (
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
FMPError,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
ValidationError,
|
|
25
|
+
)
|
|
26
|
+
from fmp_data.logger import FMPLogger, log_api_call
|
|
27
|
+
from fmp_data.models import Endpoint
|
|
28
|
+
from fmp_data.rate_limit import FMPRateLimiter, QuotaConfig
|
|
29
|
+
|
|
30
|
+
T = TypeVar("T", bound=BaseModel)
|
|
31
|
+
|
|
32
|
+
logger = FMPLogger().get_logger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BaseClient:
|
|
36
|
+
def __init__(self, config: ClientConfig):
|
|
37
|
+
self.config = config
|
|
38
|
+
self.logger = FMPLogger().get_logger(__name__)
|
|
39
|
+
self.max_rate_limit_retries = getattr(config, "max_rate_limit_retries", 3)
|
|
40
|
+
self._rate_limit_retry_count = 0
|
|
41
|
+
|
|
42
|
+
# Configure logging based on config
|
|
43
|
+
FMPLogger().configure(self.config.logging)
|
|
44
|
+
|
|
45
|
+
self._setup_http_client()
|
|
46
|
+
self.logger.info(
|
|
47
|
+
"Initializing API client",
|
|
48
|
+
extra={"base_url": self.config.base_url, "timeout": self.config.timeout},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Initialize rate limiter
|
|
52
|
+
self._rate_limiter = FMPRateLimiter(
|
|
53
|
+
QuotaConfig(
|
|
54
|
+
daily_limit=self.config.rate_limit.daily_limit,
|
|
55
|
+
requests_per_second=self.config.rate_limit.requests_per_second,
|
|
56
|
+
requests_per_minute=self.config.rate_limit.requests_per_minute,
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _setup_http_client(self):
|
|
61
|
+
"""Setup HTTP client with default configuration"""
|
|
62
|
+
self.client = httpx.Client(
|
|
63
|
+
timeout=self.config.timeout,
|
|
64
|
+
follow_redirects=True,
|
|
65
|
+
headers={
|
|
66
|
+
"User-Agent": "FMP-Python-Client/1.0",
|
|
67
|
+
"Accept": "application/json",
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def close(self):
|
|
72
|
+
"""Clean up resources"""
|
|
73
|
+
if hasattr(self, "client") and self.client is not None:
|
|
74
|
+
self.client.close()
|
|
75
|
+
|
|
76
|
+
def _handle_rate_limit(self, wait_time: float) -> None:
|
|
77
|
+
"""
|
|
78
|
+
Handle rate limiting by waiting or raising an exception based on retry count
|
|
79
|
+
"""
|
|
80
|
+
self._rate_limit_retry_count += 1
|
|
81
|
+
|
|
82
|
+
if self._rate_limit_retry_count > self.max_rate_limit_retries:
|
|
83
|
+
self._rate_limit_retry_count = 0 # Reset for next request
|
|
84
|
+
raise RateLimitError(
|
|
85
|
+
f"Rate limit exceeded after "
|
|
86
|
+
f"{self.max_rate_limit_retries} retries. "
|
|
87
|
+
f"Please wait {wait_time:.1f} seconds",
|
|
88
|
+
retry_after=wait_time,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
self.logger.warning(
|
|
92
|
+
f"Rate limit reached "
|
|
93
|
+
f"(attempt {self._rate_limit_retry_count}/{self.max_rate_limit_retries}), "
|
|
94
|
+
f"waiting {wait_time:.1f} seconds before retrying"
|
|
95
|
+
)
|
|
96
|
+
time.sleep(wait_time)
|
|
97
|
+
|
|
98
|
+
@retry(
|
|
99
|
+
stop=stop_after_attempt(3),
|
|
100
|
+
wait=wait_exponential(multiplier=1, min=4, max=10),
|
|
101
|
+
retry=retry_if_exception_type(
|
|
102
|
+
(httpx.TimeoutException, httpx.NetworkError, httpx.HTTPStatusError)
|
|
103
|
+
),
|
|
104
|
+
before_sleep=before_sleep_log(logger, logging.WARNING),
|
|
105
|
+
after=after_log(logger, logging.INFO),
|
|
106
|
+
)
|
|
107
|
+
@log_api_call()
|
|
108
|
+
def request(self, endpoint: Endpoint[T], **kwargs) -> T | list[T]:
|
|
109
|
+
"""Make request with rate limiting and retry logic"""
|
|
110
|
+
# First, check if we're already over the rate limit
|
|
111
|
+
if not self._rate_limiter.should_allow_request():
|
|
112
|
+
wait_time = self._rate_limiter.get_wait_time()
|
|
113
|
+
raise RateLimitError(
|
|
114
|
+
f"Rate limit exceeded. Please wait {wait_time:.1f} seconds",
|
|
115
|
+
retry_after=wait_time,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
self._rate_limit_retry_count = 0 # Reset counter at start of new request
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
self._rate_limiter.record_request()
|
|
122
|
+
|
|
123
|
+
# Validate and process parameters
|
|
124
|
+
validated_params = endpoint.validate_params(kwargs)
|
|
125
|
+
|
|
126
|
+
# Build URL
|
|
127
|
+
url = endpoint.build_url(self.config.base_url, validated_params)
|
|
128
|
+
|
|
129
|
+
# Extract query parameters and add API key
|
|
130
|
+
query_params = endpoint.get_query_params(validated_params)
|
|
131
|
+
query_params["apikey"] = self.config.api_key
|
|
132
|
+
|
|
133
|
+
self.logger.debug(
|
|
134
|
+
f"Making request to {endpoint.name}",
|
|
135
|
+
extra={
|
|
136
|
+
"url": url,
|
|
137
|
+
"endpoint": endpoint.name,
|
|
138
|
+
"method": endpoint.method.value,
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
response = self.client.request(
|
|
143
|
+
endpoint.method.value, url, params=query_params
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Handle 429 responses from the API
|
|
147
|
+
if response.status_code == 429:
|
|
148
|
+
self._rate_limiter.handle_response(response.status_code, response.text)
|
|
149
|
+
wait_time = self._rate_limiter.get_wait_time()
|
|
150
|
+
raise RateLimitError(
|
|
151
|
+
f"Rate limit exceeded. Please wait {wait_time:.1f} seconds",
|
|
152
|
+
retry_after=wait_time,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
data = self.handle_response(response)
|
|
156
|
+
return self._process_response(endpoint, data)
|
|
157
|
+
|
|
158
|
+
except Exception as e:
|
|
159
|
+
self.logger.error(
|
|
160
|
+
f"Request failed: {str(e)}",
|
|
161
|
+
extra={"endpoint": endpoint.name, "error": str(e)},
|
|
162
|
+
exc_info=True,
|
|
163
|
+
)
|
|
164
|
+
raise
|
|
165
|
+
|
|
166
|
+
def handle_response(self, response: httpx.Response) -> dict[str, Any]:
|
|
167
|
+
"""Handle API response and errors"""
|
|
168
|
+
try:
|
|
169
|
+
response.raise_for_status()
|
|
170
|
+
return response.json()
|
|
171
|
+
except httpx.HTTPStatusError as e:
|
|
172
|
+
error_details = {}
|
|
173
|
+
try:
|
|
174
|
+
error_details = e.response.json()
|
|
175
|
+
except json.JSONDecodeError:
|
|
176
|
+
error_details["raw_content"] = e.response.content.decode()
|
|
177
|
+
|
|
178
|
+
if e.response.status_code == 429:
|
|
179
|
+
wait_time = self._rate_limiter.get_wait_time()
|
|
180
|
+
raise RateLimitError(
|
|
181
|
+
f"Rate limit exceeded. Please wait {wait_time:.1f} seconds",
|
|
182
|
+
status_code=429,
|
|
183
|
+
response=error_details,
|
|
184
|
+
retry_after=wait_time,
|
|
185
|
+
) from e
|
|
186
|
+
elif e.response.status_code == 401:
|
|
187
|
+
raise AuthenticationError(
|
|
188
|
+
"Invalid API key or authentication failed",
|
|
189
|
+
status_code=401,
|
|
190
|
+
response=error_details,
|
|
191
|
+
) from e
|
|
192
|
+
elif e.response.status_code == 400:
|
|
193
|
+
raise ValidationError(
|
|
194
|
+
f"Invalid request parameters: {error_details}",
|
|
195
|
+
status_code=400,
|
|
196
|
+
response=error_details,
|
|
197
|
+
) from e
|
|
198
|
+
else:
|
|
199
|
+
raise FMPError(
|
|
200
|
+
f"HTTP {e.response.status_code} error occurred: {error_details}",
|
|
201
|
+
status_code=e.response.status_code,
|
|
202
|
+
response=error_details,
|
|
203
|
+
) from e
|
|
204
|
+
except json.JSONDecodeError as e:
|
|
205
|
+
raise FMPError(
|
|
206
|
+
f"Invalid JSON response from API: {str(e)}",
|
|
207
|
+
response={"raw_content": response.content.decode()},
|
|
208
|
+
) from e
|
|
209
|
+
|
|
210
|
+
@staticmethod
|
|
211
|
+
def _process_response(endpoint: Endpoint[T], data: Any) -> T | list[T]:
|
|
212
|
+
"""Process the response data with warning handling"""
|
|
213
|
+
if isinstance(data, dict):
|
|
214
|
+
# Check for different forms of error messages
|
|
215
|
+
if "Error Message" in data:
|
|
216
|
+
raise FMPError(data["Error Message"])
|
|
217
|
+
if "message" in data:
|
|
218
|
+
raise FMPError(data["message"])
|
|
219
|
+
if "error" in data:
|
|
220
|
+
raise FMPError(data["error"])
|
|
221
|
+
|
|
222
|
+
if isinstance(data, list):
|
|
223
|
+
processed_items: list[T] = []
|
|
224
|
+
for item in data:
|
|
225
|
+
with warnings.catch_warnings(record=True) as w:
|
|
226
|
+
# Enable all warnings
|
|
227
|
+
warnings.simplefilter("always")
|
|
228
|
+
# Process item
|
|
229
|
+
if isinstance(item, dict):
|
|
230
|
+
processed_item = endpoint.response_model.model_validate(item)
|
|
231
|
+
else:
|
|
232
|
+
# Get the first field info directly from model config
|
|
233
|
+
model = endpoint.response_model
|
|
234
|
+
try:
|
|
235
|
+
# Get first field name from the model
|
|
236
|
+
first_field = next(iter(model.__annotations__))
|
|
237
|
+
# Try to get alias from field info
|
|
238
|
+
field_info = model.model_fields[first_field]
|
|
239
|
+
field_name = field_info.alias or first_field
|
|
240
|
+
processed_item = model.model_validate({field_name: item})
|
|
241
|
+
except (StopIteration, KeyError, AttributeError) as e:
|
|
242
|
+
raise ValueError(
|
|
243
|
+
f"Invalid model structure for {model.__name__}"
|
|
244
|
+
) from e
|
|
245
|
+
|
|
246
|
+
# Log any warnings
|
|
247
|
+
for warning in w:
|
|
248
|
+
logger.warning(f"Validation warning: {warning.message}")
|
|
249
|
+
processed_items.append(processed_item)
|
|
250
|
+
return processed_items
|
|
251
|
+
return endpoint.response_model.model_validate(data)
|
|
252
|
+
|
|
253
|
+
async def request_async(self, endpoint: Endpoint[T], **kwargs) -> T | list[T]:
|
|
254
|
+
"""Make async request with rate limiting"""
|
|
255
|
+
validated_params = endpoint.validate_params(kwargs)
|
|
256
|
+
url = endpoint.build_url(self.config.base_url, validated_params)
|
|
257
|
+
query_params = endpoint.get_query_params(validated_params)
|
|
258
|
+
query_params["apikey"] = self.config.api_key
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
# Add timeout and other settings from config
|
|
262
|
+
async with httpx.AsyncClient(
|
|
263
|
+
timeout=self.config.timeout,
|
|
264
|
+
follow_redirects=True,
|
|
265
|
+
headers={
|
|
266
|
+
"User-Agent": "FMP-Python-Client/1.0",
|
|
267
|
+
"Accept": "application/json",
|
|
268
|
+
},
|
|
269
|
+
) as client:
|
|
270
|
+
response = await client.request(
|
|
271
|
+
endpoint.method.value, url, params=query_params
|
|
272
|
+
)
|
|
273
|
+
data = self.handle_response(response)
|
|
274
|
+
return self._process_response(endpoint, data)
|
|
275
|
+
except Exception as e:
|
|
276
|
+
self.logger.error(f"Async request failed: {str(e)}")
|
|
277
|
+
raise
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class EndpointGroup:
|
|
281
|
+
"""Abstract base class for endpoint groups"""
|
|
282
|
+
|
|
283
|
+
def __init__(self, client: BaseClient):
|
|
284
|
+
self._client = client
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def client(self) -> BaseClient:
|
|
288
|
+
"""Get the client instance"""
|
|
289
|
+
return self._client
|