Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Raise for status when Yahoo! returns 403 / use User-Agent header #74

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
53 changes: 41 additions & 12 deletions beanprice/sources/yahoo.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,31 +29,49 @@ class YahooError(ValueError):
"An error from the Yahoo API."


def _requestor(*args, **kwargs):
if "headers" not in kwargs:
kwargs["headers"] = {}
# Yahoo! balks without this header.
kwargs["headers"]["User-Agent"] = (
"Mozilla/5.0 (X11; Linux x86_64; "
"rv:109.0) Gecko/20100101 Firefox/110.0"
)
response = requests.get(*args, **kwargs)
try:
response.raise_for_status()
except requests.HTTPError as exc:
raise YahooError(
"HTTP status {}: {}".format(
response.status_code,
response.text(),
)
) from exc
return response


def parse_response(response: requests.models.Response) -> Dict:
"""Process as response from Yahoo.

Assumes the response code is among the OK response codes.

Raises:
YahooError: If there is an error in the response.
"""
json = response.json(parse_float=Decimal)
content = next(iter(json.values()))
if response.status_code != requests.codes.ok:
raise YahooError("Status {}: {}".format(response.status_code, content['error']))
if len(json) != 1:
raise YahooError("Invalid format in response from Yahoo; many keys: {}".format(
','.join(json.keys())))
if content['error'] is not None:
raise YahooError("Error fetching Yahoo data: {}".format(content['error']))
if not content['result']:
raise YahooError("No data returned from Yahoo, ensure that the symbol is correct")
return content['result'][0]


# Note: Feel free to suggest more here via a PR.
_MARKETS = {
'us_market': 'USD',
'ca_market': 'CAD',
'ch_market': 'CHF',
}


Expand Down Expand Up @@ -85,8 +103,16 @@ def get_price_series(ticker: str,
'interval': '1d',
}
payload.update(_DEFAULT_PARAMS)
response = requests.get(url, params=payload, headers={'User-Agent': None})
result = parse_response(response)
response = _requestor(url, params=payload)
try:
result = parse_response(response)
except IndexError as exc:
raise YahooError(
(
"Could not destructure price series for ticker {}: "
"the content contains zero-length result"
).format(ticker)
) from exc

meta = result['meta']
tzone = timezone(timedelta(hours=meta['gmtoffset'] / 3600),
Expand Down Expand Up @@ -120,13 +146,16 @@ def get_latest_price(self, ticker: str) -> Optional[source.SourcePrice]:
'exchange': 'NYSE',
}
payload.update(_DEFAULT_PARAMS)
response = requests.get(url, params=payload, headers={'User-Agent': None})
response = _requestor(url, params=payload)
try:
result = parse_response(response)
except YahooError as error:
# The parse_response method cannot know which ticker failed,
# but the user definitely needs to know which ticker failed!
raise YahooError("%s (ticker: %s)" % (error, ticker)) from error
except IndexError as exc:
raise YahooError(
(
"Could not destructure latest price for ticker {}: "
"the content contains zero-length result"
).format(ticker)
) from exc
try:
price = Decimal(result['regularMarketPrice'])

Expand Down
9 changes: 8 additions & 1 deletion beanprice/sources/yahoo_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,15 @@ class MockResponse:
def __init__(self, contents, status_code=requests.codes.ok):
self.status_code = status_code
self.contents = contents
self.text = contents

def json(self, **kwargs):
return json.loads(self.contents, **kwargs)

def raise_for_status(self):
if self.status_code != requests.codes.ok:
raise requests.HTTPError(self.status_code)


class YahooFinancePriceFetcher(unittest.TestCase):

Expand Down Expand Up @@ -157,7 +162,9 @@ def test_parse_response_error_not_none(self):
def test_parse_response_empty_result(self):
response = MockResponse(
'{"quoteResponse": {"error": null, "result": []}}')
with self.assertRaises(yahoo.YahooError):
with self.assertRaises(IndexError):
# Callers re-raise a YahooError from here, to provide
# superior error messages.
yahoo.parse_response(response)

def test_parse_response_no_timestamp(self):
Expand Down