hass-release/hassrelease/github.py

145 lines
5.3 KiB
Python

import os
import time
from distutils.version import StrictVersion
import requests
from github3 import GitHub
from github3.exceptions import GitHubError
from .const import TOKEN_FILE
from .core import HassReleaseError
def get_session():
"""Fetch and/or load API authorization token for GitHub."""
token = None
if os.path.isfile(TOKEN_FILE):
with open(TOKEN_FILE) as fd:
token = fd.readline().strip()
elif "GITHUB_TOKEN" in os.environ:
token = os.environ["GITHUB_TOKEN"]
if token is None:
raise HassReleaseError(
"Please write a GitHub token to .token or set GITHUB_TOKEN env var"
)
gh = GitHub(token=token)
try: # Test connection before starting
gh.is_starred("github", "gitignore")
return gh
except GitHubError:
raise HassReleaseError("Invalid token found")
def get_milestone_by_title(repo, title):
"""Fetch milestone by title."""
seen = []
for ms in repo.milestones(state="open"):
if ms.title == title:
return ms
seen.append(ms.title)
raise HassReleaseError(
"Milestone {} not found. Open milestones: {}".format(title, ", ".join(seen))
)
def get_latest_version_milestone(repo):
"""Fetch milestone by title."""
milestones = []
for ms in repo.milestones(state="open"):
try:
milestones.append((StrictVersion(ms.title), ms))
except ValueError:
print("Found milestone with invalid version", ms.title)
if not milestones:
raise HassReleaseError("No milestones found")
return list(reversed(sorted(milestones)))[0][1]
# TODO replace with a function? Use 'partial'.
class MyGitHub:
# GitHub API endpoint address
ENDPOINT = "https://api.github.com"
# GitHub API response header keys.
RATELIMIT_REMAINING_STR = "X-RateLimit-Remaining"
RATELIMIT_LIMIT_STR = "X-RateLimit-Limit"
RATELIMIT_RESET_STR = "X-RateLimit-Reset"
RETRY_AFTER_STR = "Retry-After"
def __init__(self, token: str = None, quiet: bool = False):
# The time when the GitHub API is going to be available.
self.quiet = quiet
self.next_time_available = 0
self.last_logged_next_time_available = self.next_time_available
self.headers = {"Accept": "application/vnd.github.v3+json"}
if token is not None:
self.headers["Authorization"] = "token " + token
def log_timeout(self, available_after):
if self.last_logged_next_time_available == self.next_time_available:
pass
else:
print(
"Rate limit exceeded. Retrying in {} (at {})".format(
time.strftime("%H:%M:%S", time.gmtime(available_after)),
time.asctime(time.gmtime(time.time() + available_after)),
)
)
self.last_logged_next_time_available = self.next_time_available
def request_with_retry(self, url: str, params: dict = None):
"""
GETs HTTP data with awareness of possible rate-limit and rate-limit
abuse protection limitations. If there are any, waits for them to
expire and then retries.
Basically a 'requests.get()' wrapper.
:param url: Matches the corresponding parameter of requests.get().
:param params: Matches the corresponding parameter of requests.get().
:return: Matches the return of requests.get() method.
"""
# Retry until a response is returned.
while True:
available_after = self.next_time_available - int(time.time())
if available_after > 0:
if not self.quiet:
self.log_timeout(available_after)
time.sleep(available_after)
# The API must be available at that point
try:
resp = requests.get(url, params, headers=self.headers)
except requests.exceptions.ConnectionError as err:
print("A ConnectionError was caught. Retrying. Error: {}".format(err))
continue
# If forbidden (may be because of rate-limit timeout. If so,
# we'll wait and then retry).
if resp.status_code == 403:
# There may be multiple reasons for this.
# If it is the rate-limit abuse protection, there will
# be such field.
retry_after = resp.headers.get(MyGitHub.RETRY_AFTER_STR)
if retry_after is not None:
self.next_time_available = int(time.time()) + int(retry_after)
# Back to waiting.
# If it is not the abuse protection.
else:
# Maybe rate-limit exhaustion?
ratelimit_reset = resp.headers.get(MyGitHub.RATELIMIT_RESET_STR)
# If it is rate-limit exhaustion.
if ratelimit_reset is not None:
self.next_time_available = int(ratelimit_reset)
# Back to waiting.
# If it is something else
else:
# This method is not responsible for this
return resp
# If some other case. It may be a success, or it may be an
# another error. This method is not responsible for this.
else:
return resp