GeezerVison is showing github.com — feat(downloads): allow streaming downloads access to response iterato… · python-gitlab/python-gitlab@b644721
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboardExpand all lines: docs/gl_objects/pipelines_and_jobs.rst +13Lines changed: 13 additions & 0 deletions - Display the source diff
- Display the rich diff
| Original file line number | Diff line number | Diff line change |
|---|
@@ -274,6 +274,19 @@ You can also directly stream the output into a file, and unzip it afterwards:: | 274 | 274 | subprocess.run(["unzip", "-bo", zipfn]) | 275 | 275 | os.unlink(zipfn) | 276 | 276 |
| | 277 | +Or, you can also use the underlying response iterator directly:: | | 278 | + | | 279 | + artifact_bytes_iterator = build_or_job.artifacts(iterator=True) | | 280 | + | | 281 | +This can be used with frameworks that expect an iterator (such as FastAPI/Starlette's | | 282 | +``StreamingResponse``) to forward a download from GitLab without having to download | | 283 | +the entire content server-side first:: | | 284 | + | | 285 | + @app.get("/download_artifact") | | 286 | + def download_artifact(): | | 287 | + artifact_bytes_iterator = build_or_job.artifacts(iterator=True) | | 288 | + return StreamingResponse(artifact_bytes_iterator, media_type="application/zip") | | 289 | + | 277 | 290 | Delete all artifacts of a project that can be deleted:: | 278 | 291 |
| 279 | 292 | project.artifacts.delete() | |
Collapse file Copy file name to clipboardExpand all lines: gitlab/mixins.py +6-2Lines changed: 6 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 20 | 20 | Any, | 21 | 21 | Callable, | 22 | 22 | Dict, | | 23 | + Iterator, | 23 | 24 | List, | 24 | 25 | Optional, | 25 | 26 | Tuple, | @@ -612,16 +613,19 @@ class DownloadMixin(_RestObjectBase): | 612 | 613 | def download( | 613 | 614 | self, | 614 | 615 | streamed: bool = False, | | 616 | + iterator: bool = False, | 615 | 617 | action: Optional[Callable] = None, | 616 | 618 | chunk_size: int = 1024, | 617 | 619 | **kwargs: Any, | 618 | | - ) -> Optional[bytes]: | | 620 | + ) -> Optional[Union[bytes, Iterator[Any]]]: | 619 | 621 | """Download the archive of a resource export. | 620 | 622 |
| 621 | 623 | Args: | 622 | 624 | streamed: If True the data will be processed by chunks of | 623 | 625 | `chunk_size` and each chunk is passed to `action` for | 624 | 626 | treatment | | 627 | + iterator: If True directly return the underlying response | | 628 | + iterator | 625 | 629 | action: Callable responsible of dealing with chunk of | 626 | 630 | data | 627 | 631 | chunk_size: Size of each chunk | @@ -640,7 +644,7 @@ def download( | 640 | 644 | ) | 641 | 645 | if TYPE_CHECKING: | 642 | 646 | assert isinstance(result, requests.Response) | 643 | | - return utils.response_content(result, streamed, action, chunk_size) | | 647 | + return utils.response_content(result, streamed, iterator, action, chunk_size) | 644 | 648 |
| 645 | 649 |
| 646 | 650 | class SubscribableMixin(_RestObjectBase): | |
Collapse file Copy file name to clipboardExpand all lines: gitlab/utils.py +6-2Lines changed: 6 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 19 | 19 | import traceback | 20 | 20 | import urllib.parse | 21 | 21 | import warnings | 22 | | -from typing import Any, Callable, Dict, Optional, Tuple, Type, Union | | 22 | +from typing import Any, Callable, Dict, Iterator, Optional, Tuple, Type, Union | 23 | 23 |
| 24 | 24 | import requests | 25 | 25 |
| @@ -34,9 +34,13 @@ def __call__(self, chunk: Any) -> None: | 34 | 34 | def response_content( | 35 | 35 | response: requests.Response, | 36 | 36 | streamed: bool, | | 37 | + iterator: bool, | 37 | 38 | action: Optional[Callable], | 38 | 39 | chunk_size: int, | 39 | | -) -> Optional[bytes]: | | 40 | +) -> Optional[Union[bytes, Iterator[Any]]]: | | 41 | + if iterator: | | 42 | + return response.iter_content(chunk_size=chunk_size) | | 43 | + | 40 | 44 | if streamed is False: | 41 | 45 | return response.content | 42 | 46 |
| |
Collapse file Copy file name to clipboardExpand all lines: gitlab/v4/cli.py +1Lines changed: 1 addition & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -127,6 +127,7 @@ def do_project_export_download(self) -> None: | 127 | 127 | data = export_status.download() | 128 | 128 | if TYPE_CHECKING: | 129 | 129 | assert data is not None | | 130 | + assert isinstance(data, bytes) | 130 | 131 | sys.stdout.buffer.write(data) | 131 | 132 |
| 132 | 133 | except Exception as e: # pragma: no cover, cli.die is unit-tested | |
Collapse file Copy file name to clipboardExpand all lines: gitlab/v4/objects/artifacts.py +16-6Lines changed: 16 additions & 6 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 2 | 2 | GitLab API: | 3 | 3 | https://docs.gitlab.com/ee/api/job_artifacts.html | 4 | 4 | """ | 5 | | -from typing import Any, Callable, Optional, TYPE_CHECKING | | 5 | +from typing import Any, Callable, Iterator, Optional, TYPE_CHECKING, Union | 6 | 6 |
| 7 | 7 | import requests | 8 | 8 |
| @@ -40,10 +40,14 @@ def __call__( | 40 | 40 | ), | 41 | 41 | category=DeprecationWarning, | 42 | 42 | ) | 43 | | - return self.download( | | 43 | + data = self.download( | 44 | 44 | *args, | 45 | 45 | **kwargs, | 46 | 46 | ) | | 47 | + if TYPE_CHECKING: | | 48 | + assert data is not None | | 49 | + assert isinstance(data, bytes) | | 50 | + return data | 47 | 51 |
| 48 | 52 | @exc.on_http_error(exc.GitlabDeleteError) | 49 | 53 | def delete(self, **kwargs: Any) -> None: | @@ -71,10 +75,11 @@ def download( | 71 | 75 | ref_name: str, | 72 | 76 | job: str, | 73 | 77 | streamed: bool = False, | | 78 | + iterator: bool = False, | 74 | 79 | action: Optional[Callable] = None, | 75 | 80 | chunk_size: int = 1024, | 76 | 81 | **kwargs: Any, | 77 | | - ) -> Optional[bytes]: | | 82 | + ) -> Optional[Union[bytes, Iterator[Any]]]: | 78 | 83 | """Get the job artifacts archive from a specific tag or branch. | 79 | 84 |
| 80 | 85 | Args: | @@ -85,6 +90,8 @@ def download( | 85 | 90 | streamed: If True the data will be processed by chunks of | 86 | 91 | `chunk_size` and each chunk is passed to `action` for | 87 | 92 | treatment | | 93 | + iterator: If True directly return the underlying response | | 94 | + iterator | 88 | 95 | action: Callable responsible of dealing with chunk of | 89 | 96 | data | 90 | 97 | chunk_size: Size of each chunk | @@ -103,7 +110,7 @@ def download( | 103 | 110 | ) | 104 | 111 | if TYPE_CHECKING: | 105 | 112 | assert isinstance(result, requests.Response) | 106 | | - return utils.response_content(result, streamed, action, chunk_size) | | 113 | + return utils.response_content(result, streamed, iterator, action, chunk_size) | 107 | 114 |
| 108 | 115 | @cli.register_custom_action( | 109 | 116 | "ProjectArtifactManager", ("ref_name", "artifact_path", "job") | @@ -115,10 +122,11 @@ def raw( | 115 | 122 | artifact_path: str, | 116 | 123 | job: str, | 117 | 124 | streamed: bool = False, | | 125 | + iterator: bool = False, | 118 | 126 | action: Optional[Callable] = None, | 119 | 127 | chunk_size: int = 1024, | 120 | 128 | **kwargs: Any, | 121 | | - ) -> Optional[bytes]: | | 129 | + ) -> Optional[Union[bytes, Iterator[Any]]]: | 122 | 130 | """Download a single artifact file from a specific tag or branch from | 123 | 131 | within the job's artifacts archive. | 124 | 132 |
| @@ -130,6 +138,8 @@ def raw( | 130 | 138 | streamed: If True the data will be processed by chunks of | 131 | 139 | `chunk_size` and each chunk is passed to `action` for | 132 | 140 | treatment | | 141 | + iterator: If True directly return the underlying response | | 142 | + iterator | 133 | 143 | action: Callable responsible of dealing with chunk of | 134 | 144 | data | 135 | 145 | chunk_size: Size of each chunk | @@ -148,4 +158,4 @@ def raw( | 148 | 158 | ) | 149 | 159 | if TYPE_CHECKING: | 150 | 160 | assert isinstance(result, requests.Response) | 151 | | - return utils.response_content(result, streamed, action, chunk_size) | | 161 | + return utils.response_content(result, streamed, iterator, action, chunk_size) |
Collapse file Copy file name to clipboardExpand all lines: gitlab/v4/objects/files.py +16-3Lines changed: 16 additions & 3 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | 1 | import base64 | 2 | | -from typing import Any, Callable, cast, Dict, List, Optional, TYPE_CHECKING | | 2 | +from typing import ( | | 3 | + Any, | | 4 | + Callable, | | 5 | + cast, | | 6 | + Dict, | | 7 | + Iterator, | | 8 | + List, | | 9 | + Optional, | | 10 | + TYPE_CHECKING, | | 11 | + Union, | | 12 | +) | 3 | 13 |
| 4 | 14 | import requests | 5 | 15 |
| @@ -220,10 +230,11 @@ def raw( | 220 | 230 | file_path: str, | 221 | 231 | ref: str, | 222 | 232 | streamed: bool = False, | | 233 | + iterator: bool = False, | 223 | 234 | action: Optional[Callable[..., Any]] = None, | 224 | 235 | chunk_size: int = 1024, | 225 | 236 | **kwargs: Any, | 226 | | - ) -> Optional[bytes]: | | 237 | + ) -> Optional[Union[bytes, Iterator[Any]]]: | 227 | 238 | """Return the content of a file for a commit. | 228 | 239 |
| 229 | 240 | Args: | @@ -232,6 +243,8 @@ def raw( | 232 | 243 | streamed: If True the data will be processed by chunks of | 233 | 244 | `chunk_size` and each chunk is passed to `action` for | 234 | 245 | treatment | | 246 | + iterator: If True directly return the underlying response | | 247 | + iterator | 235 | 248 | action: Callable responsible of dealing with chunk of | 236 | 249 | data | 237 | 250 | chunk_size: Size of each chunk | @@ -252,7 +265,7 @@ def raw( | 252 | 265 | ) | 253 | 266 | if TYPE_CHECKING: | 254 | 267 | assert isinstance(result, requests.Response) | 255 | | - return utils.response_content(result, streamed, action, chunk_size) | | 268 | + return utils.response_content(result, streamed, iterator, action, chunk_size) | 256 | 269 |
| 257 | 270 | @cli.register_custom_action("ProjectFileManager", ("file_path", "ref")) | 258 | 271 | @exc.on_http_error(exc.GitlabListError) | |
Collapse file Copy file name to clipboardExpand all lines: gitlab/v4/objects/jobs.py +17-6Lines changed: 17 additions & 6 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | | -from typing import Any, Callable, cast, Dict, Optional, TYPE_CHECKING, Union | | 1 | +from typing import Any, Callable, cast, Dict, Iterator, Optional, TYPE_CHECKING, Union | 2 | 2 |
| 3 | 3 | import requests | 4 | 4 |
| @@ -116,16 +116,19 @@ def delete_artifacts(self, **kwargs: Any) -> None: | 116 | 116 | def artifacts( | 117 | 117 | self, | 118 | 118 | streamed: bool = False, | | 119 | + iterator: bool = False, | 119 | 120 | action: Optional[Callable[..., Any]] = None, | 120 | 121 | chunk_size: int = 1024, | 121 | 122 | **kwargs: Any, | 122 | | - ) -> Optional[bytes]: | | 123 | + ) -> Optional[Union[bytes, Iterator[Any]]]: | 123 | 124 | """Get the job artifacts. | 124 | 125 |
| 125 | 126 | Args: | 126 | 127 | streamed: If True the data will be processed by chunks of | 127 | 128 | `chunk_size` and each chunk is passed to `action` for | 128 | 129 | treatment | | 130 | + iterator: If True directly return the underlying response | | 131 | + iterator | 129 | 132 | action: Callable responsible of dealing with chunk of | 130 | 133 | data | 131 | 134 | chunk_size: Size of each chunk | @@ -144,25 +147,28 @@ def artifacts( | 144 | 147 | ) | 145 | 148 | if TYPE_CHECKING: | 146 | 149 | assert isinstance(result, requests.Response) | 147 | | - return utils.response_content(result, streamed, action, chunk_size) | | 150 | + return utils.response_content(result, streamed, iterator, action, chunk_size) | 148 | 151 |
| 149 | 152 | @cli.register_custom_action("ProjectJob") | 150 | 153 | @exc.on_http_error(exc.GitlabGetError) | 151 | 154 | def artifact( | 152 | 155 | self, | 153 | 156 | path: str, | 154 | 157 | streamed: bool = False, | | 158 | + iterator: bool = False, | 155 | 159 | action: Optional[Callable[..., Any]] = None, | 156 | 160 | chunk_size: int = 1024, | 157 | 161 | **kwargs: Any, | 158 | | - ) -> Optional[bytes]: | | 162 | + ) -> Optional[Union[bytes, Iterator[Any]]]: | 159 | 163 | """Get a single artifact file from within the job's artifacts archive. | 160 | 164 |
| 161 | 165 | Args: | 162 | 166 | path: Path of the artifact | 163 | 167 | streamed: If True the data will be processed by chunks of | 164 | 168 | `chunk_size` and each chunk is passed to `action` for | 165 | 169 | treatment | | 170 | + iterator: If True directly return the underlying response | | 171 | + iterator | 166 | 172 | action: Callable responsible of dealing with chunk of | 167 | 173 | data | 168 | 174 | chunk_size: Size of each chunk | @@ -181,13 +187,14 @@ def artifact( | 181 | 187 | ) | 182 | 188 | if TYPE_CHECKING: | 183 | 189 | assert isinstance(result, requests.Response) | 184 | | - return utils.response_content(result, streamed, action, chunk_size) | | 190 | + return utils.response_content(result, streamed, iterator, action, chunk_size) | 185 | 191 |
| 186 | 192 | @cli.register_custom_action("ProjectJob") | 187 | 193 | @exc.on_http_error(exc.GitlabGetError) | 188 | 194 | def trace( | 189 | 195 | self, | 190 | 196 | streamed: bool = False, | | 197 | + iterator: bool = False, | 191 | 198 | action: Optional[Callable[..., Any]] = None, | 192 | 199 | chunk_size: int = 1024, | 193 | 200 | **kwargs: Any, | @@ -198,6 +205,8 @@ def trace( | 198 | 205 | streamed: If True the data will be processed by chunks of | 199 | 206 | `chunk_size` and each chunk is passed to `action` for | 200 | 207 | treatment | | 208 | + iterator: If True directly return the underlying response | | 209 | + iterator | 201 | 210 | action: Callable responsible of dealing with chunk of | 202 | 211 | data | 203 | 212 | chunk_size: Size of each chunk | @@ -216,7 +225,9 @@ def trace( | 216 | 225 | ) | 217 | 226 | if TYPE_CHECKING: | 218 | 227 | assert isinstance(result, requests.Response) | 219 | | - return_value = utils.response_content(result, streamed, action, chunk_size) | | 228 | + return_value = utils.response_content( | | 229 | + result, streamed, iterator, action, chunk_size | | 230 | + ) | 220 | 231 | if TYPE_CHECKING: | 221 | 232 | assert isinstance(return_value, dict) | 222 | 233 | return return_value | |
Collapse file Copy file name to clipboardExpand all lines: gitlab/v4/objects/packages.py +6-3Lines changed: 6 additions & 3 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 5 | 5 | """ | 6 | 6 |
| 7 | 7 | from pathlib import Path | 8 | | -from typing import Any, Callable, cast, Optional, TYPE_CHECKING, Union | | 8 | +from typing import Any, Callable, cast, Iterator, Optional, TYPE_CHECKING, Union | 9 | 9 |
| 10 | 10 | import requests | 11 | 11 |
| @@ -103,10 +103,11 @@ def download( | 103 | 103 | package_version: str, | 104 | 104 | file_name: str, | 105 | 105 | streamed: bool = False, | | 106 | + iterator: bool = False, | 106 | 107 | action: Optional[Callable] = None, | 107 | 108 | chunk_size: int = 1024, | 108 | 109 | **kwargs: Any, | 109 | | - ) -> Optional[bytes]: | | 110 | + ) -> Optional[Union[bytes, Iterator[Any]]]: | 110 | 111 | """Download a generic package. | 111 | 112 |
| 112 | 113 | Args: | @@ -116,6 +117,8 @@ def download( | 116 | 117 | streamed: If True the data will be processed by chunks of | 117 | 118 | `chunk_size` and each chunk is passed to `action` for | 118 | 119 | treatment | | 120 | + iterator: If True directly return the underlying response | | 121 | + iterator | 119 | 122 | action: Callable responsible of dealing with chunk of | 120 | 123 | data | 121 | 124 | chunk_size: Size of each chunk | @@ -132,7 +135,7 @@ def download( | 132 | 135 | result = self.gitlab.http_get(path, streamed=streamed, raw=True, **kwargs) | 133 | 136 | if TYPE_CHECKING: | 134 | 137 | assert isinstance(result, requests.Response) | 135 | | - return utils.response_content(result, streamed, action, chunk_size) | | 138 | + return utils.response_content(result, streamed, iterator, action, chunk_size) | 136 | 139 |
| 137 | 140 |
| 138 | 141 | class GroupPackage(RESTObject): | |
|