dspace-api-toolkit 0.1.1__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.
dspace_api/utils.py ADDED
@@ -0,0 +1,519 @@
1
+ # -*- coding: UTF-8 -*-
2
+ """
3
+ Utility functions for DSpace API client
4
+ """
5
+
6
+ import os
7
+ import json
8
+ import logging
9
+ import re
10
+ import math
11
+ from datetime import datetime
12
+ from typing import Dict, List, Optional, Union
13
+
14
+ # Logger for the module
15
+ logger = logging.getLogger(__name__)
16
+
17
+ def format_metadata_value(value, language=None, authority=None, confidence=-1, place=0):
18
+ """
19
+ Format a single metadata value for DSpace API
20
+
21
+ Parameters:
22
+ value: The metadata value
23
+ language: Language code (e.g., "en", "de")
24
+ authority: Authority value for controlled vocabularies
25
+ confidence: Confidence level for authority values
26
+ place: Position/order of the value
27
+
28
+ Returns:
29
+ dict: Formatted metadata value
30
+ """
31
+ metadata_value = {
32
+ "value": str(value),
33
+ "language": language,
34
+ "authority": authority,
35
+ "confidence": confidence,
36
+ "place": place
37
+ }
38
+
39
+ # Remove None values to keep JSON clean
40
+ return {k: v for k, v in metadata_value.items() if v is not None}
41
+
42
+ def format_metadata_field(values, default_language=None):
43
+ """
44
+ Format metadata field values for DSpace API
45
+
46
+ Parameters:
47
+ values: String, list of strings, or list of dicts
48
+ default_language: Default language to use if not specified
49
+
50
+ Returns:
51
+ list: Formatted metadata values
52
+ """
53
+ if isinstance(values, str):
54
+ return [format_metadata_value(values, language=default_language)]
55
+ elif isinstance(values, list):
56
+ formatted = []
57
+ for i, value in enumerate(values):
58
+ if isinstance(value, str):
59
+ formatted.append(format_metadata_value(value, language=default_language, place=i))
60
+ elif isinstance(value, dict):
61
+ # Use provided dict, but ensure place is set if not provided
62
+ if "place" not in value:
63
+ value["place"] = i
64
+ formatted.append(value)
65
+ else:
66
+ formatted.append(format_metadata_value(str(value), language=default_language, place=i))
67
+ return formatted
68
+ else:
69
+ return [format_metadata_value(str(values), language=default_language)]
70
+
71
+ def extract_uuid_from_url(url):
72
+ """
73
+ Extract UUID from DSpace URL
74
+
75
+ Parameters:
76
+ url: DSpace URL containing UUID
77
+
78
+ Returns:
79
+ str: Extracted UUID or None
80
+ """
81
+ if not url:
82
+ return None
83
+
84
+ # Split by '/' and find the last part that looks like a UUID
85
+ parts = url.split('/')
86
+ for part in reversed(parts):
87
+ if len(part) == 36 and part.count('-') == 4: # UUID format
88
+ return part
89
+
90
+ return None
91
+
92
+ def parse_handle(handle_or_url):
93
+ """
94
+ Parse DSpace handle from various formats
95
+
96
+ Parameters:
97
+ handle_or_url: Handle string or URL
98
+
99
+ Returns:
100
+ str: Clean handle (e.g., "123456789/123")
101
+ """
102
+ if not handle_or_url:
103
+ return None
104
+
105
+ # Remove common URL prefixes
106
+ handle = handle_or_url.replace("http://hdl.handle.net/", "")
107
+ handle = handle.replace("https://hdl.handle.net/", "")
108
+
109
+ # Extract handle pattern (numbers/numbers)
110
+ match = re.search(r'(\d+/\d+)', handle)
111
+ return match.group(1) if match else handle
112
+
113
+ def validate_metadata_field(field_name, values):
114
+ """
115
+ Validate metadata field format
116
+
117
+ Parameters:
118
+ field_name: Name of the metadata field
119
+ values: Field values to validate
120
+
121
+ Returns:
122
+ bool: True if valid
123
+
124
+ Raises:
125
+ ValueError: If validation fails
126
+ """
127
+ if not field_name:
128
+ raise ValueError("Field name cannot be empty")
129
+
130
+ if not values:
131
+ raise ValueError(f"No values provided for field {field_name}")
132
+
133
+ # Check if field follows Dublin Core pattern - must have at least 2 parts after "dc."
134
+ if field_name.startswith("dc.") and field_name.count(".") < 2:
135
+ raise ValueError(f"Invalid Dublin Core field format: {field_name}")
136
+
137
+ return True
138
+
139
+ def sanitize_filename(filename):
140
+ """
141
+ Sanitize filename for safe file operations
142
+
143
+ Parameters:
144
+ filename: Original filename
145
+
146
+ Returns:
147
+ str: Sanitized filename
148
+ """
149
+ # Remove or replace invalid characters
150
+ sanitized = re.sub(r'[<>:"/\\|?*]', '_', filename)
151
+ # Remove leading/trailing whitespace and dots
152
+ sanitized = sanitized.strip(' .')
153
+ # Limit length
154
+ if len(sanitized) > 255:
155
+ name, ext = os.path.splitext(sanitized)
156
+ sanitized = name[:255-len(ext)] + ext
157
+
158
+ return sanitized
159
+
160
+ def create_patch_operation(operation, path, value=None, from_path=None):
161
+ """
162
+ Create a JSON Patch operation
163
+
164
+ Parameters:
165
+ operation: Operation type ("add", "remove", "replace", "move", "copy", "test")
166
+ path: JSON Pointer path
167
+ value: Value for add/replace operations
168
+ from_path: Source path for move/copy operations
169
+
170
+ Returns:
171
+ dict: JSON Patch operation
172
+ """
173
+ patch_op = {"op": operation, "path": path}
174
+
175
+ if operation in ["add", "replace", "test"] and value is not None:
176
+ patch_op["value"] = value
177
+ elif operation in ["move", "copy"] and from_path:
178
+ patch_op["from"] = from_path
179
+
180
+ return patch_op
181
+
182
+ def batch_metadata_operations(metadata_updates):
183
+ """
184
+ Create batch patch operations for metadata updates
185
+
186
+ Parameters:
187
+ metadata_updates: Dict of field -> values mappings
188
+
189
+ Returns:
190
+ list: List of JSON Patch operations
191
+ """
192
+ operations = []
193
+
194
+ for field, values in metadata_updates.items():
195
+ operations.append(create_patch_operation(
196
+ "replace",
197
+ f"/metadata/{field}",
198
+ format_metadata_field(values)
199
+ ))
200
+
201
+ return operations
202
+
203
+ def log_api_call(method, url, status_code, response_time=None):
204
+ """
205
+ Log API call details
206
+
207
+ Parameters:
208
+ method: HTTP method
209
+ url: Request URL
210
+ status_code: Response status code
211
+ response_time: Response time in seconds
212
+ """
213
+ log_msg = f"{method} {url} -> {status_code}"
214
+ if response_time:
215
+ log_msg += f" ({response_time:.2f}s)"
216
+
217
+ if 200 <= status_code < 300:
218
+ logger.info(log_msg)
219
+ elif 400 <= status_code < 500:
220
+ logger.warning(log_msg)
221
+ else:
222
+ logger.error(log_msg)
223
+
224
+ def format_file_size(size_bytes):
225
+ """
226
+ Format file size in human readable format
227
+
228
+ Parameters:
229
+ size_bytes: Size in bytes
230
+
231
+ Returns:
232
+ str: Formatted size (e.g., "1.5 MB")
233
+ """
234
+ if size_bytes == 0:
235
+ return "0 B"
236
+
237
+ size_names = ["B", "KB", "MB", "GB", "TB"]
238
+ i = int(math.floor(math.log(size_bytes, 1024)))
239
+ p = math.pow(1024, i)
240
+ s = round(size_bytes / p, 2)
241
+ return f"{s} {size_names[i]}"
242
+
243
+ def check_file_exists_and_readable(file_path):
244
+ """
245
+ Check if file exists and is readable
246
+
247
+ Parameters:
248
+ file_path: Path to the file
249
+
250
+ Returns:
251
+ bool: True if file exists and is readable
252
+
253
+ Raises:
254
+ FileNotFoundError: If file doesn't exist
255
+ PermissionError: If file is not readable
256
+ """
257
+ if not os.path.exists(file_path):
258
+ raise FileNotFoundError(f"File not found: {file_path}")
259
+
260
+ if not os.access(file_path, os.R_OK):
261
+ raise PermissionError(f"File not readable: {file_path}")
262
+
263
+ return True
264
+
265
+ def get_file_info(file_path):
266
+ """
267
+ Get file information
268
+
269
+ raise DSpaceError(f"Version {version.get('id')} has no workspace item (already in workflow?)")
270
+
271
+ Parameters:
272
+ metadata: Flat metadata dictionary
273
+ entity_type: Entity type for the item
274
+
275
+ Returns:
276
+ dict: Metadata organized into sections
277
+ file_path: Path to the file
278
+
279
+ Returns:
280
+ dict: File information
281
+ """
282
+ check_file_exists_and_readable(file_path)
283
+
284
+ stat = os.stat(file_path)
285
+ return {
286
+ "path": file_path,
287
+ "name": os.path.basename(file_path),
288
+ "size": stat.st_size,
289
+ "size_formatted": format_file_size(stat.st_size),
290
+ "modified": datetime.fromtimestamp(stat.st_mtime),
291
+ "extension": os.path.splitext(file_path)[1].lower()
292
+ }
293
+
294
+ def create_workspace_sections_from_metadata(metadata, entity_type="Publication"):
295
+ """
296
+ Convert flat metadata dictionary to workspace sections format
297
+
298
+ Parameters:
299
+ metadata: Flat metadata dictionary
300
+ entity_type: Entity type for the item
301
+
302
+ Returns:
303
+ dict: Metadata organized into sections
304
+ """
305
+ sections = {}
306
+
307
+ # Define section mappings
308
+ section_mappings = {
309
+ "publication": [
310
+ "dc.title", "dc.contributor.author", "dc.creator", "dc.date.issued",
311
+ "dc.publisher", "dc.relation.ispartof"
312
+ ],
313
+ "publication_type": [
314
+ "dc.type"
315
+ ],
316
+ "publication_indexing": [
317
+ "dc.description", "dc.subject", "dc.description.abstract"
318
+ ],
319
+ "person": [
320
+ "person.familyName", "person.givenName", "person.identifier.gnd"
321
+ ]
322
+ }
323
+
324
+ # Distribute metadata into sections
325
+ for section, fields in section_mappings.items():
326
+ section_metadata = {}
327
+ for field in fields:
328
+ if field in metadata:
329
+ section_metadata[field] = metadata[field]
330
+ if section_metadata:
331
+ sections[section] = section_metadata
332
+
333
+ # Add any remaining fields to a general section
334
+ used_fields = set()
335
+ for fields in section_mappings.values():
336
+ used_fields.update(fields)
337
+
338
+ remaining_fields = {k: v for k, v in metadata.items() if k not in used_fields}
339
+ if remaining_fields:
340
+ sections["additional"] = remaining_fields
341
+
342
+ return sections
343
+
344
+ def merge_metadata_sections(*section_dicts):
345
+ """
346
+ Merge multiple metadata section dictionaries
347
+
348
+ Parameters:
349
+ *section_dicts: Multiple section dictionaries to merge
350
+
351
+ Returns:
352
+ dict: Merged sections
353
+ """
354
+ merged = {}
355
+
356
+ for section_dict in section_dicts:
357
+ for section_name, metadata in section_dict.items():
358
+ if section_name not in merged:
359
+ merged[section_name] = {}
360
+ merged[section_name].update(metadata)
361
+
362
+ return merged
363
+
364
+ def validate_uuid(uuid_string):
365
+ """
366
+ Validate UUID format
367
+
368
+ Parameters:
369
+ uuid_string: String to validate
370
+
371
+ Returns:
372
+ bool: True if valid UUID format
373
+ """
374
+ uuid_pattern = re.compile(
375
+ r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
376
+ re.IGNORECASE
377
+ )
378
+ return bool(uuid_pattern.match(uuid_string)) if uuid_string else False
379
+
380
+ def convert_date_format(date_string, input_format=None, output_format="%Y"):
381
+ """
382
+ Convert date between different formats
383
+
384
+ Parameters:
385
+ date_string: Date string to convert
386
+ input_format: Input format (auto-detected if None)
387
+ output_format: Desired output format
388
+
389
+ Returns:
390
+ str: Converted date string
391
+ """
392
+ if not date_string:
393
+ return None
394
+
395
+ common_formats = [
396
+ "%Y-%m-%d",
397
+ "%Y/%m/%d",
398
+ "%d.%m.%Y",
399
+ "%d/%m/%Y",
400
+ "%Y-%m-%dT%H:%M:%S",
401
+ "%Y-%m-%dT%H:%M:%SZ"
402
+ ]
403
+
404
+ parsed_date = None
405
+
406
+ if input_format:
407
+ try:
408
+ parsed_date = datetime.strptime(date_string, input_format)
409
+ except ValueError:
410
+ pass
411
+
412
+ if not parsed_date:
413
+ for fmt in common_formats:
414
+ try:
415
+ parsed_date = datetime.strptime(date_string, fmt)
416
+ break
417
+ except ValueError:
418
+ continue
419
+
420
+ if parsed_date:
421
+ return parsed_date.strftime(output_format)
422
+ else:
423
+ # If all parsing fails, try to extract year
424
+ year_match = re.search(r'\b(19|20)\d{2}\b', date_string)
425
+ return year_match.group(0) if year_match else date_string
426
+
427
+ def create_dublin_core_metadata(title=None, creators=None, contributors=None,
428
+ description=None, subjects=None, date_issued=None,
429
+ type_value=None, language="en", **kwargs):
430
+ """
431
+ Create Dublin Core metadata structure
432
+
433
+ Parameters:
434
+ title: Item title
435
+ creators: List of creators
436
+ contributors: List of contributors
437
+ description: Item description
438
+ subjects: List of subjects
439
+ date_issued: Date of issue (year or full date)
440
+ type_value: Resource type
441
+ language: Default language for metadata
442
+ **kwargs: Additional metadata fields
443
+
444
+ Returns:
445
+ dict: Formatted Dublin Core metadata
446
+ """
447
+ metadata = {}
448
+
449
+ if title:
450
+ metadata["dc.title"] = format_metadata_field(title, language)
451
+
452
+ if creators:
453
+ metadata["dc.creator"] = format_metadata_field(creators, language)
454
+
455
+ if contributors:
456
+ metadata["dc.contributor.author"] = format_metadata_field(contributors, language)
457
+
458
+ if description:
459
+ metadata["dc.description"] = format_metadata_field(description, language)
460
+
461
+ if subjects:
462
+ metadata["dc.subject"] = format_metadata_field(subjects, language)
463
+
464
+ if date_issued:
465
+ metadata["dc.date.issued"] = format_metadata_field(str(date_issued))
466
+
467
+ if type_value:
468
+ metadata["dc.type"] = format_metadata_field(type_value)
469
+
470
+ # Add any additional metadata fields
471
+ for field, value in kwargs.items():
472
+ if value:
473
+ metadata[field] = format_metadata_field(value, language)
474
+
475
+ return metadata
476
+
477
+ def create_publication_metadata(title, authors=None, date_issued=None,
478
+ pub_type="text", pub_type_authority=None,
479
+ description=None, language="en"):
480
+ """
481
+ Create metadata for a publication item
482
+
483
+ Parameters:
484
+ title: Publication title
485
+ authors: List of authors
486
+ date_issued: Publication date
487
+ pub_type: Publication type
488
+ pub_type_authority: Authority URI for publication type
489
+ description: Publication description
490
+ language: Default language
491
+
492
+ Returns:
493
+ dict: Publication metadata sections
494
+ """
495
+ metadata_sections = {}
496
+
497
+ # Publication section
498
+ publication_metadata = {"dc.title": format_metadata_field(title, language)}
499
+
500
+ if authors:
501
+ publication_metadata["dc.contributor.author"] = format_metadata_field(authors, language)
502
+
503
+ if date_issued:
504
+ publication_metadata["dc.date.issued"] = format_metadata_field(str(date_issued))
505
+
506
+ metadata_sections["publication"] = publication_metadata
507
+
508
+ # Publication type section
509
+ if pub_type:
510
+ type_value = format_metadata_value(pub_type, authority=pub_type_authority, confidence=600)
511
+ metadata_sections["publication_type"] = {"dc.type": [type_value]}
512
+
513
+ # Description section
514
+ if description:
515
+ metadata_sections["publication_indexing"] = {
516
+ "dc.description": format_metadata_field(description, language)
517
+ }
518
+
519
+ return metadata_sections