pjdev-gitlab 5.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.
- pjdev_gitlab/.agents/skills/pjdev_gitlab_issues/SKILL.md +150 -0
- pjdev_gitlab/.agents/skills/pjdev_gitlab_merge_requests/SKILL.md +101 -0
- pjdev_gitlab/.agents/skills/pjdev_gitlab_packages/SKILL.md +83 -0
- pjdev_gitlab/.agents/skills/pjdev_gitlab_repo_files/SKILL.md +96 -0
- pjdev_gitlab/__about__.py +4 -0
- pjdev_gitlab/__init__.py +17 -0
- pjdev_gitlab/api_utilities.py +201 -0
- pjdev_gitlab/config_service.py +49 -0
- pjdev_gitlab/issues_service.py +430 -0
- pjdev_gitlab/merge_requests_service.py +244 -0
- pjdev_gitlab/models.py +172 -0
- pjdev_gitlab/packages_service.py +134 -0
- pjdev_gitlab/py.typed +0 -0
- pjdev_gitlab/repo_files_service.py +145 -0
- pjdev_gitlab-5.0.0.dist-info/METADATA +129 -0
- pjdev_gitlab-5.0.0.dist-info/RECORD +18 -0
- pjdev_gitlab-5.0.0.dist-info/WHEEL +4 -0
- pjdev_gitlab-5.0.0.dist-info/licenses/LICENSE.txt +9 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
from typing import Any, Dict, List, Optional
|
|
2
|
+
|
|
3
|
+
from httpx import AsyncClient
|
|
4
|
+
|
|
5
|
+
from pjdev_gitlab.api_utilities import (
|
|
6
|
+
async_retry_http,
|
|
7
|
+
encode_path_segment,
|
|
8
|
+
http_client,
|
|
9
|
+
paginate,
|
|
10
|
+
)
|
|
11
|
+
from pjdev_gitlab.models import (
|
|
12
|
+
Discussion,
|
|
13
|
+
MergeRequest,
|
|
14
|
+
MergeRequestState,
|
|
15
|
+
Note,
|
|
16
|
+
ProjectId,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_IGNORE_4XX = [400, 401, 403, 404]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _mr_base(project_id: ProjectId, mr_iid: Optional[int] = None) -> str:
|
|
23
|
+
base = f"/projects/{encode_path_segment(project_id)}/merge_requests"
|
|
24
|
+
if mr_iid is None:
|
|
25
|
+
return base
|
|
26
|
+
return f"{base}/{mr_iid}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
|
|
30
|
+
async def list_merge_requests(
|
|
31
|
+
project_id: ProjectId,
|
|
32
|
+
*,
|
|
33
|
+
state: Optional[MergeRequestState] = None,
|
|
34
|
+
target_branch: Optional[str] = None,
|
|
35
|
+
source_branch: Optional[str] = None,
|
|
36
|
+
author_id: Optional[int] = None,
|
|
37
|
+
reviewer_id: Optional[int] = None,
|
|
38
|
+
labels: Optional[List[str]] = None,
|
|
39
|
+
search: Optional[str] = None,
|
|
40
|
+
created_after: Optional[str] = None,
|
|
41
|
+
created_before: Optional[str] = None,
|
|
42
|
+
extra_params: Optional[Dict[str, Any]] = None,
|
|
43
|
+
page_size: int = 100,
|
|
44
|
+
client: Optional[AsyncClient] = None,
|
|
45
|
+
) -> List[MergeRequest]:
|
|
46
|
+
params: Dict[str, Any] = {}
|
|
47
|
+
if state is not None:
|
|
48
|
+
params["state"] = state.value
|
|
49
|
+
if target_branch is not None:
|
|
50
|
+
params["target_branch"] = target_branch
|
|
51
|
+
if source_branch is not None:
|
|
52
|
+
params["source_branch"] = source_branch
|
|
53
|
+
if author_id is not None:
|
|
54
|
+
params["author_id"] = author_id
|
|
55
|
+
if reviewer_id is not None:
|
|
56
|
+
params["reviewer_id"] = reviewer_id
|
|
57
|
+
if labels:
|
|
58
|
+
params["labels"] = ",".join(labels)
|
|
59
|
+
if search is not None:
|
|
60
|
+
params["search"] = search
|
|
61
|
+
if created_after is not None:
|
|
62
|
+
params["created_after"] = created_after
|
|
63
|
+
if created_before is not None:
|
|
64
|
+
params["created_before"] = created_before
|
|
65
|
+
if extra_params:
|
|
66
|
+
params.update(extra_params)
|
|
67
|
+
|
|
68
|
+
url = _mr_base(project_id)
|
|
69
|
+
|
|
70
|
+
async def _exec(_client: AsyncClient) -> List[MergeRequest]:
|
|
71
|
+
rows = await paginate(_client, url, params=params, page_size=page_size)
|
|
72
|
+
return [MergeRequest.model_validate(row) for row in rows]
|
|
73
|
+
|
|
74
|
+
if client is None:
|
|
75
|
+
async with http_client() as _client:
|
|
76
|
+
return await _exec(_client)
|
|
77
|
+
return await _exec(client)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
|
|
81
|
+
async def get_merge_request(
|
|
82
|
+
project_id: ProjectId,
|
|
83
|
+
mr_iid: int,
|
|
84
|
+
client: Optional[AsyncClient] = None,
|
|
85
|
+
) -> MergeRequest:
|
|
86
|
+
url = _mr_base(project_id, mr_iid)
|
|
87
|
+
|
|
88
|
+
async def _exec(_client: AsyncClient) -> MergeRequest:
|
|
89
|
+
r = await _client.get(url)
|
|
90
|
+
r.raise_for_status()
|
|
91
|
+
return MergeRequest.model_validate(r.json())
|
|
92
|
+
|
|
93
|
+
if client is None:
|
|
94
|
+
async with http_client() as _client:
|
|
95
|
+
return await _exec(_client)
|
|
96
|
+
return await _exec(client)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
|
|
100
|
+
async def get_merge_request_changes(
|
|
101
|
+
project_id: ProjectId,
|
|
102
|
+
mr_iid: int,
|
|
103
|
+
client: Optional[AsyncClient] = None,
|
|
104
|
+
) -> Dict[str, Any]:
|
|
105
|
+
"""Fetch the raw diff/changes payload for an MR (used for review automation)."""
|
|
106
|
+
url = f"{_mr_base(project_id, mr_iid)}/changes"
|
|
107
|
+
|
|
108
|
+
async def _exec(_client: AsyncClient) -> Dict[str, Any]:
|
|
109
|
+
r = await _client.get(url)
|
|
110
|
+
r.raise_for_status()
|
|
111
|
+
return r.json()
|
|
112
|
+
|
|
113
|
+
if client is None:
|
|
114
|
+
async with http_client() as _client:
|
|
115
|
+
return await _exec(_client)
|
|
116
|
+
return await _exec(client)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
|
|
120
|
+
async def comment_on_merge_request(
|
|
121
|
+
project_id: ProjectId,
|
|
122
|
+
mr_iid: int,
|
|
123
|
+
body: str,
|
|
124
|
+
client: Optional[AsyncClient] = None,
|
|
125
|
+
) -> Note:
|
|
126
|
+
url = f"{_mr_base(project_id, mr_iid)}/notes"
|
|
127
|
+
|
|
128
|
+
async def _exec(_client: AsyncClient) -> Note:
|
|
129
|
+
r = await _client.post(url, json={"body": body})
|
|
130
|
+
r.raise_for_status()
|
|
131
|
+
return Note.model_validate(r.json())
|
|
132
|
+
|
|
133
|
+
if client is None:
|
|
134
|
+
async with http_client() as _client:
|
|
135
|
+
return await _exec(_client)
|
|
136
|
+
return await _exec(client)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
|
|
140
|
+
async def comment_on_merge_request_diff(
|
|
141
|
+
project_id: ProjectId,
|
|
142
|
+
mr_iid: int,
|
|
143
|
+
body: str,
|
|
144
|
+
*,
|
|
145
|
+
base_sha: str,
|
|
146
|
+
start_sha: str,
|
|
147
|
+
head_sha: str,
|
|
148
|
+
new_path: str,
|
|
149
|
+
new_line: int,
|
|
150
|
+
old_path: Optional[str] = None,
|
|
151
|
+
old_line: Optional[int] = None,
|
|
152
|
+
client: Optional[AsyncClient] = None,
|
|
153
|
+
) -> Discussion:
|
|
154
|
+
"""Add a threaded inline diff comment.
|
|
155
|
+
|
|
156
|
+
Position payload follows the GitLab discussions API:
|
|
157
|
+
https://docs.gitlab.com/api/discussions/#create-a-new-thread-on-the-merge-request-diff
|
|
158
|
+
"""
|
|
159
|
+
url = f"{_mr_base(project_id, mr_iid)}/discussions"
|
|
160
|
+
position: Dict[str, Any] = {
|
|
161
|
+
"base_sha": base_sha,
|
|
162
|
+
"start_sha": start_sha,
|
|
163
|
+
"head_sha": head_sha,
|
|
164
|
+
"position_type": "text",
|
|
165
|
+
"new_path": new_path,
|
|
166
|
+
"new_line": new_line,
|
|
167
|
+
}
|
|
168
|
+
if old_path is not None:
|
|
169
|
+
position["old_path"] = old_path
|
|
170
|
+
if old_line is not None:
|
|
171
|
+
position["old_line"] = old_line
|
|
172
|
+
|
|
173
|
+
async def _exec(_client: AsyncClient) -> Discussion:
|
|
174
|
+
r = await _client.post(url, json={"body": body, "position": position})
|
|
175
|
+
r.raise_for_status()
|
|
176
|
+
return Discussion.model_validate(r.json())
|
|
177
|
+
|
|
178
|
+
if client is None:
|
|
179
|
+
async with http_client() as _client:
|
|
180
|
+
return await _exec(_client)
|
|
181
|
+
return await _exec(client)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
|
|
185
|
+
async def list_merge_request_notes(
|
|
186
|
+
project_id: ProjectId,
|
|
187
|
+
mr_iid: int,
|
|
188
|
+
*,
|
|
189
|
+
page_size: int = 100,
|
|
190
|
+
client: Optional[AsyncClient] = None,
|
|
191
|
+
) -> List[Note]:
|
|
192
|
+
url = f"{_mr_base(project_id, mr_iid)}/notes"
|
|
193
|
+
|
|
194
|
+
async def _exec(_client: AsyncClient) -> List[Note]:
|
|
195
|
+
rows = await paginate(_client, url, params={}, page_size=page_size)
|
|
196
|
+
return [Note.model_validate(row) for row in rows]
|
|
197
|
+
|
|
198
|
+
if client is None:
|
|
199
|
+
async with http_client() as _client:
|
|
200
|
+
return await _exec(_client)
|
|
201
|
+
return await _exec(client)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
|
|
205
|
+
async def approve_merge_request(
|
|
206
|
+
project_id: ProjectId,
|
|
207
|
+
mr_iid: int,
|
|
208
|
+
*,
|
|
209
|
+
sha: Optional[str] = None,
|
|
210
|
+
client: Optional[AsyncClient] = None,
|
|
211
|
+
) -> Dict[str, Any]:
|
|
212
|
+
url = f"{_mr_base(project_id, mr_iid)}/approve"
|
|
213
|
+
payload: Dict[str, Any] = {}
|
|
214
|
+
if sha is not None:
|
|
215
|
+
payload["sha"] = sha
|
|
216
|
+
|
|
217
|
+
async def _exec(_client: AsyncClient) -> Dict[str, Any]:
|
|
218
|
+
r = await _client.post(url, json=payload or None)
|
|
219
|
+
r.raise_for_status()
|
|
220
|
+
return r.json()
|
|
221
|
+
|
|
222
|
+
if client is None:
|
|
223
|
+
async with http_client() as _client:
|
|
224
|
+
return await _exec(_client)
|
|
225
|
+
return await _exec(client)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
|
|
229
|
+
async def unapprove_merge_request(
|
|
230
|
+
project_id: ProjectId,
|
|
231
|
+
mr_iid: int,
|
|
232
|
+
client: Optional[AsyncClient] = None,
|
|
233
|
+
) -> None:
|
|
234
|
+
url = f"{_mr_base(project_id, mr_iid)}/unapprove"
|
|
235
|
+
|
|
236
|
+
async def _exec(_client: AsyncClient) -> None:
|
|
237
|
+
r = await _client.post(url)
|
|
238
|
+
r.raise_for_status()
|
|
239
|
+
|
|
240
|
+
if client is None:
|
|
241
|
+
async with http_client() as _client:
|
|
242
|
+
await _exec(_client)
|
|
243
|
+
return
|
|
244
|
+
await _exec(client)
|
pjdev_gitlab/models.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, List, Optional, Union
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
ProjectId = Union[int, str]
|
|
11
|
+
GroupId = Union[int, str]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class IssueState(enum.StrEnum):
|
|
15
|
+
opened = "opened"
|
|
16
|
+
closed = "closed"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StateEvent(enum.StrEnum):
|
|
20
|
+
close = "close"
|
|
21
|
+
reopen = "reopen"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class IssueType(enum.StrEnum):
|
|
25
|
+
issue = "issue"
|
|
26
|
+
incident = "incident"
|
|
27
|
+
test_case = "test_case"
|
|
28
|
+
task = "task"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MergeRequestState(enum.StrEnum):
|
|
32
|
+
opened = "opened"
|
|
33
|
+
closed = "closed"
|
|
34
|
+
merged = "merged"
|
|
35
|
+
locked = "locked"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Config(BaseSettings):
|
|
39
|
+
gitlab_url: str = "https://gitlab.com"
|
|
40
|
+
token: Optional[str] = None
|
|
41
|
+
default_project_id: Optional[ProjectId] = None
|
|
42
|
+
output_path: Optional[Path] = None
|
|
43
|
+
http_retry_max_count: int = 5
|
|
44
|
+
http_retry_delay_seconds: int = 2
|
|
45
|
+
request_timeout_seconds: Optional[float] = None
|
|
46
|
+
|
|
47
|
+
model_config = SettingsConfigDict(
|
|
48
|
+
env_prefix="GL_",
|
|
49
|
+
case_sensitive=False,
|
|
50
|
+
extra="ignore",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def api_base_url(self) -> str:
|
|
55
|
+
return f"{self.gitlab_url.rstrip('/')}/api/v4"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class GitlabBase(BaseModel):
|
|
59
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True, strict=False)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Author(GitlabBase):
|
|
63
|
+
id: int
|
|
64
|
+
username: str
|
|
65
|
+
name: Optional[str] = None
|
|
66
|
+
email: Optional[str] = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Label(GitlabBase):
|
|
70
|
+
id: Optional[int] = None
|
|
71
|
+
name: str
|
|
72
|
+
color: Optional[str] = None
|
|
73
|
+
description: Optional[str] = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Milestone(GitlabBase):
|
|
77
|
+
id: int
|
|
78
|
+
iid: Optional[int] = None
|
|
79
|
+
title: str
|
|
80
|
+
description: Optional[str] = None
|
|
81
|
+
state: Optional[str] = None
|
|
82
|
+
due_date: Optional[str] = None
|
|
83
|
+
start_date: Optional[str] = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Iteration(GitlabBase):
|
|
87
|
+
id: int
|
|
88
|
+
iid: Optional[int] = None
|
|
89
|
+
title: Optional[str] = None
|
|
90
|
+
state: Optional[int] = None
|
|
91
|
+
start_date: Optional[str] = None
|
|
92
|
+
due_date: Optional[str] = None
|
|
93
|
+
web_url: Optional[str] = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Issue(GitlabBase):
|
|
97
|
+
id: int
|
|
98
|
+
iid: int
|
|
99
|
+
project_id: int
|
|
100
|
+
title: str
|
|
101
|
+
description: Optional[str] = None
|
|
102
|
+
state: IssueState
|
|
103
|
+
labels: List[str] = []
|
|
104
|
+
author: Optional[Author] = None
|
|
105
|
+
assignees: List[Author] = []
|
|
106
|
+
milestone: Optional[Milestone] = None
|
|
107
|
+
iteration: Optional[Iteration] = None
|
|
108
|
+
created_at: Optional[datetime] = None
|
|
109
|
+
updated_at: Optional[datetime] = None
|
|
110
|
+
closed_at: Optional[datetime] = None
|
|
111
|
+
web_url: Optional[str] = None
|
|
112
|
+
issue_type: Optional[str] = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Note(GitlabBase):
|
|
116
|
+
id: int
|
|
117
|
+
body: str
|
|
118
|
+
author: Optional[Author] = None
|
|
119
|
+
created_at: Optional[datetime] = None
|
|
120
|
+
updated_at: Optional[datetime] = None
|
|
121
|
+
system: Optional[bool] = None
|
|
122
|
+
noteable_id: Optional[int] = None
|
|
123
|
+
noteable_iid: Optional[int] = None
|
|
124
|
+
noteable_type: Optional[str] = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Discussion(GitlabBase):
|
|
128
|
+
id: str
|
|
129
|
+
individual_note: Optional[bool] = None
|
|
130
|
+
notes: List[Note] = []
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class MergeRequest(GitlabBase):
|
|
134
|
+
id: int
|
|
135
|
+
iid: int
|
|
136
|
+
project_id: int
|
|
137
|
+
title: str
|
|
138
|
+
description: Optional[str] = None
|
|
139
|
+
state: MergeRequestState
|
|
140
|
+
target_branch: str
|
|
141
|
+
source_branch: str
|
|
142
|
+
labels: List[str] = []
|
|
143
|
+
author: Optional[Author] = None
|
|
144
|
+
assignees: List[Author] = []
|
|
145
|
+
reviewers: List[Author] = []
|
|
146
|
+
milestone: Optional[Milestone] = None
|
|
147
|
+
draft: Optional[bool] = None
|
|
148
|
+
work_in_progress: Optional[bool] = None
|
|
149
|
+
merge_status: Optional[str] = None
|
|
150
|
+
created_at: Optional[datetime] = None
|
|
151
|
+
updated_at: Optional[datetime] = None
|
|
152
|
+
merged_at: Optional[datetime] = None
|
|
153
|
+
web_url: Optional[str] = None
|
|
154
|
+
sha: Optional[str] = None
|
|
155
|
+
diff_refs: Optional[Dict[str, Any]] = None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class RepoTreeEntry(GitlabBase):
|
|
159
|
+
id: str
|
|
160
|
+
name: str
|
|
161
|
+
type: str
|
|
162
|
+
path: str
|
|
163
|
+
mode: Optional[str] = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class Upload(GitlabBase):
|
|
167
|
+
"""Response from POST /projects/:id/uploads"""
|
|
168
|
+
|
|
169
|
+
alt: Optional[str] = None
|
|
170
|
+
url: str
|
|
171
|
+
full_path: Optional[str] = None
|
|
172
|
+
markdown: str
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
3
|
+
|
|
4
|
+
from httpx import AsyncClient
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
from pjdev_gitlab.api_utilities import (
|
|
8
|
+
async_retry_http,
|
|
9
|
+
encode_path_segment,
|
|
10
|
+
http_client,
|
|
11
|
+
paginate,
|
|
12
|
+
)
|
|
13
|
+
from pjdev_gitlab.config_service import get_config
|
|
14
|
+
from pjdev_gitlab.models import ProjectId
|
|
15
|
+
|
|
16
|
+
_IGNORE_4XX = [400, 401, 403, 404]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _generic_url(
|
|
20
|
+
project_id: ProjectId, package_name: str, package_version: str, file_name: str
|
|
21
|
+
) -> str:
|
|
22
|
+
return (
|
|
23
|
+
f"/projects/{encode_path_segment(project_id)}"
|
|
24
|
+
f"/packages/generic/{encode_path_segment(package_name)}"
|
|
25
|
+
f"/{encode_path_segment(package_version)}"
|
|
26
|
+
f"/{encode_path_segment(file_name)}"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
|
|
31
|
+
async def upload_generic_package(
|
|
32
|
+
project_id: ProjectId,
|
|
33
|
+
package_name: str,
|
|
34
|
+
package_version: str,
|
|
35
|
+
file_name: str,
|
|
36
|
+
file_path: Path,
|
|
37
|
+
*,
|
|
38
|
+
status: Literal["default", "hidden"] = "default",
|
|
39
|
+
select: Optional[Literal["package_file"]] = None,
|
|
40
|
+
client: Optional[AsyncClient] = None,
|
|
41
|
+
) -> Dict[str, Any]:
|
|
42
|
+
"""Upload a file to the GitLab generic package registry.
|
|
43
|
+
|
|
44
|
+
PUT /projects/:id/packages/generic/:package_name/:package_version/:file_name
|
|
45
|
+
Body is the raw file bytes; ``Content-Type: application/octet-stream``.
|
|
46
|
+
"""
|
|
47
|
+
url = _generic_url(project_id, package_name, package_version, file_name)
|
|
48
|
+
params: Dict[str, Any] = {"status": status}
|
|
49
|
+
if select is not None:
|
|
50
|
+
params["select"] = select
|
|
51
|
+
|
|
52
|
+
async def _exec(_client: AsyncClient) -> Dict[str, Any]:
|
|
53
|
+
with open(file_path, "rb") as fh:
|
|
54
|
+
r = await _client.put(
|
|
55
|
+
url,
|
|
56
|
+
params=params,
|
|
57
|
+
content=fh.read(),
|
|
58
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
59
|
+
)
|
|
60
|
+
if not r.is_success:
|
|
61
|
+
logger.warning(r.text)
|
|
62
|
+
r.raise_for_status()
|
|
63
|
+
if not r.content:
|
|
64
|
+
return {}
|
|
65
|
+
try:
|
|
66
|
+
return r.json()
|
|
67
|
+
except ValueError:
|
|
68
|
+
return {"text": r.text}
|
|
69
|
+
|
|
70
|
+
if client is None:
|
|
71
|
+
async with http_client() as _client:
|
|
72
|
+
return await _exec(_client)
|
|
73
|
+
return await _exec(client)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
|
|
77
|
+
async def download_generic_package(
|
|
78
|
+
project_id: ProjectId,
|
|
79
|
+
package_name: str,
|
|
80
|
+
package_version: str,
|
|
81
|
+
file_name: str,
|
|
82
|
+
*,
|
|
83
|
+
dest: Optional[Path] = None,
|
|
84
|
+
client: Optional[AsyncClient] = None,
|
|
85
|
+
) -> Path:
|
|
86
|
+
url = _generic_url(project_id, package_name, package_version, file_name)
|
|
87
|
+
|
|
88
|
+
if dest is None:
|
|
89
|
+
output_path = get_config().output_path
|
|
90
|
+
if output_path is None:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
"Either pass dest=... or set GL_OUTPUT_PATH / config.output_path"
|
|
93
|
+
)
|
|
94
|
+
dest = output_path / file_name
|
|
95
|
+
|
|
96
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
|
|
98
|
+
async def _exec(_client: AsyncClient) -> Path:
|
|
99
|
+
async with _client.stream("GET", url) as r:
|
|
100
|
+
r.raise_for_status()
|
|
101
|
+
with open(dest, "wb") as fh:
|
|
102
|
+
async for chunk in r.aiter_bytes():
|
|
103
|
+
fh.write(chunk)
|
|
104
|
+
return dest
|
|
105
|
+
|
|
106
|
+
if client is None:
|
|
107
|
+
async with http_client() as _client:
|
|
108
|
+
return await _exec(_client)
|
|
109
|
+
return await _exec(client)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
|
|
113
|
+
async def list_packages(
|
|
114
|
+
project_id: ProjectId,
|
|
115
|
+
*,
|
|
116
|
+
package_type: Optional[str] = None,
|
|
117
|
+
package_name: Optional[str] = None,
|
|
118
|
+
page_size: int = 100,
|
|
119
|
+
client: Optional[AsyncClient] = None,
|
|
120
|
+
) -> List[Dict[str, Any]]:
|
|
121
|
+
url = f"/projects/{encode_path_segment(project_id)}/packages"
|
|
122
|
+
params: Dict[str, Any] = {}
|
|
123
|
+
if package_type is not None:
|
|
124
|
+
params["package_type"] = package_type
|
|
125
|
+
if package_name is not None:
|
|
126
|
+
params["package_name"] = package_name
|
|
127
|
+
|
|
128
|
+
async def _exec(_client: AsyncClient) -> List[Dict[str, Any]]:
|
|
129
|
+
return await paginate(_client, url, params=params, page_size=page_size)
|
|
130
|
+
|
|
131
|
+
if client is None:
|
|
132
|
+
async with http_client() as _client:
|
|
133
|
+
return await _exec(_client)
|
|
134
|
+
return await _exec(client)
|
pjdev_gitlab/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any, Dict, List, Optional, Union
|
|
3
|
+
|
|
4
|
+
from httpx import AsyncClient
|
|
5
|
+
from loguru import logger
|
|
6
|
+
|
|
7
|
+
from pjdev_gitlab.api_utilities import (
|
|
8
|
+
async_retry_http,
|
|
9
|
+
encode_path_segment,
|
|
10
|
+
http_client,
|
|
11
|
+
paginate,
|
|
12
|
+
)
|
|
13
|
+
from pjdev_gitlab.config_service import get_config
|
|
14
|
+
from pjdev_gitlab.models import ProjectId, RepoTreeEntry
|
|
15
|
+
|
|
16
|
+
_IGNORE_4XX = [400, 401, 403, 404]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _file_url(project_id: ProjectId, file_path: str) -> str:
|
|
20
|
+
return (
|
|
21
|
+
f"/projects/{encode_path_segment(project_id)}"
|
|
22
|
+
f"/repository/files/{encode_path_segment(file_path)}/raw"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _tree_url(project_id: ProjectId) -> str:
|
|
27
|
+
return f"/projects/{encode_path_segment(project_id)}/repository/tree"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
|
|
31
|
+
async def get_file(
|
|
32
|
+
project_id: ProjectId,
|
|
33
|
+
file_path: str,
|
|
34
|
+
ref: str = "main",
|
|
35
|
+
*,
|
|
36
|
+
decode: bool = True,
|
|
37
|
+
client: Optional[AsyncClient] = None,
|
|
38
|
+
) -> Union[str, bytes]:
|
|
39
|
+
url = _file_url(project_id, file_path)
|
|
40
|
+
params = {"ref": ref}
|
|
41
|
+
|
|
42
|
+
async def _exec(_client: AsyncClient) -> Union[str, bytes]:
|
|
43
|
+
r = await _client.get(url, params=params)
|
|
44
|
+
r.raise_for_status()
|
|
45
|
+
return r.text if decode else r.content
|
|
46
|
+
|
|
47
|
+
if client is None:
|
|
48
|
+
async with http_client() as _client:
|
|
49
|
+
return await _exec(_client)
|
|
50
|
+
return await _exec(client)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
|
|
54
|
+
async def download_file(
|
|
55
|
+
project_id: ProjectId,
|
|
56
|
+
file_path: str,
|
|
57
|
+
ref: str = "main",
|
|
58
|
+
*,
|
|
59
|
+
dest: Optional[Path] = None,
|
|
60
|
+
client: Optional[AsyncClient] = None,
|
|
61
|
+
) -> Path:
|
|
62
|
+
url = _file_url(project_id, file_path)
|
|
63
|
+
params = {"ref": ref}
|
|
64
|
+
|
|
65
|
+
if dest is None:
|
|
66
|
+
output_path = get_config().output_path
|
|
67
|
+
if output_path is None:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
"Either pass dest=... or set GL_OUTPUT_PATH / config.output_path"
|
|
70
|
+
)
|
|
71
|
+
dest = output_path / Path(file_path).name
|
|
72
|
+
|
|
73
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
|
|
75
|
+
async def _exec(_client: AsyncClient) -> Path:
|
|
76
|
+
async with _client.stream("GET", url, params=params) as r:
|
|
77
|
+
r.raise_for_status()
|
|
78
|
+
with open(dest, "wb") as fh:
|
|
79
|
+
async for chunk in r.aiter_bytes():
|
|
80
|
+
fh.write(chunk)
|
|
81
|
+
return dest
|
|
82
|
+
|
|
83
|
+
if client is None:
|
|
84
|
+
async with http_client() as _client:
|
|
85
|
+
return await _exec(_client)
|
|
86
|
+
return await _exec(client)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@async_retry_http(status_codes_to_ignore=_IGNORE_4XX)
|
|
90
|
+
async def list_tree(
|
|
91
|
+
project_id: ProjectId,
|
|
92
|
+
path: str = "",
|
|
93
|
+
ref: str = "main",
|
|
94
|
+
*,
|
|
95
|
+
recursive: bool = False,
|
|
96
|
+
page_size: int = 100,
|
|
97
|
+
client: Optional[AsyncClient] = None,
|
|
98
|
+
) -> List[RepoTreeEntry]:
|
|
99
|
+
url = _tree_url(project_id)
|
|
100
|
+
params: Dict[str, Any] = {"ref": ref, "recursive": "true" if recursive else "false"}
|
|
101
|
+
if path:
|
|
102
|
+
params["path"] = path
|
|
103
|
+
|
|
104
|
+
async def _exec(_client: AsyncClient) -> List[RepoTreeEntry]:
|
|
105
|
+
rows = await paginate(_client, url, params=params, page_size=page_size)
|
|
106
|
+
return [RepoTreeEntry.model_validate(row) for row in rows]
|
|
107
|
+
|
|
108
|
+
if client is None:
|
|
109
|
+
async with http_client() as _client:
|
|
110
|
+
return await _exec(_client)
|
|
111
|
+
return await _exec(client)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def download_directory(
|
|
115
|
+
project_id: ProjectId,
|
|
116
|
+
path: str,
|
|
117
|
+
ref: str = "main",
|
|
118
|
+
*,
|
|
119
|
+
dest: Path,
|
|
120
|
+
client: Optional[AsyncClient] = None,
|
|
121
|
+
) -> Path:
|
|
122
|
+
"""Recursively download every blob under ``path`` into ``dest``, preserving
|
|
123
|
+
the relative directory structure.
|
|
124
|
+
"""
|
|
125
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
|
|
127
|
+
async def _exec(_client: AsyncClient) -> Path:
|
|
128
|
+
entries = await list_tree(project_id, path=path, ref=ref, recursive=True, client=_client)
|
|
129
|
+
for entry in entries:
|
|
130
|
+
if entry.type != "blob":
|
|
131
|
+
continue
|
|
132
|
+
relative = (
|
|
133
|
+
entry.path[len(path):].lstrip("/")
|
|
134
|
+
if path and entry.path.startswith(path)
|
|
135
|
+
else entry.path
|
|
136
|
+
)
|
|
137
|
+
file_dest = dest / relative
|
|
138
|
+
await download_file(project_id, entry.path, ref=ref, dest=file_dest, client=_client)
|
|
139
|
+
logger.info(f"Downloaded {path or '/'}@{ref} from project {project_id} to {dest}")
|
|
140
|
+
return dest
|
|
141
|
+
|
|
142
|
+
if client is None:
|
|
143
|
+
async with http_client() as _client:
|
|
144
|
+
return await _exec(_client)
|
|
145
|
+
return await _exec(client)
|