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.
@@ -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)