cmem-plugin-git 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.
- cmem_plugin_git/__init__.py +1 -0
- cmem_plugin_git/autocompletion.py +86 -0
- cmem_plugin_git/download.py +272 -0
- cmem_plugin_git/git-download.svg +2 -0
- cmem_plugin_git/git-list.svg +2 -0
- cmem_plugin_git/git-upload.svg +2 -0
- cmem_plugin_git/list.py +161 -0
- cmem_plugin_git/parameters.py +180 -0
- cmem_plugin_git/remote.py +774 -0
- cmem_plugin_git/upload.py +261 -0
- cmem_plugin_git-1.0.0.dist-info/METADATA +82 -0
- cmem_plugin_git-1.0.0.dist-info/RECORD +14 -0
- cmem_plugin_git-1.0.0.dist-info/WHEEL +4 -0
- cmem_plugin_git-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""cmem-plugin-git"""
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Parameter types that ask the repository what it holds"""
|
|
2
|
+
|
|
3
|
+
from typing import Any, ClassVar
|
|
4
|
+
|
|
5
|
+
from cmem_plugin_base.dataintegration.context import PluginContext
|
|
6
|
+
from cmem_plugin_base.dataintegration.types import Autocompletion, StringParameterType
|
|
7
|
+
|
|
8
|
+
from cmem_plugin_git.remote import GitRemote
|
|
9
|
+
|
|
10
|
+
ROOT_LABEL = "/ (top of the repository)"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _matching(completions: list[Autocompletion], query_terms: list[str]) -> list[Autocompletion]:
|
|
14
|
+
"""Keep the completions that match every term the user typed."""
|
|
15
|
+
if not query_terms:
|
|
16
|
+
return completions
|
|
17
|
+
return [
|
|
18
|
+
completion
|
|
19
|
+
for completion in completions
|
|
20
|
+
if all(
|
|
21
|
+
term.lower() in (completion.label or completion.value).lower() for term in query_terms
|
|
22
|
+
)
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _remote(values: list[Any]) -> GitRemote | None:
|
|
27
|
+
"""Build a remote from the parameter values an autocompletion depends on."""
|
|
28
|
+
url, username, token = values[0], values[1], values[2]
|
|
29
|
+
if not str(url).strip():
|
|
30
|
+
return None
|
|
31
|
+
password = token if isinstance(token, str) else token.decrypt()
|
|
32
|
+
return GitRemote(url=str(url), token=str(password), username=str(username))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RefParameterType(StringParameterType):
|
|
36
|
+
"""Autocomplete the branches and tags a repository advertises"""
|
|
37
|
+
|
|
38
|
+
allow_only_autocompleted_values: bool = False
|
|
39
|
+
autocomplete_value_with_labels: bool = True
|
|
40
|
+
autocompletion_depends_on_parameters: ClassVar[list[str]] = ["url", "username", "token"]
|
|
41
|
+
|
|
42
|
+
def autocomplete(
|
|
43
|
+
self,
|
|
44
|
+
query_terms: list[str],
|
|
45
|
+
depend_on_parameter_values: list[Any],
|
|
46
|
+
context: PluginContext,
|
|
47
|
+
) -> list[Autocompletion]:
|
|
48
|
+
"""Return the branches and tags matching all query terms."""
|
|
49
|
+
_ = context
|
|
50
|
+
remote = _remote(depend_on_parameter_values)
|
|
51
|
+
if remote is None:
|
|
52
|
+
return []
|
|
53
|
+
completions = [
|
|
54
|
+
Autocompletion(value=ref.name, label=f"{ref.name} ({ref.kind})")
|
|
55
|
+
for ref in remote.list_refs()
|
|
56
|
+
]
|
|
57
|
+
return _matching(completions, query_terms)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FolderParameterType(StringParameterType):
|
|
61
|
+
"""Autocomplete the folders a repository holds at the selected revision"""
|
|
62
|
+
|
|
63
|
+
allow_only_autocompleted_values: bool = False
|
|
64
|
+
autocomplete_value_with_labels: bool = True
|
|
65
|
+
autocompletion_depends_on_parameters: ClassVar[list[str]] = [
|
|
66
|
+
"url",
|
|
67
|
+
"username",
|
|
68
|
+
"token",
|
|
69
|
+
"ref",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
def autocomplete(
|
|
73
|
+
self,
|
|
74
|
+
query_terms: list[str],
|
|
75
|
+
depend_on_parameter_values: list[Any],
|
|
76
|
+
context: PluginContext,
|
|
77
|
+
) -> list[Autocompletion]:
|
|
78
|
+
"""Return the folders matching all query terms."""
|
|
79
|
+
_ = context
|
|
80
|
+
remote = _remote(depend_on_parameter_values)
|
|
81
|
+
if remote is None:
|
|
82
|
+
return []
|
|
83
|
+
snapshot = remote.snapshot(str(depend_on_parameter_values[3]))
|
|
84
|
+
completions = [Autocompletion(value="", label=ROOT_LABEL)]
|
|
85
|
+
completions += [Autocompletion(value=folder, label=folder) for folder in snapshot.folders()]
|
|
86
|
+
return _matching(completions, query_terms)
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Download files from a git repository"""
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from cmem_plugin_base.dataintegration.context import ExecutionContext, ExecutionReport
|
|
9
|
+
from cmem_plugin_base.dataintegration.description import Icon, Plugin, PluginAction
|
|
10
|
+
from cmem_plugin_base.dataintegration.entity import Entities, Entity
|
|
11
|
+
from cmem_plugin_base.dataintegration.parameter.password import Password
|
|
12
|
+
from cmem_plugin_base.dataintegration.plugins import WorkflowPlugin
|
|
13
|
+
from cmem_plugin_base.dataintegration.ports import FixedNumberOfInputs, FixedSchemaPort
|
|
14
|
+
from cmem_plugin_base.dataintegration.typed_entities.file import FileEntitySchema, LocalFile
|
|
15
|
+
|
|
16
|
+
from cmem_plugin_git.parameters import (
|
|
17
|
+
URL_DESCRIPTION,
|
|
18
|
+
connection_parameters,
|
|
19
|
+
list_schema,
|
|
20
|
+
preview_of,
|
|
21
|
+
remote_of,
|
|
22
|
+
selection_parameters,
|
|
23
|
+
)
|
|
24
|
+
from cmem_plugin_git.remote import (
|
|
25
|
+
DEFAULT_USERNAME,
|
|
26
|
+
LFS_POINTER_PREFIX,
|
|
27
|
+
RepositoryFile,
|
|
28
|
+
Selection,
|
|
29
|
+
Snapshot,
|
|
30
|
+
iter_warnings,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
ACCESS_DESCRIPTION = (
|
|
34
|
+
"An access token that may read the repository. Leave it empty to read a public repository."
|
|
35
|
+
)
|
|
36
|
+
BATCH_SIZE = 50
|
|
37
|
+
"""How many files' content is asked for in one request.
|
|
38
|
+
|
|
39
|
+
A server that hands out single objects answers one request per batch rather than
|
|
40
|
+
one per file, and a batch bounds how much content is held in memory at once.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
URL_DESCRIPTION_WITH_INPUT = (
|
|
44
|
+
f"{URL_DESCRIPTION} Leave it empty to take the repository, and the files to read from it, "
|
|
45
|
+
"from the connected input instead - which is how **List Git files** drives this task."
|
|
46
|
+
)
|
|
47
|
+
SELECTION_NOTE = (
|
|
48
|
+
"Ignored when the Repository URL is empty and the input drives file selection instead."
|
|
49
|
+
)
|
|
50
|
+
REF_LABEL = "Revision"
|
|
51
|
+
REF_DESCRIPTION = (
|
|
52
|
+
"The branch, tag or commit id to read. Leave it empty to read the repository's default "
|
|
53
|
+
f"branch. {SELECTION_NOTE}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@Plugin(
|
|
58
|
+
label="Download Git files",
|
|
59
|
+
plugin_id="cmem_plugin_git-Download",
|
|
60
|
+
description="Download files from a git repository and hand them to the next task.",
|
|
61
|
+
documentation="""
|
|
62
|
+
This workflow task reads files from a git repository at one revision and hands them
|
|
63
|
+
to the next task in the workflow. It works on the objects of the repository rather
|
|
64
|
+
than on a checkout, so only the files it selects are transferred.
|
|
65
|
+
|
|
66
|
+
Files leave the task as file entities that any file consuming task can read. They are
|
|
67
|
+
written into a temporary folder of the container the task runs in, and they are not
|
|
68
|
+
deleted afterwards, because the next task reads them only when it gets to them.
|
|
69
|
+
|
|
70
|
+
This task works in one of two mutually exclusive ways, decided by whether a
|
|
71
|
+
Repository URL is configured. Set it, and the task fetches on its own, guided by
|
|
72
|
+
the folder, expression and subfolder settings, with the input closed. Leave it
|
|
73
|
+
empty, and the input opens instead: it supplies both the repository and the
|
|
74
|
+
files to read from it, and the folder and expression settings go unused. This is
|
|
75
|
+
how **List Git files** drives this task after the workflow has filtered its
|
|
76
|
+
output.
|
|
77
|
+
|
|
78
|
+
The repository is reached over HTTP(S), which is the only transport these tasks speak;
|
|
79
|
+
an SSH address is refused. Reading needs a token with read access to the repository and
|
|
80
|
+
no more than that: the `read_repository` scope on GitLab, or a fine grained GitHub token
|
|
81
|
+
granting `Contents: Read-only`. A public repository needs no token at all. The user name sent
|
|
82
|
+
alongside the token matters on GitLab and not on GitHub, which is why its default is
|
|
83
|
+
the value GitLab accepts.
|
|
84
|
+
|
|
85
|
+
Submodules and files stored with Git LFS are reported as skipped rather than
|
|
86
|
+
downloaded, because the repository holds no content for them, only a reference. A
|
|
87
|
+
symbolic link is delivered under its own name with the content of the file it points
|
|
88
|
+
at; one that points outside the repository is skipped. Reading a commit id rather
|
|
89
|
+
than a branch or a tag works only where the server allows fetching an object it does
|
|
90
|
+
not advertise.
|
|
91
|
+
""",
|
|
92
|
+
icon=Icon(package=__package__, file_name="git-download.svg"),
|
|
93
|
+
actions=[
|
|
94
|
+
PluginAction(
|
|
95
|
+
name="preview",
|
|
96
|
+
label="Preview results (max. 10)",
|
|
97
|
+
description="List the first files this configuration selects.",
|
|
98
|
+
)
|
|
99
|
+
],
|
|
100
|
+
parameters=[
|
|
101
|
+
*connection_parameters(
|
|
102
|
+
REF_LABEL,
|
|
103
|
+
REF_DESCRIPTION,
|
|
104
|
+
ACCESS_DESCRIPTION,
|
|
105
|
+
url_description=URL_DESCRIPTION_WITH_INPUT,
|
|
106
|
+
url_optional=True,
|
|
107
|
+
),
|
|
108
|
+
*selection_parameters(note=SELECTION_NOTE),
|
|
109
|
+
],
|
|
110
|
+
)
|
|
111
|
+
class DownloadGitFiles(WorkflowPlugin):
|
|
112
|
+
"""Git Workflow Plugin: download files"""
|
|
113
|
+
|
|
114
|
+
# A plugin constructor takes one argument per PluginParameter, so its arity is
|
|
115
|
+
# fixed by the plugin's configuration surface, not by a style choice here.
|
|
116
|
+
def __init__( # noqa: PLR0913, PLR0917
|
|
117
|
+
self,
|
|
118
|
+
url: str = "",
|
|
119
|
+
token: str | Password = "",
|
|
120
|
+
ref: str = "",
|
|
121
|
+
username: str = DEFAULT_USERNAME,
|
|
122
|
+
path: str = "",
|
|
123
|
+
regex: str = "",
|
|
124
|
+
no_subfolder: bool = False,
|
|
125
|
+
) -> None:
|
|
126
|
+
self.url = url
|
|
127
|
+
self.token = token
|
|
128
|
+
self.ref = ref
|
|
129
|
+
self.username = username
|
|
130
|
+
self.path = path
|
|
131
|
+
self.regex = regex
|
|
132
|
+
self.no_subfolder = no_subfolder
|
|
133
|
+
# A Repository URL configured here means this task fetches on its own, so the
|
|
134
|
+
# input is closed; leaving it empty means the repository and the files both
|
|
135
|
+
# come from the input, so the input is required instead.
|
|
136
|
+
self.input_ports = (
|
|
137
|
+
FixedNumberOfInputs([])
|
|
138
|
+
if self.url.strip()
|
|
139
|
+
else FixedNumberOfInputs([FixedSchemaPort(schema=list_schema())])
|
|
140
|
+
)
|
|
141
|
+
self.output_port = FixedSchemaPort(schema=FileEntitySchema())
|
|
142
|
+
|
|
143
|
+
def _snapshot(self) -> Snapshot:
|
|
144
|
+
"""Fetch the revision's directory listing."""
|
|
145
|
+
return remote_of(self.url, self.token, self.username).snapshot(self.ref)
|
|
146
|
+
|
|
147
|
+
def _selection(self, snapshot: Snapshot) -> Selection:
|
|
148
|
+
"""Decide which files to read, from the folder, expression and subfolder parameters."""
|
|
149
|
+
return snapshot.select(path=self.path, regex=self.regex, no_subfolder=self.no_subfolder)
|
|
150
|
+
|
|
151
|
+
def _from_input(
|
|
152
|
+
self, inputs: Sequence[Entities]
|
|
153
|
+
) -> tuple[Selection, Callable[[list[str]], dict[str, bytes]]]:
|
|
154
|
+
"""Build the selection and a blob reader from what a connected task sent
|
|
155
|
+
|
|
156
|
+
Used when this task's own Repository URL is left empty: the files to fetch,
|
|
157
|
+
and the repository to fetch them from, both come from the input instead of
|
|
158
|
+
from this task's own parameters. Fetching each file by the blob id it
|
|
159
|
+
already carries, rather than re-resolving a revision, means there is no
|
|
160
|
+
window in which the branch could have moved between the two tasks.
|
|
161
|
+
"""
|
|
162
|
+
if not inputs:
|
|
163
|
+
raise ValueError(
|
|
164
|
+
"No Repository URL is configured and nothing is connected to the input, "
|
|
165
|
+
"so there is nothing to read."
|
|
166
|
+
)
|
|
167
|
+
entities = list(inputs[0].entities)
|
|
168
|
+
if not entities:
|
|
169
|
+
return Selection(), lambda _blob_ids: {}
|
|
170
|
+
url = str(entities[0].values[-1][0])
|
|
171
|
+
remote = remote_of(url, self.token, self.username)
|
|
172
|
+
files = [_file_from_entity(entity) for entity in entities]
|
|
173
|
+
return Selection(files=files), remote.fetch_blobs
|
|
174
|
+
|
|
175
|
+
def preview(self) -> str:
|
|
176
|
+
"""Show what this configuration selects."""
|
|
177
|
+
if not self.url.strip():
|
|
178
|
+
return (
|
|
179
|
+
"This configuration takes its repository and file selection from the "
|
|
180
|
+
"connected input, so it cannot be previewed before the workflow runs."
|
|
181
|
+
)
|
|
182
|
+
selection = self._selection(self._snapshot())
|
|
183
|
+
return preview_of([file.path for file in selection.files], selection.warnings)
|
|
184
|
+
|
|
185
|
+
def execute(self, inputs: Sequence[Entities], context: ExecutionContext) -> Entities:
|
|
186
|
+
"""Run the workflow task."""
|
|
187
|
+
read: Callable[[list[str]], dict[str, bytes]]
|
|
188
|
+
if self.url.strip():
|
|
189
|
+
snapshot = self._snapshot()
|
|
190
|
+
selection = self._selection(snapshot)
|
|
191
|
+
read = snapshot.read
|
|
192
|
+
self.log.info(f"Downloading {len(selection.files)} file(s) from {self.url}")
|
|
193
|
+
else:
|
|
194
|
+
selection, read = self._from_input(inputs)
|
|
195
|
+
self.log.info(f"Downloading {len(selection.files)} file(s) from the connected input")
|
|
196
|
+
directory = Path(tempfile.mkdtemp(prefix="cmem-plugin-git-"))
|
|
197
|
+
files = []
|
|
198
|
+
cancelled = False
|
|
199
|
+
for batch in _batched(selection.files, BATCH_SIZE):
|
|
200
|
+
if cancelled:
|
|
201
|
+
break
|
|
202
|
+
contents = read([file.blob_id for file in batch])
|
|
203
|
+
for file in batch:
|
|
204
|
+
with suppress(AttributeError):
|
|
205
|
+
if context.workflow.status() == "Canceling": # type: ignore[union-attr]
|
|
206
|
+
cancelled = True
|
|
207
|
+
break
|
|
208
|
+
content = contents[file.blob_id]
|
|
209
|
+
if content.startswith(LFS_POINTER_PREFIX):
|
|
210
|
+
selection.warnings.append(
|
|
211
|
+
f"'{file.path}' holds a Git LFS pointer instead of content and was skipped."
|
|
212
|
+
)
|
|
213
|
+
continue
|
|
214
|
+
files.append(_write(directory, file, content))
|
|
215
|
+
context.report.update(
|
|
216
|
+
ExecutionReport(
|
|
217
|
+
entity_count=len(files),
|
|
218
|
+
operation="read",
|
|
219
|
+
operation_desc="files downloaded",
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
if cancelled:
|
|
223
|
+
selection.warnings.append(
|
|
224
|
+
"The workflow was cancelled, so only part of the selection was downloaded."
|
|
225
|
+
)
|
|
226
|
+
schema = FileEntitySchema()
|
|
227
|
+
entities = [schema.to_entity(file) for file in files]
|
|
228
|
+
context.report.update(
|
|
229
|
+
ExecutionReport(
|
|
230
|
+
entity_count=len(entities),
|
|
231
|
+
operation="read",
|
|
232
|
+
operation_desc="files downloaded",
|
|
233
|
+
warnings=list(iter_warnings(selection.warnings)),
|
|
234
|
+
sample_entities=Entities(entities=iter(entities[:10]), schema=schema),
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
return Entities(entities=iter(entities), schema=schema)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _file_from_entity(entity: Entity) -> RepositoryFile:
|
|
241
|
+
"""Rebuild the file a list task described, from its entity
|
|
242
|
+
|
|
243
|
+
The inverse of list.py's ``_entity()``: values are read positionally, in the
|
|
244
|
+
order ``list_schema()`` declares them. The URL carried alongside is read by
|
|
245
|
+
the caller instead, since it names the repository rather than one file of it.
|
|
246
|
+
"""
|
|
247
|
+
path, name, size, mode, blob_id, commit_id, _url = entity.values
|
|
248
|
+
return RepositoryFile(
|
|
249
|
+
path=path[0],
|
|
250
|
+
name=name[0],
|
|
251
|
+
size=int(size[0]) if size else None,
|
|
252
|
+
mode=mode[0],
|
|
253
|
+
blob_id=blob_id[0],
|
|
254
|
+
commit_id=commit_id[0],
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _batched(files: list[RepositoryFile], size: int) -> Iterator[list[RepositoryFile]]:
|
|
259
|
+
"""Cut the selection into groups that are fetched together."""
|
|
260
|
+
for start in range(0, len(files), size):
|
|
261
|
+
yield files[start : start + size]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _write(directory: Path, file: RepositoryFile, content: bytes) -> LocalFile:
|
|
265
|
+
"""Write one downloaded file into the task's temporary folder."""
|
|
266
|
+
target = directory / file.name
|
|
267
|
+
counter = 0
|
|
268
|
+
while target.exists():
|
|
269
|
+
counter += 1
|
|
270
|
+
target = directory / f"{target.stem}_{counter}{target.suffix}"
|
|
271
|
+
target.write_bytes(content)
|
|
272
|
+
return LocalFile(str(target))
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
2
|
+
<svg focusable="false" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="32" height="32" viewBox="0 0 32 32" aria-hidden="true" class="eccgui-icon"><circle cx="8" cy="6" r="3"></circle><circle cx="8" cy="26" r="3"></circle><rect x="7" y="6" width="2" height="20"></rect><rect x="8" y="15" width="7" height="2"></rect><circle cx="17" cy="16" r="3"></circle><rect x="26" y="6" width="2" height="13"></rect><path d="M22 17h10l-5 8z"></path></svg>
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
2
|
+
<svg focusable="false" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="32" height="32" viewBox="0 0 32 32" aria-hidden="true" class="eccgui-icon"><circle cx="8" cy="6" r="3"></circle><circle cx="8" cy="26" r="3"></circle><rect x="7" y="6" width="2" height="20"></rect><rect x="8" y="15" width="7" height="2"></rect><circle cx="17" cy="16" r="3"></circle><rect x="23" y="6" width="8" height="2"></rect><rect x="23" y="15" width="8" height="2"></rect><rect x="23" y="24" width="8" height="2"></rect></svg>
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
2
|
+
<svg focusable="false" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="32" height="32" viewBox="0 0 32 32" aria-hidden="true" class="eccgui-icon"><circle cx="8" cy="6" r="3"></circle><circle cx="8" cy="26" r="3"></circle><rect x="7" y="6" width="2" height="20"></rect><rect x="8" y="15" width="7" height="2"></rect><circle cx="17" cy="16" r="3"></circle><rect x="26" y="13" width="2" height="13"></rect><path d="M27 6l5 8H22z"></path></svg>
|
cmem_plugin_git/list.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""List the files of a git repository"""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
from cmem_plugin_base.dataintegration.context import ExecutionContext, ExecutionReport
|
|
6
|
+
from cmem_plugin_base.dataintegration.description import Icon, Plugin, PluginAction
|
|
7
|
+
from cmem_plugin_base.dataintegration.entity import Entities, Entity
|
|
8
|
+
from cmem_plugin_base.dataintegration.parameter.password import Password
|
|
9
|
+
from cmem_plugin_base.dataintegration.plugins import WorkflowPlugin
|
|
10
|
+
from cmem_plugin_base.dataintegration.ports import (
|
|
11
|
+
FixedNumberOfInputs,
|
|
12
|
+
FixedSchemaPort,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from cmem_plugin_git.parameters import (
|
|
16
|
+
connection_parameters,
|
|
17
|
+
list_schema,
|
|
18
|
+
preview_of,
|
|
19
|
+
remote_of,
|
|
20
|
+
selection_parameters,
|
|
21
|
+
)
|
|
22
|
+
from cmem_plugin_git.remote import DEFAULT_USERNAME, RepositoryFile, Selection, iter_warnings
|
|
23
|
+
|
|
24
|
+
ACCESS_DESCRIPTION = (
|
|
25
|
+
"An access token that may read the repository. Leave it empty to read a public repository."
|
|
26
|
+
)
|
|
27
|
+
REF_LABEL = "Revision"
|
|
28
|
+
REF_DESCRIPTION = (
|
|
29
|
+
"The branch, tag or commit id to read. Leave it empty to read the repository's default branch."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@Plugin(
|
|
34
|
+
label="List Git files",
|
|
35
|
+
plugin_id="cmem_plugin_git-List",
|
|
36
|
+
description="List the files of a git repository, without transferring their content.",
|
|
37
|
+
documentation="""
|
|
38
|
+
This workflow task lists the files a git repository holds at one revision. It reads
|
|
39
|
+
the repository over HTTP(S) and transfers the directory listing only, never the
|
|
40
|
+
content of a file.
|
|
41
|
+
|
|
42
|
+
Each file leaves the task as one entity with its repository path, its name, its size
|
|
43
|
+
in bytes, its git file mode, the id of its content, the id of the commit that was
|
|
44
|
+
read, and this task's own Repository URL. The task takes no input.
|
|
45
|
+
|
|
46
|
+
The size is the one value that may be missing. A git tree records a name, a mode and
|
|
47
|
+
an object id, but not a length, so a file's size is only known once its content has
|
|
48
|
+
been transferred. Against a server that supports partial fetching - GitHub and GitLab
|
|
49
|
+
both do - this task deliberately does not transfer content, and the size stays empty;
|
|
50
|
+
against a server that does not, the content arrives anyway and the size is filled in.
|
|
51
|
+
|
|
52
|
+
It is usually the first task of a chain: filter its output in the workflow, then
|
|
53
|
+
connect it to **Download Git files**, which then fetches exactly the files that
|
|
54
|
+
survived the filtering. Leave Download's own Repository URL empty for this -
|
|
55
|
+
it then reads the URL from this task's output as well, rather than needing it
|
|
56
|
+
configured a second time.
|
|
57
|
+
|
|
58
|
+
The repository is reached over HTTP(S), which is the only transport these tasks speak;
|
|
59
|
+
an SSH address is refused. Reading needs a token with read access to the repository and
|
|
60
|
+
no more than that: the `read_repository` scope on GitLab, or a fine grained GitHub token
|
|
61
|
+
granting `Contents: Read-only`. A public repository needs no token at all. The user name sent
|
|
62
|
+
alongside the token matters on GitLab and not on GitHub, which is why its default is
|
|
63
|
+
the value GitLab accepts.
|
|
64
|
+
|
|
65
|
+
Submodules and files stored with Git LFS are reported as skipped rather than listed,
|
|
66
|
+
because neither holds content the repository can hand out. A symbolic link is listed
|
|
67
|
+
under its own name, carrying the size and the content id of the file it points at;
|
|
68
|
+
one that points outside the repository is skipped. Reading a commit id rather than a
|
|
69
|
+
branch or a tag works only where the server allows fetching an object it does not
|
|
70
|
+
advertise.
|
|
71
|
+
|
|
72
|
+
The task reads one revision and nothing around it. It answers no question about
|
|
73
|
+
history: when a file last changed, who changed it, or how two revisions differ are
|
|
74
|
+
all outside what these tasks do, because none of it can be had without fetching the
|
|
75
|
+
history the design deliberately leaves on the server.
|
|
76
|
+
""",
|
|
77
|
+
icon=Icon(package=__package__, file_name="git-list.svg"),
|
|
78
|
+
actions=[
|
|
79
|
+
PluginAction(
|
|
80
|
+
name="preview",
|
|
81
|
+
label="Preview results (max. 10)",
|
|
82
|
+
description="List the first files this configuration selects.",
|
|
83
|
+
)
|
|
84
|
+
],
|
|
85
|
+
parameters=[
|
|
86
|
+
*connection_parameters(REF_LABEL, REF_DESCRIPTION, ACCESS_DESCRIPTION),
|
|
87
|
+
*selection_parameters(),
|
|
88
|
+
],
|
|
89
|
+
)
|
|
90
|
+
class ListGitFiles(WorkflowPlugin):
|
|
91
|
+
"""Git Workflow Plugin: list files"""
|
|
92
|
+
|
|
93
|
+
# A plugin constructor takes one argument per PluginParameter, so its arity is
|
|
94
|
+
# fixed by the plugin's configuration surface, not by a style choice here.
|
|
95
|
+
def __init__( # noqa: PLR0913, PLR0917
|
|
96
|
+
self,
|
|
97
|
+
url: str,
|
|
98
|
+
token: str | Password = "",
|
|
99
|
+
ref: str = "",
|
|
100
|
+
username: str = DEFAULT_USERNAME,
|
|
101
|
+
path: str = "",
|
|
102
|
+
regex: str = "",
|
|
103
|
+
no_subfolder: bool = False,
|
|
104
|
+
) -> None:
|
|
105
|
+
self.url = url
|
|
106
|
+
self.token = token
|
|
107
|
+
self.ref = ref
|
|
108
|
+
self.username = username
|
|
109
|
+
self.path = path
|
|
110
|
+
self.regex = regex
|
|
111
|
+
self.no_subfolder = no_subfolder
|
|
112
|
+
self.input_ports = FixedNumberOfInputs([])
|
|
113
|
+
self.output_port = FixedSchemaPort(schema=list_schema())
|
|
114
|
+
|
|
115
|
+
def _select(self) -> Selection:
|
|
116
|
+
"""Fetch the revision's directory listing and apply the selection."""
|
|
117
|
+
remote = remote_of(self.url, self.token, self.username)
|
|
118
|
+
snapshot = remote.snapshot(self.ref)
|
|
119
|
+
return snapshot.select(path=self.path, regex=self.regex, no_subfolder=self.no_subfolder)
|
|
120
|
+
|
|
121
|
+
def preview(self) -> str:
|
|
122
|
+
"""Show what this configuration selects."""
|
|
123
|
+
selection = self._select()
|
|
124
|
+
return preview_of([file.path for file in selection.files], selection.warnings)
|
|
125
|
+
|
|
126
|
+
def execute(self, inputs: Sequence[Entities], context: ExecutionContext) -> Entities:
|
|
127
|
+
"""Run the workflow task."""
|
|
128
|
+
_ = inputs
|
|
129
|
+
self.log.info(f"Listing files of {self.url}")
|
|
130
|
+
selection = self._select()
|
|
131
|
+
entities = [_entity(file, self.url) for file in selection.files]
|
|
132
|
+
context.report.update(
|
|
133
|
+
ExecutionReport(
|
|
134
|
+
entity_count=len(entities),
|
|
135
|
+
operation="read",
|
|
136
|
+
operation_desc="files listed",
|
|
137
|
+
warnings=list(iter_warnings(selection.warnings)),
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
return Entities(entities=iter(entities), schema=list_schema())
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _entity(file: RepositoryFile, url: str) -> Entity:
|
|
144
|
+
"""Turn one listed file into an entity.
|
|
145
|
+
|
|
146
|
+
``url`` is this task's own repository URL, carried along so that
|
|
147
|
+
**Download Git files** can read the repository it is being asked to fetch
|
|
148
|
+
from without needing the same URL configured a second time.
|
|
149
|
+
"""
|
|
150
|
+
return Entity(
|
|
151
|
+
uri=f"urn:git:{file.commit_id}:{file.path}",
|
|
152
|
+
values=[
|
|
153
|
+
[file.path],
|
|
154
|
+
[file.name],
|
|
155
|
+
[] if file.size is None else [str(file.size)],
|
|
156
|
+
[file.mode],
|
|
157
|
+
[file.blob_id],
|
|
158
|
+
[file.commit_id],
|
|
159
|
+
[url],
|
|
160
|
+
],
|
|
161
|
+
)
|