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