|
22 | 22 |
|
23 | 23 | '''Downloads a previously uploaded fileset to the local workspace''' |
24 | 24 |
|
| 25 | +from __future__ import annotations |
| 26 | + |
25 | 27 | import copy |
| 28 | +import io |
26 | 29 | import logging |
27 | 30 | import os |
28 | 31 | import shutil |
| 32 | +import subprocess |
| 33 | +import tempfile |
| 34 | +from pathlib import Path |
29 | 35 | from pathlib import PurePosixPath |
| 36 | +from typing import Iterator |
30 | 37 |
|
31 | 38 | import nimp.artifacts |
32 | 39 | import nimp.command |
33 | 40 | import nimp.system |
| 41 | +from nimp.environment import Environment |
| 42 | +from nimp.utils import git |
34 | 43 |
|
35 | 44 |
|
36 | 45 | class DownloadFileset(nimp.command.Command): |
@@ -59,31 +68,35 @@ def configure_arguments(self, env, parser): |
59 | 68 | def is_available(self, env): |
60 | 69 | return True, '' |
61 | 70 |
|
62 | | - def run(self, env): |
63 | | - api_context = nimp.utils.git.initialize_gitea_api_context(env) |
| 71 | + def run(self, env: Environment) -> bool: |
| 72 | + api_context = git.initialize_gitea_api_context(env) |
64 | 73 |
|
65 | | - artifacts_source = env.artifact_repository_source |
| 74 | + artifacts_source: str = env.artifact_repository_source |
66 | 75 | if env.prefer_http: |
67 | 76 | artifacts_http_source = getattr(env, 'artifact_http_repository_source', None) |
68 | 77 | if artifacts_http_source: |
69 | 78 | artifacts_source = artifacts_http_source |
70 | 79 | else: |
71 | 80 | logging.warning('prefer-http provided but no artifact_http_repository_source in configuration') |
72 | 81 |
|
73 | | - artifact_uri_pattern = artifacts_source.rstrip('/') + '/' + env.artifact_collection[env.fileset] |
| 82 | + artifact_uri_pattern: str = artifacts_source.rstrip('/') + '/' + str(env.artifact_collection[env.fileset]) |
74 | 83 |
|
75 | 84 | install_directory = env.root_dir |
76 | 85 | if env.destination: |
77 | 86 | install_directory = str(PurePosixPath(install_directory) / env.format(env.destination)) |
78 | 87 |
|
79 | 88 | format_arguments = copy.deepcopy(vars(env)) |
80 | | - format_arguments['revision'] = '*' |
81 | | - logging.info('Searching %s', artifact_uri_pattern.format(**format_arguments)) |
| 89 | + logging.info('Searching %s', artifact_uri_pattern.format_map({**format_arguments, 'revision': '*'})) |
82 | 90 | all_artifacts = nimp.system.try_execute( |
83 | | - lambda: nimp.artifacts.list_artifacts(artifact_uri_pattern, format_arguments, api_context), OSError |
| 91 | + lambda: nimp.artifacts.list_artifacts(artifact_uri_pattern, format_arguments, api_context), |
| 92 | + OSError, |
84 | 93 | ) |
85 | 94 | artifact_to_download = DownloadFileset._find_matching_artifact( |
86 | | - all_artifacts, env.revision, env.min_revision, env.max_revision, api_context |
| 95 | + all_artifacts, |
| 96 | + env.revision, |
| 97 | + env.min_revision, |
| 98 | + env.max_revision, |
| 99 | + api_context, |
87 | 100 | ) |
88 | 101 |
|
89 | 102 | logging.info('Downloading %s%s', artifact_to_download['uri'], ' (simulation)' if env.dry_run else '') |
@@ -123,39 +136,140 @@ def run(self, env): |
123 | 136 |
|
124 | 137 | return True |
125 | 138 |
|
126 | | - # TODO: Handle revision comparison when identified by a hash |
127 | 139 | @staticmethod |
128 | | - def _find_matching_artifact(all_artifacts, exact_revision, minimum_revision, maximum_revision, api_context): |
129 | | - all_artifacts = sorted(all_artifacts, key=lambda artifact: int(artifact['sortable_revision'], 16), reverse=True) |
130 | | - has_revision_input = exact_revision or minimum_revision or maximum_revision |
131 | | - |
132 | | - if api_context: |
133 | | - exact_revision = nimp.utils.git.get_gitea_commit_timestamp(api_context, exact_revision) |
134 | | - minimum_revision = nimp.utils.git.get_gitea_commit_timestamp(api_context, minimum_revision) |
135 | | - maximum_revision = nimp.utils.git.get_gitea_commit_timestamp(api_context, maximum_revision) |
136 | | - revision_not_found = not exact_revision and not minimum_revision and not maximum_revision |
137 | | - if has_revision_input and revision_not_found: |
138 | | - raise ValueError('Searched commit not found on gitea repo') |
139 | | - |
140 | | - if not api_context and (has_revision_input is not None and not has_revision_input.isdigit()): |
141 | | - raise ValueError( |
142 | | - 'Revision seems to be a git commit hash but missing gitea api information. Please check project_branches in project configuration.' |
143 | | - ) |
| 140 | + def _find_matching_artifact( |
| 141 | + all_artifacts: list[nimp.artifacts.Artifact], |
| 142 | + exact_revision: str | None, |
| 143 | + minimum_revision: str | None, |
| 144 | + maximum_revision: str | None, |
| 145 | + api_context: git.GitApiContext | None, |
| 146 | + ) -> nimp.artifacts.Artifact: |
| 147 | + # fastpath for exact_revision |
| 148 | + if exact_revision is not None: |
| 149 | + if (artifact := next((a for a in all_artifacts if a['revision'] == exact_revision), None)) is not None: |
| 150 | + return artifact |
| 151 | + raise ValueError('Matching artifact not found') |
| 152 | + |
| 153 | + # fastpath for maximum_revision |
| 154 | + if maximum_revision is not None: |
| 155 | + if (artifact := next((a for a in all_artifacts if a['revision'] == maximum_revision), None)) is not None: |
| 156 | + return artifact |
| 157 | + |
| 158 | + if ( |
| 159 | + any(git.maybe_git_revision(a['revision']) for a in all_artifacts) |
| 160 | + or (minimum_revision is not None and git.maybe_git_revision(minimum_revision)) |
| 161 | + or (maximum_revision is not None and git.maybe_git_revision(maximum_revision)) |
| 162 | + ): |
| 163 | + if ( |
| 164 | + newest_rev := DownloadFileset._get_newest_revision( |
| 165 | + revisions=[a['revision'] for a in all_artifacts], |
| 166 | + minimum_revision=minimum_revision, |
| 167 | + maximum_revision=maximum_revision, |
| 168 | + api_context=api_context, |
| 169 | + ) |
| 170 | + ) is not None: |
| 171 | + return next(a for a in all_artifacts if a['revision'] == newest_rev) |
| 172 | + |
| 173 | + probably_p4_rev = all(a['revision'].isdigit() for a in all_artifacts) |
| 174 | + if probably_p4_rev: |
| 175 | + iter_: Iterator[int] = iter(int(a['revision']) for a in all_artifacts) |
| 176 | + if minimum_revision: |
| 177 | + minimum_revision_int = int(minimum_revision) |
| 178 | + iter_ = filter(lambda rev: rev >= minimum_revision_int, iter_) |
| 179 | + |
| 180 | + if maximum_revision: |
| 181 | + maximum_revision_int = int(maximum_revision) |
| 182 | + iter_ = filter(lambda rev: rev <= maximum_revision_int, iter_) |
| 183 | + |
| 184 | + if (revision := max(iter_, default=None)) is not None: |
| 185 | + revision_str = str(revision) |
| 186 | + return next(a for a in all_artifacts if a['revision'] == revision_str) |
| 187 | + |
| 188 | + raise ValueError('Matching artifact not found') |
| 189 | + |
| 190 | + @staticmethod |
| 191 | + def _get_newest_revision( |
| 192 | + revisions: list[str], |
| 193 | + minimum_revision: str | None, |
| 194 | + maximum_revision: str | None, |
| 195 | + api_context: git.GitApiContext | None, |
| 196 | + ) -> str | None: |
| 197 | + remote: str | None = None |
| 198 | + if api_context is not None and (api_client := api_context['instance'].api_client) is not None: |
| 199 | + remote = f"{api_client.configuration.host}/{api_context['repo_owner']}/{api_context['repo_name']}" |
| 200 | + |
| 201 | + cwd_git_dir = git.get_git_dir() |
| 202 | + |
| 203 | + if remote is not None: |
| 204 | + with tempfile.TemporaryDirectory(prefix="nimp_git_") as tmp_git_dir: |
| 205 | + Path(tmp_git_dir).mkdir(parents=True, exist_ok=True) |
| 206 | + subprocess.check_call(['git', 'init', '--bare'], cwd=tmp_git_dir) |
144 | 207 |
|
145 | | - try: |
146 | | - if exact_revision is not None: |
147 | | - return next(a for a in all_artifacts if a['sortable_revision'] == exact_revision) |
148 | | - if minimum_revision is not None and maximum_revision is not None: |
149 | | - return next( |
150 | | - a |
151 | | - for a in all_artifacts |
152 | | - if int(a['sortable_revision']) >= int(minimum_revision) |
153 | | - and int(a['sortable_revision']) <= int(maximum_revision) |
| 208 | + # if current workdir contains a git repo, use it as alternate to prevent unnecessary burden on remote |
| 209 | + if cwd_git_dir is not None and git.is_shallow_repository(cwd_git_dir) is False: |
| 210 | + git.add_alternates(cwd_git_dir, cwd=tmp_git_dir) |
| 211 | + |
| 212 | + return DownloadFileset._find_newest_revision( |
| 213 | + tmp_git_dir, |
| 214 | + revisions=revisions, |
| 215 | + minimum_revision=minimum_revision, |
| 216 | + maximum_revision=maximum_revision, |
154 | 217 | ) |
155 | | - if minimum_revision is not None: |
156 | | - return next(a for a in all_artifacts if int(a['sortable_revision']) >= int(minimum_revision)) |
| 218 | + |
| 219 | + elif cwd_git_dir is not None: |
| 220 | + # no remote, fallback to current git |
| 221 | + return DownloadFileset._find_newest_revision( |
| 222 | + cwd_git_dir, |
| 223 | + revisions=revisions, |
| 224 | + minimum_revision=minimum_revision, |
| 225 | + maximum_revision=maximum_revision, |
| 226 | + ) |
| 227 | + |
| 228 | + # no current git. Can't find revisions informations |
| 229 | + return None |
| 230 | + |
| 231 | + @staticmethod |
| 232 | + def _find_newest_revision( |
| 233 | + git_dir: str, revisions: list[str], minimum_revision: str | None, maximum_revision: str | None |
| 234 | + ): |
| 235 | + remotes = git.get_remotes(git_dir) |
| 236 | + |
| 237 | + to_fetch = [*revisions] |
| 238 | + if minimum_revision is not None: |
| 239 | + to_fetch.append(minimum_revision) |
| 240 | + if maximum_revision is not None: |
| 241 | + to_fetch.append(maximum_revision) |
| 242 | + |
| 243 | + for remote in remotes: |
| 244 | + if subprocess.call(['git', 'fetch', '--no-recurse-submodules', remote, *to_fetch], cwd=git_dir) != 0: |
| 245 | + # might have failed due to one (or more) unknown ref, |
| 246 | + # try one-by-one and ignore failures |
| 247 | + for rev in to_fetch: |
| 248 | + subprocess.call(['git', 'fetch', '--no-recurse-submodules', remote, rev], cwd=git_dir) |
| 249 | + |
| 250 | + rev_list_cmd = ['git', 'rev-list', '--ignore-missing', *revisions] |
| 251 | + if maximum_revision is not None: |
| 252 | + rev_list_cmd.append(maximum_revision) |
| 253 | + |
| 254 | + if minimum_revision is not None: |
| 255 | + rev_list_cmd.append(minimum_revision) |
| 256 | + |
| 257 | + process = subprocess.run(rev_list_cmd, text=True, stdout=subprocess.PIPE, cwd=git_dir) |
| 258 | + assert process.returncode == 0 |
| 259 | + # read line by line, no need to split all |
| 260 | + with io.StringIO(process.stdout) as buffer: |
| 261 | + revisions_set = set(revisions) |
157 | 262 | if maximum_revision is not None: |
158 | | - return next(a for a in all_artifacts if int(a['sortable_revision']) <= int(maximum_revision)) |
159 | | - return next(a for a in all_artifacts) |
160 | | - except StopIteration: |
161 | | - raise ValueError('Matching artifact not found') |
| 263 | + # should not happen, handled as a special case before |
| 264 | + assert maximum_revision not in revisions_set |
| 265 | + while revision := buffer.readline(): |
| 266 | + if revision == maximum_revision: |
| 267 | + break |
| 268 | + |
| 269 | + while revision := buffer.readline(): |
| 270 | + if revision in revisions_set: |
| 271 | + return revision |
| 272 | + if revision == minimum_revision: |
| 273 | + break |
| 274 | + |
| 275 | + return None |
0 commit comments