ghpy 0.2.99__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.
- ghpy/Connections.py +44 -0
- ghpy/PooledResponse.py +49 -0
- ghpy/Release.py +149 -0
- ghpy/ReleaseAssets.py +80 -0
- ghpy/Releases.py +54 -0
- ghpy/Request.py +142 -0
- ghpy/RequestFactory.py +53 -0
- ghpy/converters.py +8 -0
- ghpy/errors/AssetNotFoundError.py +7 -0
- ghpy/errors/AssetTimeoutError.py +12 -0
- ghpy/errors/DownloadError.py +8 -0
- ghpy/errors/GhpyError.py +2 -0
- ghpy/errors/GraphQLError.py +10 -0
- ghpy/errors/HTTPError.py +13 -0
- ghpy/errors/InvalidRedirectError.py +6 -0
- ghpy/errors/OperationError.py +5 -0
- ghpy/errors/RedirectError.py +9 -0
- ghpy/errors/ReleaseNotFoundError.py +9 -0
- ghpy/errors/TooManyRedirectsError.py +6 -0
- ghpy/errors/UploadError.py +8 -0
- ghpy/main.py +75 -0
- ghpy/netlib.py +32 -0
- ghpy-0.2.99.dist-info/METADATA +148 -0
- ghpy-0.2.99.dist-info/RECORD +28 -0
- ghpy-0.2.99.dist-info/WHEEL +5 -0
- ghpy-0.2.99.dist-info/entry_points.txt +2 -0
- ghpy-0.2.99.dist-info/licenses/LICENSE +662 -0
- ghpy-0.2.99.dist-info/top_level.txt +1 -0
ghpy/Connections.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from http.client import HTTPSConnection
|
|
4
|
+
from queue import Empty, LifoQueue
|
|
5
|
+
from threading import Semaphore
|
|
6
|
+
from typing import TYPE_CHECKING, BinaryIO
|
|
7
|
+
|
|
8
|
+
from .PooledResponse import PooledResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
MAX_CONNECTIONS = 4
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Connections:
|
|
15
|
+
def __init__(self, host: str, maxConnections: int = MAX_CONNECTIONS):
|
|
16
|
+
self.host = host
|
|
17
|
+
self.connections: LifoQueue[HTTPSConnection] = LifoQueue()
|
|
18
|
+
self.semaphore = Semaphore(maxConnections)
|
|
19
|
+
|
|
20
|
+
def request(self, method: str, url: str, headers: dict[str, str],
|
|
21
|
+
data: bytes | BinaryIO | None) -> PooledResponse:
|
|
22
|
+
connection = self._acquire()
|
|
23
|
+
try:
|
|
24
|
+
connection.request(method, url, headers=headers, body=data)
|
|
25
|
+
response = connection.getresponse()
|
|
26
|
+
except OSError:
|
|
27
|
+
self._close(connection)
|
|
28
|
+
raise
|
|
29
|
+
return PooledResponse(response, self, connection)
|
|
30
|
+
|
|
31
|
+
def takeBack(self, connection: HTTPSConnection) -> None:
|
|
32
|
+
self.connections.put(connection)
|
|
33
|
+
self.semaphore.release()
|
|
34
|
+
|
|
35
|
+
def _acquire(self) -> HTTPSConnection:
|
|
36
|
+
self.semaphore.acquire()
|
|
37
|
+
try:
|
|
38
|
+
return self.connections.get_nowait()
|
|
39
|
+
except Empty:
|
|
40
|
+
return HTTPSConnection(self.host)
|
|
41
|
+
|
|
42
|
+
def _close(self, connection: HTTPSConnection) -> None:
|
|
43
|
+
connection.close()
|
|
44
|
+
self.semaphore.release()
|
ghpy/PooledResponse.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from http.client import HTTPResponse, HTTPSConnection
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from .Connections import Connections
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PooledResponse:
|
|
11
|
+
def __init__(self, response: HTTPResponse, connections: Connections,
|
|
12
|
+
connection: HTTPSConnection):
|
|
13
|
+
self.response = response
|
|
14
|
+
self.connections = connections
|
|
15
|
+
self.connection = connection
|
|
16
|
+
|
|
17
|
+
def __enter__(self) -> "PooledResponse":
|
|
18
|
+
return self
|
|
19
|
+
|
|
20
|
+
def __exit__(self, excType, excValue, traceback) -> None:
|
|
21
|
+
self.close()
|
|
22
|
+
|
|
23
|
+
def close(self) -> None:
|
|
24
|
+
if not self.connection:
|
|
25
|
+
return
|
|
26
|
+
try:
|
|
27
|
+
self.response.read()
|
|
28
|
+
self.response.close()
|
|
29
|
+
finally:
|
|
30
|
+
self.connections.takeBack(self.connection)
|
|
31
|
+
self.connection = None
|
|
32
|
+
|
|
33
|
+
def read(self, amount: int = -1) -> bytes:
|
|
34
|
+
return self.response.read(amount)
|
|
35
|
+
|
|
36
|
+
def getheader(self, name: str, default: str | None = None) -> str | None:
|
|
37
|
+
return self.response.getheader(name, default)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def headers(self):
|
|
41
|
+
return self.response.headers
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def reason(self) -> str:
|
|
45
|
+
return self.response.reason
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def status(self) -> int:
|
|
49
|
+
return self.response.status
|
ghpy/Release.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import math
|
|
3
|
+
import time
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable, Iterable, TypeVar
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from .ReleaseAssets import ReleaseAssets
|
|
10
|
+
from .RequestFactory import RequestFactory
|
|
11
|
+
from .errors.AssetNotFoundError import AssetNotFoundError
|
|
12
|
+
from .errors.AssetTimeoutError import AssetTimeoutError
|
|
13
|
+
from .errors.DownloadError import DownloadError
|
|
14
|
+
from .errors.HTTPError import HTTPError
|
|
15
|
+
from .errors.OperationError import OperationError
|
|
16
|
+
from .errors.UploadError import UploadError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
RELEASE_ASSET_URL = "https://api.github.com/repos/{owner}/{repo}/releases/assets/{id}"
|
|
20
|
+
RELEASE_UPLOAD_URL = "https://uploads.github.com/repos/{owner}/{repo}/releases/{id}/assets"
|
|
21
|
+
MAX_WORKERS = 4
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parallel(items: Iterable[T], function: Callable[[T], None]) -> None:
|
|
29
|
+
failed = False
|
|
30
|
+
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
|
31
|
+
futures = [executor.submit(function, item) for item in items]
|
|
32
|
+
for future in as_completed(futures):
|
|
33
|
+
if error := future.exception():
|
|
34
|
+
logger.error("%s", error)
|
|
35
|
+
failed = True
|
|
36
|
+
if failed:
|
|
37
|
+
raise OperationError("Operation failed")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Release:
|
|
41
|
+
def __init__(self, owner: str, repo: str, data: dict[str, Any], token: str,
|
|
42
|
+
requests: RequestFactory, assets: ReleaseAssets):
|
|
43
|
+
self.owner = owner
|
|
44
|
+
self.repo = repo
|
|
45
|
+
self.id = data["databaseId"]
|
|
46
|
+
self.tag = data["tagName"]
|
|
47
|
+
self.isDraft = data["isDraft"]
|
|
48
|
+
self.isPrivate = data["isPrivate"]
|
|
49
|
+
self.token = token
|
|
50
|
+
self.requests = requests
|
|
51
|
+
self.assets = assets
|
|
52
|
+
|
|
53
|
+
def uploadAssets(self, paths: list[Path], clobber: bool = False) -> None:
|
|
54
|
+
names = [path.name for path in paths]
|
|
55
|
+
if len(names) != len(set(names)):
|
|
56
|
+
raise ValueError("Upload files must have unique names")
|
|
57
|
+
|
|
58
|
+
assets = self.assets.find([]) if clobber else {}
|
|
59
|
+
parallel(paths, lambda path: self.uploadAsset(path, assets.get(path.name)))
|
|
60
|
+
|
|
61
|
+
def uploadAsset(self, path: Path, asset: dict[str, Any] | None = None) -> None:
|
|
62
|
+
if asset is not None:
|
|
63
|
+
self.assets.deleteAsset(asset["id"])
|
|
64
|
+
|
|
65
|
+
url = RELEASE_UPLOAD_URL.format(owner=self.owner, repo=self.repo, id=self.id)
|
|
66
|
+
url += f"?name={quote(path.name)}"
|
|
67
|
+
|
|
68
|
+
file = response = None
|
|
69
|
+
try:
|
|
70
|
+
file = path.open("rb")
|
|
71
|
+
headers = {
|
|
72
|
+
"Authorization": f"Bearer {self.token}",
|
|
73
|
+
"Accept": "application/vnd.github+json",
|
|
74
|
+
"Content-Type": "application/octet-stream",
|
|
75
|
+
"Content-Length": str(path.stat().st_size),
|
|
76
|
+
}
|
|
77
|
+
response = self.requests.post(url, headers=headers, data=file)
|
|
78
|
+
except HTTPError as error:
|
|
79
|
+
raise UploadError(path.name, error.status) from error
|
|
80
|
+
finally:
|
|
81
|
+
file and file.close()
|
|
82
|
+
response and response.close()
|
|
83
|
+
|
|
84
|
+
logger.info("Uploaded %s", path.name)
|
|
85
|
+
|
|
86
|
+
def downloadAssets(self, patterns: list[str]) -> None:
|
|
87
|
+
assets = self.assets.find(patterns)
|
|
88
|
+
if not assets:
|
|
89
|
+
raise AssetNotFoundError(patterns)
|
|
90
|
+
|
|
91
|
+
parallel(assets.items(), self.downloadAsset)
|
|
92
|
+
|
|
93
|
+
def downloadAsset(self, asset: tuple[str, dict[str, Any]]) -> None:
|
|
94
|
+
name, data = asset
|
|
95
|
+
url = (
|
|
96
|
+
RELEASE_ASSET_URL.format(owner=self.owner, repo=self.repo, id=data["id"])
|
|
97
|
+
if self.isPrivate
|
|
98
|
+
else data["browser_download_url"]
|
|
99
|
+
)
|
|
100
|
+
headers = {
|
|
101
|
+
"Authorization": f"Bearer {self.token}",
|
|
102
|
+
"Accept": "application/octet-stream"
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
file = response = None
|
|
106
|
+
try:
|
|
107
|
+
response = self.requests.get(url, headers=headers)
|
|
108
|
+
file = Path(name).open("wb")
|
|
109
|
+
while chunk := response.read(1024 * 1024):
|
|
110
|
+
file.write(chunk)
|
|
111
|
+
except HTTPError as error:
|
|
112
|
+
raise DownloadError(name, error.status) from error
|
|
113
|
+
finally:
|
|
114
|
+
file and file.close()
|
|
115
|
+
response and response.close()
|
|
116
|
+
|
|
117
|
+
logger.info("Downloaded %s", name)
|
|
118
|
+
|
|
119
|
+
def awaitAssets(self, names: list[str]) -> None:
|
|
120
|
+
deadline = time.monotonic() + self.requests.timeout
|
|
121
|
+
parallel(self.assets.awaitAssets(names, deadline),
|
|
122
|
+
lambda asset: self.awaitAsset(asset, deadline))
|
|
123
|
+
|
|
124
|
+
def awaitAsset(self, asset: tuple[str, dict[str, Any]],
|
|
125
|
+
deadline: float) -> None:
|
|
126
|
+
name, data = asset
|
|
127
|
+
logger.info("Waiting for %s", name)
|
|
128
|
+
url = (
|
|
129
|
+
RELEASE_ASSET_URL.format(owner=self.owner, repo=self.repo, id=data["id"])
|
|
130
|
+
if self.isPrivate else data["browser_download_url"]
|
|
131
|
+
)
|
|
132
|
+
headers = {
|
|
133
|
+
"Authorization": f"Bearer {self.token}",
|
|
134
|
+
"Accept": "application/octet-stream",
|
|
135
|
+
}
|
|
136
|
+
remaining = math.ceil(deadline - time.monotonic())
|
|
137
|
+
if remaining <= 0:
|
|
138
|
+
raise AssetTimeoutError(name)
|
|
139
|
+
|
|
140
|
+
response = None
|
|
141
|
+
try:
|
|
142
|
+
response = self.requests.get(url, headers=headers, retryStatuses={404},
|
|
143
|
+
timeout=remaining)
|
|
144
|
+
except TimeoutError as error:
|
|
145
|
+
raise AssetTimeoutError(name) from error
|
|
146
|
+
finally:
|
|
147
|
+
response and response.close()
|
|
148
|
+
|
|
149
|
+
logger.info("Available %s", name)
|
ghpy/ReleaseAssets.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from typing import Any, Iterator
|
|
6
|
+
|
|
7
|
+
from fnmatch import fnmatch
|
|
8
|
+
from .errors.AssetTimeoutError import AssetTimeoutError
|
|
9
|
+
from .RequestFactory import RequestFactory
|
|
10
|
+
|
|
11
|
+
PAGE_SIZE = 100
|
|
12
|
+
MAX_ASSETS = 1000
|
|
13
|
+
MAX_PAGES = MAX_ASSETS // PAGE_SIZE
|
|
14
|
+
|
|
15
|
+
RELEASE_ASSETS_URL = "https://api.github.com/repos/{owner}/{repo}/releases/{id}/assets"
|
|
16
|
+
RELEASE_ASSET_URL = "https://api.github.com/repos/{owner}/{repo}/releases/assets/{id}"
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ReleaseAssets:
|
|
22
|
+
def __init__(self, requests: RequestFactory, token: str, owner: str, repo: str,
|
|
23
|
+
releaseId: int):
|
|
24
|
+
self.owner = owner
|
|
25
|
+
self.repo = repo
|
|
26
|
+
self.releaseId = releaseId
|
|
27
|
+
self.token = token
|
|
28
|
+
self.requests = requests
|
|
29
|
+
|
|
30
|
+
def find(self, patterns: list[str]) -> dict[str, Any]:
|
|
31
|
+
assets: dict[str, Any] = {}
|
|
32
|
+
headers = {
|
|
33
|
+
"Authorization": f"Bearer {self.token}",
|
|
34
|
+
"Accept": "application/vnd.github+json",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for page in range(1, MAX_PAGES + 1):
|
|
38
|
+
url = RELEASE_ASSETS_URL.format(owner=self.owner, repo=self.repo,
|
|
39
|
+
id=self.releaseId) + f"?per_page={PAGE_SIZE}&page={page}"
|
|
40
|
+
with self.requests.get(url, headers=headers) as response:
|
|
41
|
+
pageAssets = json.load(response)
|
|
42
|
+
assets.update({asset["name"]: asset for asset in pageAssets
|
|
43
|
+
if not patterns or any(
|
|
44
|
+
fnmatch(asset["name"], pattern) for pattern in patterns
|
|
45
|
+
)})
|
|
46
|
+
if len(pageAssets) < PAGE_SIZE:
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
return assets
|
|
50
|
+
|
|
51
|
+
def awaitAssets(self, names: list[str],
|
|
52
|
+
deadline: float) -> Iterator[tuple[str, dict[str, Any]]]:
|
|
53
|
+
pending = set(names)
|
|
54
|
+
|
|
55
|
+
for retry in range(self.requests.maxRetries + 1):
|
|
56
|
+
assets = self.find(list(pending))
|
|
57
|
+
for name, asset in assets.items():
|
|
58
|
+
if name not in pending:
|
|
59
|
+
continue
|
|
60
|
+
pending.remove(name)
|
|
61
|
+
yield name, asset
|
|
62
|
+
if not pending:
|
|
63
|
+
return
|
|
64
|
+
if retry >= self.requests.maxRetries or time.monotonic() >= deadline:
|
|
65
|
+
break
|
|
66
|
+
delay = min(self.requests.delay + retry * self.requests.increment,
|
|
67
|
+
self.requests.maxDelay)
|
|
68
|
+
time.sleep(delay)
|
|
69
|
+
|
|
70
|
+
raise AssetTimeoutError(list(pending))
|
|
71
|
+
|
|
72
|
+
def deleteAsset(self, assetId: int) -> None:
|
|
73
|
+
url = RELEASE_ASSET_URL.format(owner=self.owner, repo=self.repo, id=assetId)
|
|
74
|
+
headers = {
|
|
75
|
+
"Authorization": f"Bearer {self.token}",
|
|
76
|
+
"Accept": "application/vnd.github+json",
|
|
77
|
+
}
|
|
78
|
+
with self.requests.delete(url, headers=headers):
|
|
79
|
+
pass
|
|
80
|
+
|
ghpy/Releases.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from .Release import Release
|
|
4
|
+
from .ReleaseAssets import ReleaseAssets
|
|
5
|
+
from .RequestFactory import RequestFactory
|
|
6
|
+
from .errors.GraphQLError import GraphQLError
|
|
7
|
+
from .errors.ReleaseNotFoundError import ReleaseNotFoundError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
GET_RELEASE_QUERY = """
|
|
11
|
+
query($owner: String!, $repo: String!, $tag: String!) {
|
|
12
|
+
repository(owner: $owner, name: $repo) {
|
|
13
|
+
isPrivate
|
|
14
|
+
release(tagName: $tag) {
|
|
15
|
+
databaseId
|
|
16
|
+
tagName
|
|
17
|
+
isDraft
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Releases:
|
|
25
|
+
def __init__(self, token: str, requests: RequestFactory):
|
|
26
|
+
self.token = token
|
|
27
|
+
self.requests = requests
|
|
28
|
+
|
|
29
|
+
def getRelease(self, owner: str, repo: str, tag: str) -> Release:
|
|
30
|
+
headers = {
|
|
31
|
+
"Authorization": f"Bearer {self.token}",
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
}
|
|
34
|
+
data = json.dumps({"query": GET_RELEASE_QUERY,
|
|
35
|
+
"variables": {"owner": owner, "repo": repo, "tag": tag}
|
|
36
|
+
}).encode()
|
|
37
|
+
|
|
38
|
+
with self.requests.post("https://api.github.com/graphql", headers=headers,
|
|
39
|
+
data=data) as response:
|
|
40
|
+
body = response.read()
|
|
41
|
+
result = json.loads(body)
|
|
42
|
+
|
|
43
|
+
if "errors" in result:
|
|
44
|
+
raise GraphQLError(result)
|
|
45
|
+
|
|
46
|
+
repository = result["data"]["repository"]
|
|
47
|
+
releaseData = repository["release"]
|
|
48
|
+
if releaseData is None:
|
|
49
|
+
raise ReleaseNotFoundError(tag)
|
|
50
|
+
|
|
51
|
+
releaseData["isPrivate"] = repository["isPrivate"]
|
|
52
|
+
releaseId = releaseData["databaseId"]
|
|
53
|
+
assets = ReleaseAssets(self.requests, self.token, owner, repo, releaseId)
|
|
54
|
+
return Release(owner, repo, releaseData, self.token, self.requests, assets)
|
ghpy/Request.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
from urllib.parse import urljoin
|
|
8
|
+
|
|
9
|
+
from .PooledResponse import PooledResponse
|
|
10
|
+
from .errors.HTTPError import HTTPError
|
|
11
|
+
from .errors.InvalidRedirectError import InvalidRedirectError
|
|
12
|
+
from .errors.TooManyRedirectsError import TooManyRedirectsError
|
|
13
|
+
from .netlib import sameOrigin, stripHeaders
|
|
14
|
+
|
|
15
|
+
from typing import BinaryIO
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from RequestFactory import RequestFactory
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
MAX_CONNECTION_ERRORS = 3
|
|
22
|
+
MAX_DELAY = 300
|
|
23
|
+
MAX_REDIRECTS = 5
|
|
24
|
+
MAX_RETRIES = 8
|
|
25
|
+
RETRY_DELAY = 1
|
|
26
|
+
RETRY_INCREMENT = 1
|
|
27
|
+
|
|
28
|
+
REDIRECT_PRESERVE_METHOD = {307, 308}
|
|
29
|
+
REDIRECT_TO_GET = {301, 302, 303}
|
|
30
|
+
REDIRECT_STATUSES = REDIRECT_PRESERVE_METHOD | REDIRECT_TO_GET
|
|
31
|
+
RETRY_STATUSES = {408, 425, 429, 500, 502, 503, 504}
|
|
32
|
+
|
|
33
|
+
SENSITIVE_HEADERS = {"authorization", "proxy-authorization"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Request:
|
|
37
|
+
def __init__(self, requests: RequestFactory, method: str, url: str,
|
|
38
|
+
headers: dict[str, str], data: bytes | BinaryIO | None, timeout: int = 30,
|
|
39
|
+
maxRetries: int = MAX_RETRIES, delay: int = RETRY_DELAY,
|
|
40
|
+
increment: int = RETRY_INCREMENT, maxDelay: int = MAX_DELAY,
|
|
41
|
+
retryStatuses: set[int] | None = None):
|
|
42
|
+
self.requests = requests
|
|
43
|
+
self.method = method
|
|
44
|
+
self.url = url
|
|
45
|
+
self.headers = headers
|
|
46
|
+
self.data = data
|
|
47
|
+
self.timeout = timeout
|
|
48
|
+
self.maxRetries = maxRetries
|
|
49
|
+
self.delay = delay
|
|
50
|
+
self.increment = increment
|
|
51
|
+
self.maxDelay = maxDelay
|
|
52
|
+
self.retryStatuses = RETRY_STATUSES | (retryStatuses or set())
|
|
53
|
+
|
|
54
|
+
self.retry = 0
|
|
55
|
+
self.connectionErrors = 0
|
|
56
|
+
self.redirects = 0
|
|
57
|
+
self.spent = 0
|
|
58
|
+
|
|
59
|
+
def execute(self) -> PooledResponse:
|
|
60
|
+
while True:
|
|
61
|
+
try:
|
|
62
|
+
connections = self.requests.getConnections(self.url)
|
|
63
|
+
if hasattr(self.data, "seek"):
|
|
64
|
+
self.data.seek(0)
|
|
65
|
+
response = connections.request(self.method, self.url,
|
|
66
|
+
self.headers, self.data)
|
|
67
|
+
self.connectionErrors = 0
|
|
68
|
+
if self._redirect(response):
|
|
69
|
+
continue
|
|
70
|
+
if not self._shouldRetry(response):
|
|
71
|
+
if response.status >= 400:
|
|
72
|
+
raise self._httpError(response)
|
|
73
|
+
return response
|
|
74
|
+
response.close()
|
|
75
|
+
self._wait(response.headers)
|
|
76
|
+
except OSError as error:
|
|
77
|
+
self.connectionErrors += 1
|
|
78
|
+
if not self._couldRetry(error):
|
|
79
|
+
raise
|
|
80
|
+
self._wait()
|
|
81
|
+
|
|
82
|
+
def _redirect(self, response: PooledResponse):
|
|
83
|
+
if response.status not in REDIRECT_STATUSES:
|
|
84
|
+
return False
|
|
85
|
+
response.close()
|
|
86
|
+
|
|
87
|
+
location = response.headers.get("Location")
|
|
88
|
+
if not location:
|
|
89
|
+
raise InvalidRedirectError(self.url, response.status, response.reason)
|
|
90
|
+
if self.redirects >= MAX_REDIRECTS:
|
|
91
|
+
raise TooManyRedirectsError(self.url, response.status, response.reason)
|
|
92
|
+
|
|
93
|
+
url = self.url
|
|
94
|
+
self.url = urljoin(self.url, location)
|
|
95
|
+
if not sameOrigin(url, self.url):
|
|
96
|
+
self.headers = stripHeaders(self.headers, SENSITIVE_HEADERS)
|
|
97
|
+
if response.status in REDIRECT_TO_GET:
|
|
98
|
+
self.method = "GET"
|
|
99
|
+
self.headers.pop("Content-Length", None)
|
|
100
|
+
self.headers.pop("Content-Type", None)
|
|
101
|
+
self.data = None
|
|
102
|
+
self.redirects += 1
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
def _shouldRetry(self, response: PooledResponse):
|
|
106
|
+
return response.status in self.retryStatuses or (
|
|
107
|
+
response.status == 403 and (
|
|
108
|
+
response.headers.get("Retry-After") or
|
|
109
|
+
response.headers.get("X-RateLimit-Remaining") == "0"
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def _couldRetry(self, error: OSError):
|
|
114
|
+
return isinstance(error, (BrokenPipeError, ConnectionRefusedError,
|
|
115
|
+
ConnectionResetError, socket.gaierror)) and \
|
|
116
|
+
self.connectionErrors < MAX_CONNECTION_ERRORS
|
|
117
|
+
|
|
118
|
+
def _wait(self, headers: dict[str, str] | None = None):
|
|
119
|
+
if self.retry >= self.maxRetries:
|
|
120
|
+
raise TimeoutError(f"Timed out after {self.retry} retries")
|
|
121
|
+
|
|
122
|
+
delay = min(self.delay + self.retry * self.increment, self.maxDelay)
|
|
123
|
+
if headers:
|
|
124
|
+
delay = max(delay, int(headers.get("Retry-After", "0")))
|
|
125
|
+
if headers.get("X-RateLimit-Remaining") == "0":
|
|
126
|
+
reset = int(headers.get("X-RateLimit-Reset", "0"))
|
|
127
|
+
delay = max(delay, reset - int(time.time()))
|
|
128
|
+
|
|
129
|
+
if self.spent + delay > self.timeout:
|
|
130
|
+
raise TimeoutError(f"Timed out after {self.retry} retries")
|
|
131
|
+
|
|
132
|
+
self.spent += delay
|
|
133
|
+
self.retry += 1
|
|
134
|
+
time.sleep(delay)
|
|
135
|
+
|
|
136
|
+
def _httpError(self, response: PooledResponse) -> HTTPError:
|
|
137
|
+
try:
|
|
138
|
+
body = response.read()
|
|
139
|
+
return HTTPError(self.url, response.status, response.reason,
|
|
140
|
+
response.headers, body)
|
|
141
|
+
finally:
|
|
142
|
+
response.close()
|
ghpy/RequestFactory.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from threading import Lock
|
|
2
|
+
from typing import BinaryIO
|
|
3
|
+
from urllib.parse import urlsplit
|
|
4
|
+
|
|
5
|
+
from .Connections import Connections
|
|
6
|
+
from .PooledResponse import PooledResponse
|
|
7
|
+
from .Request import MAX_DELAY, MAX_RETRIES, RETRY_DELAY, RETRY_INCREMENT, Request
|
|
8
|
+
|
|
9
|
+
DEFAULT_HEADERS = {"User-Agent": "ghpy"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RequestFactory:
|
|
13
|
+
def __init__(self, timeout: int = 30, maxRetries: int = MAX_RETRIES,
|
|
14
|
+
delay: int = RETRY_DELAY, increment: int = RETRY_INCREMENT,
|
|
15
|
+
maxDelay: int = MAX_DELAY):
|
|
16
|
+
self.timeout = timeout
|
|
17
|
+
self.maxRetries = maxRetries
|
|
18
|
+
self.delay = delay
|
|
19
|
+
self.increment = increment
|
|
20
|
+
self.maxDelay = maxDelay
|
|
21
|
+
self.connections: dict[str, Connections] = {}
|
|
22
|
+
self.connectionsLock = Lock()
|
|
23
|
+
|
|
24
|
+
def get(self, url: str, headers: dict[str, str] | None = None,
|
|
25
|
+
retryStatuses: set[int] | None = None,
|
|
26
|
+
timeout: int | None = None) -> PooledResponse:
|
|
27
|
+
return self._request("GET", url, headers, retryStatuses=retryStatuses,
|
|
28
|
+
timeout=timeout)
|
|
29
|
+
|
|
30
|
+
def post(self, url: str, headers: dict[str, str] | None = None,
|
|
31
|
+
data: bytes | BinaryIO | None = None,
|
|
32
|
+
retryStatuses: set[int] | None = None) -> PooledResponse:
|
|
33
|
+
return self._request("POST", url, headers, data, retryStatuses)
|
|
34
|
+
|
|
35
|
+
def delete(self, url: str, headers: dict[str, str] | None = None,
|
|
36
|
+
retryStatuses: set[int] | None = None) -> PooledResponse:
|
|
37
|
+
return self._request("DELETE", url, headers, retryStatuses=retryStatuses)
|
|
38
|
+
|
|
39
|
+
def _request(self, method: str, url: str, headers: dict[str, str] | None = None,
|
|
40
|
+
data: bytes | BinaryIO | None = None, retryStatuses: set[int] | None = None,
|
|
41
|
+
timeout: int | None = None) -> PooledResponse:
|
|
42
|
+
requestHeaders = {**DEFAULT_HEADERS, **(headers or {})}
|
|
43
|
+
return Request(self, method, url, requestHeaders, data,
|
|
44
|
+
self.timeout if timeout is None else timeout,
|
|
45
|
+
self.maxRetries, self.delay, self.increment, self.maxDelay,
|
|
46
|
+
retryStatuses).execute()
|
|
47
|
+
|
|
48
|
+
def getConnections(self, url: str) -> Connections:
|
|
49
|
+
host = urlsplit(url).netloc
|
|
50
|
+
with self.connectionsLock:
|
|
51
|
+
if host not in self.connections:
|
|
52
|
+
self.connections[host] = Connections(host)
|
|
53
|
+
return self.connections[host]
|
ghpy/converters.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from .GhpyError import GhpyError
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AssetTimeoutError(GhpyError):
|
|
5
|
+
def __init__(self, names: str | list[str], status: int = 0):
|
|
6
|
+
if isinstance(names, str):
|
|
7
|
+
names = [names]
|
|
8
|
+
self.names = names
|
|
9
|
+
message = "Timed out waiting for assets"
|
|
10
|
+
if status:
|
|
11
|
+
message += f" (HTTP {status})"
|
|
12
|
+
super().__init__(f"{message}: {', '.join(names)}")
|
ghpy/errors/GhpyError.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .GhpyError import GhpyError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GraphQLError(GhpyError):
|
|
7
|
+
def __init__(self, response: dict):
|
|
8
|
+
self.response = response
|
|
9
|
+
messages = [error["message"] for error in response["errors"]]
|
|
10
|
+
super().__init__(", ".join(messages))
|
ghpy/errors/HTTPError.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from http.client import HTTPMessage
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class HTTPError(Exception):
|
|
5
|
+
def __init__(self, url: str, status: int, reason: str,
|
|
6
|
+
headers: HTTPMessage, body: bytes):
|
|
7
|
+
self.url = url
|
|
8
|
+
self.status = status
|
|
9
|
+
self.code = status
|
|
10
|
+
self.reason = reason
|
|
11
|
+
self.headers = headers
|
|
12
|
+
self.body = body
|
|
13
|
+
super().__init__(f"HTTP {status} {reason}")
|