mango-lib 0.1.0__tar.gz → 0.2.0__tar.gz

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,60 @@
1
+ Metadata-Version: 2.3
2
+ Name: mango-lib
3
+ Version: 0.2.0
4
+ Summary: Library of utility functions used across ManGO Portal, ManGO Flow and ManGO Ingest amongst others
5
+ Author: Paul Borgermans, Mariana Montes
6
+ Author-email: Paul Borgermans <paul.borgermans@kuleuven.be>, Mariana Montes <mariana.montes@kuleuven.be>
7
+ Requires-Dist: click>=8.1
8
+ Requires-Dist: humanize>=4.15.0
9
+ Requires-Dist: jinja2>=3.1.6
10
+ Requires-Dist: mango-mdconverter>=0.0.10
11
+ Requires-Dist: nh3>=0.3.5
12
+ Requires-Dist: python-irodsclient>=3.2.0
13
+ Requires-Dist: rich>=15.0.0
14
+ Requires-Dist: mango-mdconverter>=0.0.10 ; extra == 'mango-mdconverter'
15
+ Requires-Python: >=3.12
16
+ Provides-Extra: mango-mdconverter
17
+ Description-Content-Type: text/markdown
18
+
19
+ # ManGO Lib
20
+
21
+ This package provides common or useful tools that are in part extracted from the ManGO family of applications and frameworks developed and used by KULeuven ICTS-FOZ-RDM team.
22
+
23
+ > [!WARNING]
24
+ > ManGO Lib is currently under heavy development as it is the cornerstone for the ongoing ManGO platform refactoring
25
+ > Therefore there is no PyPi installable package (yet)
26
+
27
+ ## Documentation
28
+
29
+ The documentation is curated in a dedicated repository covering the entire ManGO family and is located at:
30
+
31
+ ...
32
+
33
+ ## Installation
34
+
35
+ ### With `uv`
36
+
37
+ ```bash
38
+ uv add mango-lib
39
+ ```
40
+
41
+ ### With `pip`
42
+
43
+ ```bash
44
+ pip install mango-lib
45
+ ```
46
+
47
+ ## Running the unit tests
48
+
49
+ Requirements: `uv` and `pytest`
50
+
51
+ - Checkout the repository locally
52
+ - From the top level run:
53
+
54
+ ```bash
55
+ uv run pytest -v -s
56
+ ```
57
+
58
+
59
+
60
+
@@ -0,0 +1,42 @@
1
+ # ManGO Lib
2
+
3
+ This package provides common or useful tools that are in part extracted from the ManGO family of applications and frameworks developed and used by KULeuven ICTS-FOZ-RDM team.
4
+
5
+ > [!WARNING]
6
+ > ManGO Lib is currently under heavy development as it is the cornerstone for the ongoing ManGO platform refactoring
7
+ > Therefore there is no PyPi installable package (yet)
8
+
9
+ ## Documentation
10
+
11
+ The documentation is curated in a dedicated repository covering the entire ManGO family and is located at:
12
+
13
+ ...
14
+
15
+ ## Installation
16
+
17
+ ### With `uv`
18
+
19
+ ```bash
20
+ uv add mango-lib
21
+ ```
22
+
23
+ ### With `pip`
24
+
25
+ ```bash
26
+ pip install mango-lib
27
+ ```
28
+
29
+ ## Running the unit tests
30
+
31
+ Requirements: `uv` and `pytest`
32
+
33
+ - Checkout the repository locally
34
+ - From the top level run:
35
+
36
+ ```bash
37
+ uv run pytest -v -s
38
+ ```
39
+
40
+
41
+
42
+
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "mango-lib"
3
+ version = "0.2.0"
4
+ description = "Library of utility functions used across ManGO Portal, ManGO Flow and ManGO Ingest amongst others"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "click>=8.1",
9
+ "humanize>=4.15.0",
10
+ "jinja2>=3.1.6",
11
+ "mango-mdconverter>=0.0.10",
12
+ "nh3>=0.3.5",
13
+ "python-irodsclient>=3.2.0",
14
+ "rich>=15.0.0",
15
+ ]
16
+
17
+ [[project.authors]]
18
+ name = "Paul Borgermans"
19
+ email = "paul.borgermans@kuleuven.be"
20
+
21
+ [[project.authors]]
22
+ name = "Mariana Montes"
23
+ email = "mariana.montes@kuleuven.be"
24
+
25
+ [project.scripts]
26
+ mango = "mango_lib.cli:mango"
27
+
28
+ [project.optional-dependencies]
29
+ mango-mdconverter = ["mango-mdconverter>=0.0.10"]
30
+
31
+ [build-system]
32
+ requires = ["uv_build>=0.11,<0.12.0"]
33
+ build-backend = "uv_build"
34
+
35
+ [tool.pytest.ini_options]
36
+ pythonpath = ["src"]
37
+
38
+ [dependency-groups]
39
+ dev = [
40
+ "pytest>=9.0.3,<9.1.0",
41
+ "pytest-cases>=3.10.1",
42
+ ]
@@ -0,0 +1,42 @@
1
+
2
+ [project]
3
+ name = "mango-lib"
4
+ version = "0.2.0"
5
+ description = "Library of utility functions used across ManGO Portal, ManGO Flow and ManGO Ingest amongst others"
6
+ readme = "README.md"
7
+ authors = [
8
+ { name = "Paul Borgermans", email = "paul.borgermans@kuleuven.be" },
9
+ { name = "Mariana Montes", email = "mariana.montes@kuleuven.be"}
10
+ ]
11
+ requires-python = ">=3.12"
12
+ dependencies = [
13
+ "click>=8.1",
14
+ "humanize>=4.15.0",
15
+ "jinja2>=3.1.6",
16
+ "mango-mdconverter>=0.0.10",
17
+ "nh3>=0.3.5",
18
+ "python-irodsclient>=3.2.0",
19
+ "rich>=15.0.0",
20
+ ]
21
+
22
+ [project.scripts]
23
+ mango = "mango_lib.cli:mango"
24
+
25
+ [project.optional-dependencies]
26
+ mango-mdconverter = [
27
+ "mango-mdconverter>=0.0.10",
28
+ ]
29
+
30
+
31
+ [build-system]
32
+ requires = ["uv_build>=0.11,<0.12.0"]
33
+ build-backend = "uv_build"
34
+
35
+ [tool.pytest.ini_options]
36
+ pythonpath = ["src"]
37
+
38
+ [dependency-groups]
39
+ dev = [
40
+ "pytest>=9.0.3,<9.1.0",
41
+ "pytest-cases>=3.10.1",
42
+ ]
@@ -0,0 +1,4 @@
1
+ from mango_lib.cli import mango
2
+ from mango_lib.irods.auth import get_zone_operator_session
3
+
4
+ __all__ = ["get_zone_operator_session", "mango"]
@@ -0,0 +1,344 @@
1
+ """Attempt at isolating the packaging function in a module with everything it needs
2
+ to run from local/iRODS to iRODS, with metadata if the source is iRODS,
3
+ WITHOUT celery or steps or any of that.
4
+ This should be added to the orchestrator before providing the orchestrator to the function.
5
+ The only compulsory arguments of the packaging function are
6
+ - an iterator of files
7
+ - a buffered writer to tar things into
8
+ - a base path to compute the relative path
9
+ """
10
+
11
+ import functools
12
+ import hashlib
13
+ import io
14
+ import json
15
+ import pathlib
16
+ import tarfile
17
+ from collections.abc import Callable, Generator, Iterable
18
+
19
+ from irods.collection import iRODSCollection
20
+ from irods.data_object import iRODSDataObject
21
+ from irods.session import iRODSSession
22
+ from mango_mdconverter.md2dict import convert_metadata_to_dict
23
+
24
+ from mango_lib.archive import mango_tar
25
+
26
+ MANIFEST_PREFIX = "data"
27
+ MANIFEST_NAME = "manifest-sha256.txt"
28
+ LOCAL_FOLDER = pathlib.Path(".")
29
+
30
+ MANIFEST_PATH = LOCAL_FOLDER / MANIFEST_NAME
31
+
32
+
33
+ def tar_prefix(dataset):
34
+ return f"{dataset}/bag/data"
35
+
36
+
37
+ def add_irods_metadata_to_tar(
38
+ orchestrator: mango_tar.TarOrchestrator,
39
+ item: mango_tar.TarInputItem,
40
+ alt_tar: io.BufferedWriter | None = None,
41
+ ):
42
+ if not isinstance(item, mango_tar.iRODSInputItem):
43
+ return
44
+ metadata = convert_metadata_to_dict(item.item.metadata.items())
45
+
46
+ if len(metadata) == 0:
47
+ # skip further processing
48
+ print(f"NO metadata for {item.name}")
49
+
50
+ return
51
+
52
+ bytes_buff = json.dumps(metadata).encode()
53
+ metadata_base_path = (
54
+ item.item.path
55
+ if isinstance(item.item, iRODSDataObject)
56
+ else str(pathlib.Path(item.item.path) / item.item.name)
57
+ )
58
+ tar_input_item = mango_tar.BytesInputItem(
59
+ bytes_buff,
60
+ needs_checksum=True,
61
+ path=f"{metadata_base_path}.metadata.json",
62
+ prefix=item.prefix,
63
+ alt_prefix=item.alt_prefix,
64
+ rel_path=item.rel_path,
65
+ )
66
+ mango_tar.record_checksum(orchestrator, tar_input_item)
67
+ mango_tar.add_bytes_item_to_tar(orchestrator.dest_tar, tar_input_item)
68
+
69
+ if alt_tar:
70
+ mango_tar.add_bytes_item_to_tar(alt_tar, tar_input_item)
71
+
72
+
73
+ def bag_tar(
74
+ orchestrator: mango_tar.TarOrchestrator,
75
+ dataset_name: str,
76
+ manifest_path: pathlib.Path = MANIFEST_PATH,
77
+ rel_path: str = str(LOCAL_FOLDER),
78
+ ):
79
+ manifest_tar_input = mango_tar.FileInputItem(
80
+ manifest_path, rel_path=rel_path, prefix=f"{dataset_name}/bag"
81
+ )
82
+ with manifest_path.open("rb") as manifest_fp:
83
+ mango_tar.stream_fp_to_tar(
84
+ manifest_fp,
85
+ input_item=manifest_tar_input,
86
+ tar_dest=orchestrator.dest_tar,
87
+ read_buffer_size=1048576,
88
+ orchestrator=orchestrator,
89
+ )
90
+ orchestrator.at_current_file_end(
91
+ item=manifest_tar_input, dest_tar=orchestrator.dest_tar, callbacks=None
92
+ )
93
+ bagit_contents = "BagIt version 0.97\nTag-File-Character-Encoding: UTF-8\n".encode()
94
+ bagit_item = mango_tar.BytesInputItem(
95
+ bagit_contents, needs_checksum=False, path=f"{dataset_name}/bag/bagit.txt"
96
+ )
97
+ mango_tar.add_bytes_item_to_tar(orchestrator.dest_tar, bagit_item)
98
+ orchestrator.at_current_file_end(
99
+ item=bagit_item, dest_tar=orchestrator.dest_tar, callbacks=None
100
+ )
101
+
102
+
103
+ def packaging_orchestrator(
104
+ orchestrator: mango_tar.TarOrchestrator = None,
105
+ manifest_path: pathlib.Path = MANIFEST_PATH,
106
+ dataset_name: str = "",
107
+ metadata_tar: io.BufferedWriter | None = None,
108
+ ):
109
+
110
+ if not isinstance(orchestrator, mango_tar.TarOrchestrator):
111
+ orchestrator = mango_tar.TarOrchestrator() # initializing!
112
+
113
+ # CHECKSUMS FOR MANIFEST
114
+ orchestrator.add_callback("file_end", "checksum-save", mango_tar.record_checksum)
115
+
116
+ flush_checksums = functools.partial(
117
+ mango_tar.flush_checksums,
118
+ manifest_path=manifest_path,
119
+ format="bagit",
120
+ name_field="alt_name",
121
+ )
122
+ orchestrator.add_callback("delay_point", "update-manifest", flush_checksums)
123
+
124
+ orchestrator.add_callback(
125
+ "end",
126
+ "finish-manifest",
127
+ flush_checksums,
128
+ )
129
+
130
+ orchestrator.add_callback(
131
+ "end",
132
+ "make-bagit",
133
+ functools.partial(
134
+ bag_tar,
135
+ dataset_name=dataset_name,
136
+ manifest_path=manifest_path,
137
+ rel_path=str(manifest_path.parent),
138
+ ),
139
+ )
140
+
141
+ # add metadata
142
+ add_metadata_partial = functools.partial(
143
+ add_irods_metadata_to_tar,
144
+ alt_tar=metadata_tar,
145
+ )
146
+ orchestrator.add_callback("file_end", "add_metadata", add_metadata_partial)
147
+ orchestrator.add_callback(
148
+ "collection_item", "add_collection_metadata", add_metadata_partial
149
+ )
150
+ orchestrator.add_callback("abort", "flush-checksums", flush_checksums)
151
+ orchestrator.add_callback("exception", "flush-checksums", flush_checksums)
152
+
153
+ return orchestrator
154
+
155
+
156
+ def parse_file_iterator(
157
+ file_iterator: Iterable, rel_path: str, dataset_name: str
158
+ ) -> Generator[mango_tar.TarInputItem, None, None]:
159
+ for file in file_iterator:
160
+ if isinstance(file, mango_tar.TarInputItem):
161
+ yield file
162
+ elif isinstance(file, iRODSDataObject):
163
+ class_name = mango_tar.iRODSInputItem
164
+ elif isinstance(file, pathlib.Path):
165
+ class_name = mango_tar.FileInputItem
166
+ else:
167
+ raise TypeError(f"File type not supported for: {file}")
168
+ yield class_name(
169
+ file,
170
+ needs_checksum=True,
171
+ rel_path=rel_path,
172
+ prefix=tar_prefix(dataset_name),
173
+ alt_prefix=MANIFEST_PREFIX,
174
+ )
175
+
176
+
177
+ def parse_folder_iterator(
178
+ folder_iterator: Iterable, rel_path: str, dataset_name: str
179
+ ) -> Generator[mango_tar.iRODSInputItem, None, None]:
180
+ # if isinstance(folder_iterator, iRODSTarInputJSONLReader):
181
+ # return folder_iterator.get_next_object()
182
+ for folder in folder_iterator:
183
+ if not isinstance(folder, iRODSCollection):
184
+ raise TypeError("Item must be an iRODS Collection")
185
+ yield mango_tar.iRODSInputItem(
186
+ folder,
187
+ rel_path=rel_path,
188
+ prefix=tar_prefix(dataset_name),
189
+ alt_prefix=MANIFEST_PREFIX,
190
+ )
191
+
192
+
193
+ def package_dataset(
194
+ file_iterator: Iterable,
195
+ dest_tar_object: iRODSDataObject | pathlib.Path,
196
+ base_path: str, # to compute relative paths
197
+ dataset_name: str = "dummy_dataset",
198
+ folder_iterator: Iterable | None = None,
199
+ orchestrator: mango_tar.TarOrchestrator | None = None,
200
+ local_folder: pathlib.Path = LOCAL_FOLDER,
201
+ manifest_name: str = MANIFEST_NAME,
202
+ add_metadata_tar: bool = False,
203
+ last_good_write: dict | None = None,
204
+ # rocrate_source=None,
205
+ return_orchestrator: bool = False,
206
+ ) -> tuple:
207
+ manifest_path = local_folder / manifest_name
208
+ with manifest_path.open("w"):
209
+ pass
210
+
211
+ files = parse_file_iterator(file_iterator, base_path, dataset_name)
212
+ collections = parse_folder_iterator(folder_iterator, base_path, dataset_name)
213
+
214
+ # metadata tar
215
+ if add_metadata_tar:
216
+ alt_metadata_tar_path = local_folder / f"{dataset_name}_metadata.tar"
217
+ alt_metadata_tar = alt_metadata_tar_path.open("wb")
218
+ else:
219
+ alt_metadata_tar = None
220
+
221
+ # set up orchestrator, from scratch if none is provided
222
+ orchestrator = packaging_orchestrator(
223
+ orchestrator=orchestrator,
224
+ manifest_path=manifest_path,
225
+ dataset_name=dataset_name,
226
+ metadata_tar=alt_metadata_tar,
227
+ )
228
+
229
+ restart_output = mango_tar.restartable_tar(
230
+ object_iterator=files,
231
+ collection_iterator=collections,
232
+ dest_tar_object=dest_tar_object,
233
+ orchestrator=orchestrator,
234
+ last_good_write=last_good_write,
235
+ local_folder=local_folder,
236
+ )
237
+ if alt_metadata_tar is not None:
238
+ alt_metadata_tar.close()
239
+ return (
240
+ (restart_output, orchestrator)
241
+ if return_orchestrator
242
+ else (restart_output, None)
243
+ )
244
+
245
+
246
+ def compute_sha256(
247
+ fp,
248
+ chunk_size: int = 16 * 1024 * 1024,
249
+ stream_callback: Callable | None = None,
250
+ ):
251
+ """Compute sha256 checksum with a filepointer as input"""
252
+ hash_sha256 = hashlib.sha256()
253
+ for chunk in iter(lambda: fp.read(chunk_size), b""):
254
+ hash_sha256.update(chunk)
255
+ if stream_callback:
256
+ stream_callback(len(chunk))
257
+ return hash_sha256.hexdigest()
258
+
259
+
260
+ def compute_checksums_from_irods_tar(
261
+ irods_session: iRODSSession,
262
+ path_to_tar: str,
263
+ output_manifest_path: str,
264
+ stream_callback: Callable | None = None,
265
+ file_callback: Callable | None = None,
266
+ ):
267
+ """Finish Me"""
268
+
269
+ # @todo implement and do something with the callbacks
270
+ # or implement something like the tar orchestrator?
271
+ with irods_session.data_objects.open(path_to_tar, "r") as irods_tar_fp:
272
+ with tarfile.open(fileobj=irods_tar_fp, mode="r") as tar_fp:
273
+ with pathlib.Path(output_manifest_path).open("w") as output_manifest_fp:
274
+ while tar_info := tar_fp.next():
275
+ if tar_info.isfile():
276
+ with tar_fp.extractfile(tar_info) as fp:
277
+ computed_hash = compute_sha256(
278
+ fp, stream_callback=stream_callback
279
+ )
280
+ output_manifest_fp.write(
281
+ f"{computed_hash} {tar_info.name}\n"
282
+ )
283
+ if file_callback:
284
+ file_callback(
285
+ tar_info.name, tar_info.size, computed_hash
286
+ )
287
+
288
+
289
+ def compare_checksums_from_files(
290
+ from_input: str, from_tar: str, input_prefix="", tar_prefix=""
291
+ ):
292
+ """compare checksums from input lists of which the elements are not necessarily in the
293
+ same order or even the same list size
294
+
295
+ """
296
+ # Using more_itertools.filter_map
297
+ cs_input_d = {} # {path: checksum}
298
+ cs_tar_d = {}
299
+ with pathlib.Path(from_input).open("r") as input_mf_fp:
300
+ for line in input_mf_fp:
301
+ item = line.rstrip().split(" ", 1) # path may also contain whitespaces
302
+ if len(item) == 2:
303
+ cs_input_d[input_prefix + item[1]] = item[0]
304
+ with pathlib.Path(from_tar).open("r") as tar_mf_fp:
305
+ for line in tar_mf_fp:
306
+ item = line.rstrip().split(" ", 1)
307
+ if len(item) == 2:
308
+ cs_tar_d[tar_prefix + item[1]] = item[0]
309
+
310
+ not_found = {}
311
+ mis_match = {}
312
+
313
+ for path, checksum in cs_input_d.items():
314
+ tar_checksum = cs_tar_d.get(path, None)
315
+ if tar_checksum:
316
+ if checksum != tar_checksum:
317
+ mis_match[path] = f"{checksum} {tar_checksum}"
318
+ else:
319
+ not_found[path] = checksum
320
+
321
+ if not_found or mis_match:
322
+ # write the problematic paths to a file for later inspection
323
+ message = ""
324
+ if not_found:
325
+ with (pathlib.Path(from_input).parent / "checksums_not_found.txt").open(
326
+ "w"
327
+ ) as nf_fp:
328
+ for item in not_found:
329
+ nf_fp.write(f"{not_found[item]} {item}\n")
330
+ message += f"{len(not_found)} items were not found. "
331
+ if mis_match:
332
+ with (pathlib.Path(from_input).parent / "checksums_mis_match.txt").open(
333
+ "w"
334
+ ) as missing_fp:
335
+ for item in mis_match:
336
+ # The format is first the checksum as read + SPACE + checksum from tar + PATH
337
+ missing_fp.write(f"{mis_match[item]} {item}\n")
338
+ message += (
339
+ f"{len(mis_match)} items were found but the checksum does not match."
340
+ )
341
+
342
+ raise ValueError(f"The match is not perfect: {message}")
343
+
344
+ return True