fluidattacks_gitlab_sdk 1.0.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.
- fluidattacks_gitlab_sdk/__init__.py +22 -0
- fluidattacks_gitlab_sdk/_decoders.py +170 -0
- fluidattacks_gitlab_sdk/_gql_client/__init__.py +8 -0
- fluidattacks_gitlab_sdk/_gql_client/_client.py +101 -0
- fluidattacks_gitlab_sdk/_gql_client/_error.py +24 -0
- fluidattacks_gitlab_sdk/_gql_client/_handlers.py +87 -0
- fluidattacks_gitlab_sdk/_handlers.py +40 -0
- fluidattacks_gitlab_sdk/_http_client/__init__.py +28 -0
- fluidattacks_gitlab_sdk/_http_client/_client_1.py +206 -0
- fluidattacks_gitlab_sdk/_http_client/_core.py +152 -0
- fluidattacks_gitlab_sdk/_logger.py +36 -0
- fluidattacks_gitlab_sdk/ids.py +135 -0
- fluidattacks_gitlab_sdk/issues/__init__.py +7 -0
- fluidattacks_gitlab_sdk/issues/_client/__init__.py +28 -0
- fluidattacks_gitlab_sdk/issues/_client/_decode.py +165 -0
- fluidattacks_gitlab_sdk/issues/_client/_get_issue.py +79 -0
- fluidattacks_gitlab_sdk/issues/_client/_most_recent.py +119 -0
- fluidattacks_gitlab_sdk/issues/_client/_updated_by.py +157 -0
- fluidattacks_gitlab_sdk/issues/core.py +99 -0
- fluidattacks_gitlab_sdk/members/__init__.py +25 -0
- fluidattacks_gitlab_sdk/members/_client.py +49 -0
- fluidattacks_gitlab_sdk/members/_decode.py +40 -0
- fluidattacks_gitlab_sdk/members/core.py +23 -0
- fluidattacks_gitlab_sdk/merge_requests/__init__.py +9 -0
- fluidattacks_gitlab_sdk/merge_requests/_client.py +265 -0
- fluidattacks_gitlab_sdk/merge_requests/_decode.py +234 -0
- fluidattacks_gitlab_sdk/merge_requests/core.py +137 -0
- fluidattacks_gitlab_sdk/milestones/__init__.py +4 -0
- fluidattacks_gitlab_sdk/milestones/_client.py +147 -0
- fluidattacks_gitlab_sdk/milestones/_decode.py +78 -0
- fluidattacks_gitlab_sdk/milestones/core.py +40 -0
- fluidattacks_gitlab_sdk/mr_approvals/__init__.py +4 -0
- fluidattacks_gitlab_sdk/mr_approvals/_client.py +69 -0
- fluidattacks_gitlab_sdk/mr_approvals/_decode.py +30 -0
- fluidattacks_gitlab_sdk/mr_approvals/core.py +25 -0
- fluidattacks_gitlab_sdk/py.typed +0 -0
- fluidattacks_gitlab_sdk/users/__init__.py +5 -0
- fluidattacks_gitlab_sdk/users/core.py +26 -0
- fluidattacks_gitlab_sdk/users/decode.py +25 -0
- fluidattacks_gitlab_sdk-1.0.0.dist-info/METADATA +13 -0
- fluidattacks_gitlab_sdk-1.0.0.dist-info/RECORD +42 -0
- fluidattacks_gitlab_sdk-1.0.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from fa_purity import (
|
|
4
|
+
Unsafe,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
from fluidattacks_gitlab_sdk._http_client import Credentials
|
|
8
|
+
|
|
9
|
+
from ._handlers import NotFound
|
|
10
|
+
from ._logger import (
|
|
11
|
+
set_logger,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__version__ = "1.0.0"
|
|
15
|
+
|
|
16
|
+
Unsafe.compute(set_logger(__name__, __version__))
|
|
17
|
+
LOG = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Credentials",
|
|
21
|
+
"NotFound",
|
|
22
|
+
]
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from datetime import date
|
|
3
|
+
from typing import TypeVar
|
|
4
|
+
|
|
5
|
+
from fa_purity import (
|
|
6
|
+
Bool,
|
|
7
|
+
Coproduct,
|
|
8
|
+
CoproductFactory,
|
|
9
|
+
FrozenList,
|
|
10
|
+
Maybe,
|
|
11
|
+
NewFrozenList,
|
|
12
|
+
Result,
|
|
13
|
+
ResultE,
|
|
14
|
+
ResultSmash,
|
|
15
|
+
ResultTransform,
|
|
16
|
+
)
|
|
17
|
+
from fa_purity.json import JsonObj, JsonUnfolder
|
|
18
|
+
from fluidattacks_etl_utils import smash
|
|
19
|
+
from fluidattacks_etl_utils.decode import DecodeUtils
|
|
20
|
+
from fluidattacks_etl_utils.natural import Natural
|
|
21
|
+
|
|
22
|
+
from fluidattacks_gitlab_sdk._handlers import handle_value_error
|
|
23
|
+
from fluidattacks_gitlab_sdk.ids import (
|
|
24
|
+
EpicFullId,
|
|
25
|
+
EpicFullInternalId,
|
|
26
|
+
EpicGlobalId,
|
|
27
|
+
EpicInternalId,
|
|
28
|
+
GroupId,
|
|
29
|
+
IssueFullId,
|
|
30
|
+
IssueFullInternalId,
|
|
31
|
+
IssueGlobalId,
|
|
32
|
+
IssueInternalId,
|
|
33
|
+
MilestoneFullId,
|
|
34
|
+
MilestoneFullInternalId,
|
|
35
|
+
MilestoneGlobalId,
|
|
36
|
+
MilestoneInternalId,
|
|
37
|
+
MrFullId,
|
|
38
|
+
MrFullInternalId,
|
|
39
|
+
MrGlobalId,
|
|
40
|
+
MrInternalId,
|
|
41
|
+
ProjectId,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_T = TypeVar("_T")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def decode_maybe_single(items: NewFrozenList[_T]) -> Maybe[_T]:
|
|
48
|
+
return Bool.from_primitive(len(items.items) <= 1).map(
|
|
49
|
+
lambda _: ResultTransform.get_index(items, 0).map(Maybe.some).value_or(Maybe.empty()),
|
|
50
|
+
lambda _: Maybe.empty(),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _decode_internal_id(
|
|
55
|
+
raw: JsonObj,
|
|
56
|
+
iid_transform: Callable[[Natural], _T],
|
|
57
|
+
) -> ResultE[tuple[ProjectId, _T]]:
|
|
58
|
+
_project = (
|
|
59
|
+
JsonUnfolder.require(raw, "project_id", DecodeUtils.to_int)
|
|
60
|
+
.bind(Natural.from_int)
|
|
61
|
+
.map(ProjectId)
|
|
62
|
+
)
|
|
63
|
+
return smash.smash_result_2(
|
|
64
|
+
_project,
|
|
65
|
+
JsonUnfolder.require(raw, "iid", DecodeUtils.to_int)
|
|
66
|
+
.bind(Natural.from_int)
|
|
67
|
+
.map(iid_transform),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _decode_id(
|
|
72
|
+
raw: JsonObj,
|
|
73
|
+
) -> ResultE[Natural]:
|
|
74
|
+
return JsonUnfolder.require(raw, "id", DecodeUtils.to_int).bind(Natural.from_int)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def decode_milestone_internal_id(raw: JsonObj) -> ResultE[MilestoneFullInternalId]:
|
|
78
|
+
_decode_project = (
|
|
79
|
+
JsonUnfolder.require(raw, "project_id", DecodeUtils.to_int)
|
|
80
|
+
.bind(Natural.from_int)
|
|
81
|
+
.map(ProjectId)
|
|
82
|
+
)
|
|
83
|
+
_decode_group = (
|
|
84
|
+
JsonUnfolder.require(raw, "group_id", DecodeUtils.to_int)
|
|
85
|
+
.bind(Natural.from_int)
|
|
86
|
+
.map(GroupId)
|
|
87
|
+
)
|
|
88
|
+
_decode_iid = (
|
|
89
|
+
JsonUnfolder.require(raw, "iid", DecodeUtils.to_int)
|
|
90
|
+
.bind(Natural.from_int)
|
|
91
|
+
.map(MilestoneInternalId)
|
|
92
|
+
)
|
|
93
|
+
_union: CoproductFactory[ProjectId, GroupId] = CoproductFactory()
|
|
94
|
+
_project_or_group = _decode_project.map(_union.inl).lash(
|
|
95
|
+
lambda _: _decode_group.map(_union.inr),
|
|
96
|
+
)
|
|
97
|
+
return ResultSmash.smash_result_2(
|
|
98
|
+
_project_or_group,
|
|
99
|
+
_decode_iid,
|
|
100
|
+
).map(lambda t: MilestoneFullInternalId(*t))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def decode_milestone_full_id(raw: JsonObj) -> ResultE[MilestoneFullId]:
|
|
104
|
+
return smash.smash_result_2(
|
|
105
|
+
_decode_id(raw).map(MilestoneGlobalId),
|
|
106
|
+
decode_milestone_internal_id(raw),
|
|
107
|
+
).map(lambda t: MilestoneFullId(*t))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def decode_epic_internal_id(raw: JsonObj) -> ResultE[EpicFullInternalId]:
|
|
111
|
+
_decode_group = (
|
|
112
|
+
JsonUnfolder.require(raw, "group_id", DecodeUtils.to_int)
|
|
113
|
+
.bind(Natural.from_int)
|
|
114
|
+
.map(GroupId)
|
|
115
|
+
)
|
|
116
|
+
_decode_iid = (
|
|
117
|
+
JsonUnfolder.require(raw, "iid", DecodeUtils.to_int)
|
|
118
|
+
.bind(Natural.from_int)
|
|
119
|
+
.map(EpicInternalId)
|
|
120
|
+
)
|
|
121
|
+
return ResultSmash.smash_result_2(
|
|
122
|
+
_decode_group,
|
|
123
|
+
_decode_iid,
|
|
124
|
+
).map(lambda t: EpicFullInternalId(*t))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def decode_epic_full_id(raw: JsonObj) -> ResultE[EpicFullId]:
|
|
128
|
+
return smash.smash_result_2(
|
|
129
|
+
_decode_id(raw).map(EpicGlobalId),
|
|
130
|
+
decode_epic_internal_id(raw),
|
|
131
|
+
).map(lambda t: EpicFullId(*t))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def decode_mr_internal_id(raw: JsonObj) -> ResultE[MrFullInternalId]:
|
|
135
|
+
return _decode_internal_id(raw, MrInternalId).map(lambda t: MrFullInternalId(*t))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def decode_mr_full_id(raw: JsonObj) -> ResultE[MrFullId]:
|
|
139
|
+
return smash.smash_result_2(_decode_id(raw).map(MrGlobalId), decode_mr_internal_id(raw)).map(
|
|
140
|
+
lambda t: MrFullId(*t),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def decode_issue_internal_id(raw: JsonObj) -> ResultE[IssueFullInternalId]:
|
|
145
|
+
return _decode_internal_id(raw, IssueInternalId).map(lambda t: IssueFullInternalId(*t))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def decode_issue_full_id(raw: JsonObj) -> ResultE[IssueFullId]:
|
|
149
|
+
return smash.smash_result_2(
|
|
150
|
+
_decode_id(raw).map(IssueGlobalId),
|
|
151
|
+
decode_issue_internal_id(raw),
|
|
152
|
+
).map(lambda t: IssueFullId(*t))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def decode_date(raw: str) -> ResultE[date]:
|
|
156
|
+
return handle_value_error(lambda: date.fromisoformat(raw))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def assert_single(item: Coproduct[JsonObj, FrozenList[JsonObj]]) -> ResultE[JsonObj]:
|
|
160
|
+
return item.map(
|
|
161
|
+
Result.success,
|
|
162
|
+
lambda _: Result.failure(ValueError("Expected a json not a list")),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def assert_multiple(item: Coproduct[JsonObj, FrozenList[JsonObj]]) -> ResultE[FrozenList[JsonObj]]:
|
|
167
|
+
return item.map(
|
|
168
|
+
lambda _: Result.failure(ValueError("Expected a json list not a single json")),
|
|
169
|
+
Result.success,
|
|
170
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import (
|
|
2
|
+
annotations,
|
|
3
|
+
)
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from dataclasses import (
|
|
7
|
+
dataclass,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from fa_purity import (
|
|
11
|
+
Cmd,
|
|
12
|
+
FrozenDict,
|
|
13
|
+
Result,
|
|
14
|
+
ResultE,
|
|
15
|
+
)
|
|
16
|
+
from fa_purity.json import (
|
|
17
|
+
JsonObj,
|
|
18
|
+
JsonValueFactory,
|
|
19
|
+
Unfolder,
|
|
20
|
+
)
|
|
21
|
+
from fluidattacks_etl_utils.bug import (
|
|
22
|
+
Bug,
|
|
23
|
+
)
|
|
24
|
+
from fluidattacks_etl_utils.retry import (
|
|
25
|
+
cmd_if_fail,
|
|
26
|
+
retry_cmd,
|
|
27
|
+
sleep_cmd,
|
|
28
|
+
)
|
|
29
|
+
from fluidattacks_etl_utils.typing import (
|
|
30
|
+
Dict,
|
|
31
|
+
TypeVar,
|
|
32
|
+
)
|
|
33
|
+
from gql import (
|
|
34
|
+
Client,
|
|
35
|
+
gql,
|
|
36
|
+
)
|
|
37
|
+
from gql.transport.requests import (
|
|
38
|
+
RequestsHTTPTransport,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
from . import _handlers
|
|
42
|
+
from ._error import (
|
|
43
|
+
ApiError,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
API_ENDPOINT = "https://gitlab.com/api/graphql"
|
|
47
|
+
_T = TypeVar("_T")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def error_handler(cmd: Cmd[_T]) -> Cmd[ResultE[_T]]:
|
|
51
|
+
return _handlers.too_many_requests_handler(
|
|
52
|
+
_handlers.server_error_handler(_handlers.connection_error_handler(cmd)),
|
|
53
|
+
).map(lambda a: a.bind(lambda b: b.bind(lambda c: c)))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class _GraphQlAsmClient:
|
|
58
|
+
client: Client
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class GraphQlGitlabClient:
|
|
63
|
+
_inner: _GraphQlAsmClient
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def new(token: str) -> Cmd[GraphQlGitlabClient]:
|
|
67
|
+
def _new() -> GraphQlGitlabClient:
|
|
68
|
+
headers: Dict[str, str] = {"Authorization": f"Bearer {token}"}
|
|
69
|
+
transport = RequestsHTTPTransport(API_ENDPOINT, headers)
|
|
70
|
+
client = Client(transport=transport, fetch_schema_from_transport=False)
|
|
71
|
+
return GraphQlGitlabClient(_GraphQlAsmClient(client))
|
|
72
|
+
|
|
73
|
+
return Cmd.wrap_impure(_new)
|
|
74
|
+
|
|
75
|
+
def _get(self, query: str, values: FrozenDict[str, str]) -> Cmd[JsonObj]:
|
|
76
|
+
def _action() -> JsonObj:
|
|
77
|
+
return Bug.assume_success(
|
|
78
|
+
"gql_decode_get_response",
|
|
79
|
+
inspect.currentframe(),
|
|
80
|
+
(query, str(values)),
|
|
81
|
+
JsonValueFactory.from_any(
|
|
82
|
+
self._inner.client.execute(gql(query), dict(values)), # type: ignore[misc]
|
|
83
|
+
).bind(Unfolder.to_json),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return Cmd.wrap_impure(_action)
|
|
87
|
+
|
|
88
|
+
def get(self, query: str, values: FrozenDict[str, str]) -> Cmd[Result[JsonObj, ApiError]]:
|
|
89
|
+
result = retry_cmd(
|
|
90
|
+
error_handler(self._get(query, values)),
|
|
91
|
+
lambda i, r: cmd_if_fail(r, sleep_cmd(i**2)),
|
|
92
|
+
10,
|
|
93
|
+
).map(
|
|
94
|
+
lambda r: Bug.assume_success(
|
|
95
|
+
"gql_get_response",
|
|
96
|
+
inspect.currentframe(),
|
|
97
|
+
(query, str(values)),
|
|
98
|
+
r,
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
return _handlers.api_error_handler(result)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from dataclasses import (
|
|
2
|
+
dataclass,
|
|
3
|
+
)
|
|
4
|
+
|
|
5
|
+
from fa_purity import (
|
|
6
|
+
FrozenList,
|
|
7
|
+
Result,
|
|
8
|
+
)
|
|
9
|
+
from fa_purity.json import (
|
|
10
|
+
JsonValue,
|
|
11
|
+
)
|
|
12
|
+
from fluidattacks_etl_utils.typing import (
|
|
13
|
+
TypeVar,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_T = TypeVar("_T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ApiError(Exception):
|
|
21
|
+
errors: FrozenList[JsonValue]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
ApiResult = Result[_T, ApiError] # type: ignore[misc]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
|
|
3
|
+
from fa_purity import (
|
|
4
|
+
Cmd,
|
|
5
|
+
CmdUnwrapper,
|
|
6
|
+
Result,
|
|
7
|
+
ResultE,
|
|
8
|
+
ResultFactory,
|
|
9
|
+
)
|
|
10
|
+
from fa_purity.json import (
|
|
11
|
+
JsonValueFactory,
|
|
12
|
+
Unfolder,
|
|
13
|
+
)
|
|
14
|
+
from fluidattacks_etl_utils.bug import (
|
|
15
|
+
Bug,
|
|
16
|
+
)
|
|
17
|
+
from fluidattacks_etl_utils.typing import (
|
|
18
|
+
Callable,
|
|
19
|
+
TypeVar,
|
|
20
|
+
)
|
|
21
|
+
from gql.transport.exceptions import (
|
|
22
|
+
TransportQueryError,
|
|
23
|
+
TransportServerError,
|
|
24
|
+
)
|
|
25
|
+
from requests.exceptions import (
|
|
26
|
+
ConnectionError as RequestsConnectionError,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from ._error import (
|
|
30
|
+
ApiError,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_T = TypeVar("_T")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def http_status_handler(is_handled: Callable[[int], bool], cmd: Cmd[_T]) -> Cmd[ResultE[_T]]:
|
|
37
|
+
factory: ResultFactory[_T, Exception] = ResultFactory()
|
|
38
|
+
|
|
39
|
+
def _action(unwrapper: CmdUnwrapper) -> ResultE[_T]:
|
|
40
|
+
try:
|
|
41
|
+
return factory.success(unwrapper.act(cmd))
|
|
42
|
+
except TransportServerError as err:
|
|
43
|
+
if err.code is not None and is_handled(err.code):
|
|
44
|
+
return factory.failure(err)
|
|
45
|
+
raise
|
|
46
|
+
|
|
47
|
+
return Cmd.new_cmd(_action)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def api_error_handler(cmd: Cmd[_T]) -> Cmd[Result[_T, ApiError]]:
|
|
51
|
+
factory: ResultFactory[_T, ApiError] = ResultFactory()
|
|
52
|
+
|
|
53
|
+
def _action(unwrapper: CmdUnwrapper) -> Result[_T, ApiError]:
|
|
54
|
+
try:
|
|
55
|
+
return factory.success(unwrapper.act(cmd))
|
|
56
|
+
except TransportQueryError as err: # type: ignore[misc]
|
|
57
|
+
_errors = JsonValueFactory.from_any(err.errors).bind( # type: ignore[misc]
|
|
58
|
+
lambda x: Unfolder.to_list(x),
|
|
59
|
+
)
|
|
60
|
+
errors = Bug.assume_success(
|
|
61
|
+
"decode_errors",
|
|
62
|
+
inspect.currentframe(),
|
|
63
|
+
(str(_errors),),
|
|
64
|
+
_errors,
|
|
65
|
+
)
|
|
66
|
+
return factory.failure(ApiError(errors))
|
|
67
|
+
|
|
68
|
+
return Cmd.new_cmd(_action)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def too_many_requests_handler(cmd: Cmd[_T]) -> Cmd[ResultE[_T]]:
|
|
72
|
+
too_many_requests_num = 429
|
|
73
|
+
return http_status_handler(lambda c: c == too_many_requests_num, cmd)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def server_error_handler(cmd: Cmd[_T]) -> Cmd[ResultE[_T]]:
|
|
77
|
+
return http_status_handler(lambda c: c in range(500, 600), cmd)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def connection_error_handler(cmd: Cmd[_T]) -> Cmd[ResultE[_T]]:
|
|
81
|
+
def _action(unwrapper: CmdUnwrapper) -> ResultE[_T]:
|
|
82
|
+
try:
|
|
83
|
+
return Result.success(unwrapper.act(cmd))
|
|
84
|
+
except RequestsConnectionError as err:
|
|
85
|
+
return Result.failure(Exception(err))
|
|
86
|
+
|
|
87
|
+
return Cmd.new_cmd(_action)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import TypeVar
|
|
4
|
+
|
|
5
|
+
from fa_purity import Coproduct, Result, ResultE, cast_exception
|
|
6
|
+
|
|
7
|
+
from fluidattacks_gitlab_sdk._http_client import HTTPError, UnhandledErrors
|
|
8
|
+
|
|
9
|
+
_T = TypeVar("_T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class NotFound(Exception):
|
|
14
|
+
parent: Exception | None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def handle_value_error(value: Callable[[], _T]) -> ResultE[_T]:
|
|
18
|
+
try:
|
|
19
|
+
return Result.success(value())
|
|
20
|
+
except ValueError as error:
|
|
21
|
+
return Result.failure(error)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _handle_not_found(error: HTTPError) -> Coproduct[NotFound, Exception]:
|
|
25
|
+
not_found_code = 404
|
|
26
|
+
err_code: int = error.raw.response.status_code # type: ignore[misc]
|
|
27
|
+
|
|
28
|
+
if err_code == not_found_code:
|
|
29
|
+
return Coproduct.inl(NotFound(error.raw))
|
|
30
|
+
return Coproduct.inr(error.raw)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def handle_not_found(error: UnhandledErrors) -> Coproduct[NotFound, Exception]:
|
|
34
|
+
return error.error.map(
|
|
35
|
+
lambda e: Coproduct.inr(cast_exception(e.raw)),
|
|
36
|
+
lambda c: c.map(
|
|
37
|
+
_handle_not_found,
|
|
38
|
+
lambda e: Coproduct.inr(cast_exception(e.raw)),
|
|
39
|
+
),
|
|
40
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from fluidattacks_gitlab_sdk._http_client._client_1 import Client1
|
|
4
|
+
from fluidattacks_gitlab_sdk._http_client._core import (
|
|
5
|
+
Credentials,
|
|
6
|
+
HTTPError,
|
|
7
|
+
HttpJsonClient,
|
|
8
|
+
Page,
|
|
9
|
+
RelativeEndpoint,
|
|
10
|
+
UnhandledErrors,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ClientFactory:
|
|
16
|
+
@staticmethod
|
|
17
|
+
def new(creds: Credentials) -> HttpJsonClient:
|
|
18
|
+
return Client1.new(creds).client
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Credentials",
|
|
23
|
+
"HTTPError",
|
|
24
|
+
"HttpJsonClient",
|
|
25
|
+
"Page",
|
|
26
|
+
"RelativeEndpoint",
|
|
27
|
+
"UnhandledErrors",
|
|
28
|
+
]
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from __future__ import (
|
|
2
|
+
annotations,
|
|
3
|
+
)
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import (
|
|
7
|
+
dataclass,
|
|
8
|
+
)
|
|
9
|
+
from typing import (
|
|
10
|
+
TypeVar,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from fa_purity import (
|
|
14
|
+
Cmd,
|
|
15
|
+
Coproduct,
|
|
16
|
+
FrozenDict,
|
|
17
|
+
FrozenList,
|
|
18
|
+
Result,
|
|
19
|
+
UnitType,
|
|
20
|
+
unit,
|
|
21
|
+
)
|
|
22
|
+
from fa_purity.json import (
|
|
23
|
+
JsonObj,
|
|
24
|
+
JsonPrimitiveFactory,
|
|
25
|
+
JsonUnfolder,
|
|
26
|
+
JsonValue,
|
|
27
|
+
)
|
|
28
|
+
from fluidattacks_etl_utils.smash import bind_chain
|
|
29
|
+
from pure_requests import (
|
|
30
|
+
response,
|
|
31
|
+
)
|
|
32
|
+
from pure_requests import (
|
|
33
|
+
retry as _retry,
|
|
34
|
+
)
|
|
35
|
+
from pure_requests.basic import (
|
|
36
|
+
Data,
|
|
37
|
+
Endpoint,
|
|
38
|
+
HttpClientFactory,
|
|
39
|
+
Params,
|
|
40
|
+
)
|
|
41
|
+
from pure_requests.retry import (
|
|
42
|
+
HandledError,
|
|
43
|
+
MaxRetriesReached,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
from ._core import (
|
|
47
|
+
Credentials,
|
|
48
|
+
HandledErrors,
|
|
49
|
+
HTTPError,
|
|
50
|
+
HttpJsonClient,
|
|
51
|
+
JSONDecodeError,
|
|
52
|
+
RelativeEndpoint,
|
|
53
|
+
RequestException,
|
|
54
|
+
UnhandledErrors,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
LOG = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_S = TypeVar("_S")
|
|
61
|
+
_F = TypeVar("_F")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _retry_cmd(retry: int, item: Result[_S, _F]) -> Cmd[Result[_S, _F]]:
|
|
65
|
+
log = Cmd.wrap_impure(lambda: LOG.info("retry #%2s waiting...", retry))
|
|
66
|
+
return _retry.cmd_if_fail(item, log + _retry.sleep_cmd(retry**2))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _http_error_handler(
|
|
70
|
+
error: HTTPError,
|
|
71
|
+
) -> HandledError[HandledErrors, UnhandledErrors]:
|
|
72
|
+
err_code: int = error.raw.response.status_code # type: ignore[misc]
|
|
73
|
+
handled = (
|
|
74
|
+
409,
|
|
75
|
+
429,
|
|
76
|
+
)
|
|
77
|
+
if err_code in range(500, 600) or err_code in handled:
|
|
78
|
+
return HandledError.handled(HandledErrors(Coproduct.inl(error)))
|
|
79
|
+
return HandledError.unhandled(UnhandledErrors(Coproduct.inr(Coproduct.inl(error))))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _handled_request_exception(
|
|
83
|
+
error: RequestException,
|
|
84
|
+
) -> HandledError[HandledErrors, UnhandledErrors]:
|
|
85
|
+
return (
|
|
86
|
+
error.to_chunk_error()
|
|
87
|
+
.map(lambda e: HandledError.handled(HandledErrors(Coproduct.inr(Coproduct.inl(e)))))
|
|
88
|
+
.lash(
|
|
89
|
+
lambda _: error.to_connection_error().map(
|
|
90
|
+
lambda e: HandledError.handled(HandledErrors(Coproduct.inr(Coproduct.inr(e)))),
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
.value_or(HandledError.unhandled(UnhandledErrors(Coproduct.inr(Coproduct.inr(error)))))
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _handled_errors(
|
|
98
|
+
error: Coproduct[JSONDecodeError, Coproduct[HTTPError, RequestException]],
|
|
99
|
+
) -> HandledError[HandledErrors, UnhandledErrors]:
|
|
100
|
+
"""Classify errors."""
|
|
101
|
+
return error.map(
|
|
102
|
+
lambda _: HandledError.unhandled(UnhandledErrors(error)),
|
|
103
|
+
lambda c: c.map(
|
|
104
|
+
_http_error_handler,
|
|
105
|
+
_handled_request_exception,
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _adjust_unhandled(
|
|
111
|
+
error: UnhandledErrors | MaxRetriesReached,
|
|
112
|
+
) -> Coproduct[UnhandledErrors, MaxRetriesReached]:
|
|
113
|
+
return Coproduct.inr(error) if isinstance(error, MaxRetriesReached) else Coproduct.inl(error)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class Client1:
|
|
118
|
+
_creds: Credentials
|
|
119
|
+
_max_retries: int
|
|
120
|
+
|
|
121
|
+
def _full_endpoint(self, endpoint: RelativeEndpoint) -> Endpoint:
|
|
122
|
+
return Endpoint("/".join(("https://gitlab.com/api/v4", *endpoint.paths)))
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def new(creds: Credentials) -> Client1:
|
|
126
|
+
return Client1(
|
|
127
|
+
creds,
|
|
128
|
+
150,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def _headers(self) -> JsonObj:
|
|
133
|
+
return FrozenDict(
|
|
134
|
+
{
|
|
135
|
+
"Private-Token": JsonValue.from_primitive(
|
|
136
|
+
JsonPrimitiveFactory.from_raw(self._creds.api_key),
|
|
137
|
+
),
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def get(
|
|
142
|
+
self,
|
|
143
|
+
endpoint: RelativeEndpoint,
|
|
144
|
+
params: JsonObj,
|
|
145
|
+
) -> Cmd[
|
|
146
|
+
Result[
|
|
147
|
+
Coproduct[JsonObj, FrozenList[JsonObj]],
|
|
148
|
+
Coproduct[UnhandledErrors, MaxRetriesReached],
|
|
149
|
+
]
|
|
150
|
+
]:
|
|
151
|
+
_full = self._full_endpoint(endpoint)
|
|
152
|
+
log = Cmd.wrap_impure(
|
|
153
|
+
lambda: LOG.debug(
|
|
154
|
+
"[API] get: %s\nparams = %s",
|
|
155
|
+
_full,
|
|
156
|
+
JsonUnfolder.dumps(params),
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
client = HttpClientFactory.new_client(None, self._headers, False)
|
|
160
|
+
handled = log + client.get(_full, Params(params)).map(
|
|
161
|
+
lambda r: r.alt(RequestException),
|
|
162
|
+
).map(
|
|
163
|
+
lambda r: bind_chain(r, lambda i: response.handle_status(i).alt(HTTPError)),
|
|
164
|
+
).map(
|
|
165
|
+
lambda r: bind_chain(r, lambda i: response.json_decode(i).alt(JSONDecodeError)).alt(
|
|
166
|
+
_handled_errors,
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
return _retry.retry_cmd(
|
|
170
|
+
handled,
|
|
171
|
+
_retry_cmd,
|
|
172
|
+
self._max_retries,
|
|
173
|
+
).map(lambda r: r.alt(_adjust_unhandled))
|
|
174
|
+
|
|
175
|
+
def post(
|
|
176
|
+
self,
|
|
177
|
+
endpoint: RelativeEndpoint,
|
|
178
|
+
) -> Cmd[Result[UnitType, Coproduct[UnhandledErrors, MaxRetriesReached]]]:
|
|
179
|
+
_full = self._full_endpoint(endpoint)
|
|
180
|
+
log = Cmd.wrap_impure(lambda: LOG.info("API call (post): %s", _full))
|
|
181
|
+
client = HttpClientFactory.new_client(None, self._headers, False)
|
|
182
|
+
handled = log + client.post(
|
|
183
|
+
self._full_endpoint(endpoint),
|
|
184
|
+
Params(FrozenDict({})),
|
|
185
|
+
Data(FrozenDict({})),
|
|
186
|
+
).map(lambda r: r.alt(RequestException)).map(
|
|
187
|
+
lambda r: bind_chain(r, lambda i: response.handle_status(i).alt(HTTPError)).alt(
|
|
188
|
+
lambda e: _handled_errors(Coproduct.inr(e)),
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
return _retry.retry_cmd(
|
|
192
|
+
handled,
|
|
193
|
+
_retry_cmd,
|
|
194
|
+
self._max_retries,
|
|
195
|
+
).map(
|
|
196
|
+
lambda r: r.map(
|
|
197
|
+
lambda _: unit,
|
|
198
|
+
).alt(_adjust_unhandled),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def client(self) -> HttpJsonClient:
|
|
203
|
+
return HttpJsonClient(
|
|
204
|
+
self.get,
|
|
205
|
+
self.post,
|
|
206
|
+
)
|