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 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,8 @@
1
+ import argparse
2
+
3
+
4
+ def repository(value: str) -> tuple[str, str]:
5
+ parts = value.split("/")
6
+ if len(parts) != 2 or not all(parts):
7
+ raise argparse.ArgumentTypeError("must be in OWNER/REPO format")
8
+ return tuple(parts)
@@ -0,0 +1,7 @@
1
+ from .GhpyError import GhpyError
2
+
3
+
4
+ class AssetNotFoundError(GhpyError):
5
+ def __init__(self, patterns: list[str]):
6
+ self.patterns = patterns
7
+ super().__init__(f"No assets found for: {', '.join(patterns)}")
@@ -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)}")
@@ -0,0 +1,8 @@
1
+ from .GhpyError import GhpyError
2
+
3
+
4
+ class DownloadError(Exception):
5
+ def __init__(self, name: str, status: int):
6
+ self.name = name
7
+ self.status = status
8
+ super().__init__(f"Failed to download {name} (HTTP {status})")
@@ -0,0 +1,2 @@
1
+ class GhpyError(Exception):
2
+ pass
@@ -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))
@@ -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}")
@@ -0,0 +1,6 @@
1
+ from .RedirectError import RedirectError
2
+
3
+
4
+ class InvalidRedirectError(RedirectError):
5
+ def __init__(self, url: str, status: int, reason: str):
6
+ super().__init__(url, status, reason, "Location header not found")
@@ -0,0 +1,5 @@
1
+ from .GhpyError import GhpyError
2
+
3
+
4
+ class OperationError(GhpyError):
5
+ pass
@@ -0,0 +1,9 @@
1
+ from .GhpyError import GhpyError
2
+
3
+
4
+ class RedirectError(GhpyError):
5
+ def __init__(self, url: str, status: int, reason: str, message: str):
6
+ self.url = url
7
+ self.status = status
8
+ self.reason = reason
9
+ super().__init__(message)
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from .GhpyError import GhpyError
4
+
5
+
6
+ class ReleaseNotFoundError(GhpyError):
7
+ def __init__(self, tag: str):
8
+ self.tag = tag
9
+ super().__init__(f"Release {tag} not found")
@@ -0,0 +1,6 @@
1
+ from .RedirectError import RedirectError
2
+
3
+
4
+ class TooManyRedirectsError(RedirectError):
5
+ def __init__(self, url: str, status: int, reason: str):
6
+ super().__init__(url, status, reason, "Too many redirects")