das-cli 1.0.4__py3-none-any.whl → 1.2.4__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.
das/ai/plugins/dasai.py CHANGED
@@ -3,6 +3,7 @@ import logging
3
3
  from dotenv import load_dotenv
4
4
  import asyncio
5
5
  from semantic_kernel import Kernel
6
+ from openai import OpenAI
6
7
  from semantic_kernel.connectors.ai.open_ai import (
7
8
  OpenAIChatCompletion,
8
9
  )
@@ -22,9 +23,17 @@ class DasAI:
22
23
  api_key = os.getenv("OPENAI_API_KEY") or load_openai_api_key()
23
24
  if not api_key:
24
25
  raise ValueError("OpenAI API key is not configured.")
25
- self.openai_chat_completion = OpenAIChatCompletion(ai_model_id="gpt-4o-mini", api_key=api_key)
26
+ self.openai_chat_completion = OpenAIChatCompletion(ai_model_id=os.getenv("OPEN_AI_MODEL_ID"), api_key=api_key)
26
27
  self.kernel.add_service(self.openai_chat_completion)
27
- self.kernel.add_plugin(GetEntryByCodePlugin(), plugin_name="get_entry_by_code")
28
+ self.kernel.add_plugin(GetEntryByCodePlugin(), plugin_name="get_entry_by_code")
29
+ # self.__load_available_models()
30
+
31
+ def __load_available_models(self):
32
+ client = OpenAI(api_key=os.getenv("OPENAI_API_KEY") or load_openai_api_key())
33
+ models = client.models.list()
34
+ for model in models:
35
+ print(model.id)
36
+
28
37
 
29
38
  async def main(self):
30
39
  history = ChatHistory()
das/cli.py CHANGED
@@ -14,6 +14,7 @@ from das.app import Das
14
14
  from das.managers.download_manager import DownloadManager
15
15
  from das.managers.entries_manager import EntryManager
16
16
  from das.managers.search_manager import SearchManager
17
+ from das.managers.digital_objects_manager import DigitalObjectsManager
17
18
  from das.common.file_utils import load_file_based_on_extension, parse_data_string
18
19
  from das.ai.plugins.dasai import DasAI
19
20
 
@@ -51,6 +52,7 @@ class DasCLI:
51
52
  self.entry_manager = None
52
53
  self.search_manager = None
53
54
  self.download_manager = None
55
+ self.digital_objects_manager = None
54
56
  self.das_ai = None
55
57
 
56
58
  def get_client(self):
@@ -65,6 +67,7 @@ class DasCLI:
65
67
  self.entry_manager = EntryManager()
66
68
  self.search_manager = SearchManager()
67
69
  self.download_manager = DownloadManager()
70
+ self.digital_objects_manager = DigitalObjectsManager()
68
71
  # Set SSL verification based on saved config
69
72
  if not self.client:
70
73
  self.client = Das(self.api_url)
@@ -80,6 +83,7 @@ class DasCLI:
80
83
  pass_das_context = click.make_pass_decorator(DasCLI, ensure=True)
81
84
 
82
85
  @click.group()
86
+ @click.version_option(package_name="das-cli")
83
87
  @click.pass_context
84
88
  def cli(ctx):
85
89
  """DAS Python CLI - Data Archive System client tool"""
@@ -397,14 +401,67 @@ def update_entry(das_ctx, attribute, code=None, file_path=None, data=None):
397
401
  click.secho(f"Error: {e}", fg="red")
398
402
 
399
403
  @entry.command("delete")
400
- @click.argument('code', required=True)
404
+ @click.option('--code', default=None, help='Entry code')
405
+ @click.option('--id', default=None, help='Entry ID')
406
+ @click.option('--force', is_flag=True, help='Skip confirmation prompt')
401
407
  @pass_das_context
402
- def delete_entry(das_ctx, code):
403
- """Delete entry by its code"""
404
- client = das_ctx.get_client()
408
+ def delete_entry(das_ctx, code, id, force):
409
+ """Delete an entry by its code or ID"""
410
+ if not code and not id:
411
+ raise click.UsageError("Please provide either an entry code or ID")
412
+
413
+ # Ensure client and entry_manager are initialized
414
+ das_ctx.get_client()
415
+
416
+ identifier = code or id
417
+ id_label = "code" if code else "ID"
418
+
419
+ if not force:
420
+ if not click.confirm(f"Are you sure you want to delete entry with {id_label} '{identifier}'?"):
421
+ click.echo("Operation cancelled.")
422
+ return
423
+
405
424
  try:
406
- client.entries.delete(code)
407
- click.secho(f"✓ Entry '{code}' deleted!", fg="green")
425
+ das_ctx.entry_manager.delete(id=id, code=code)
426
+ click.secho(f"✓ Entry with {id_label} '{identifier}' deleted!", fg="green")
427
+ except Exception as e:
428
+ click.secho(f"Error: {e}", fg="red")
429
+
430
+ @entry.command("link-digital-objects")
431
+ @click.option('--entry-code', required=True, help='Entry code to link/unlink digital objects to')
432
+ @click.option('--digital-object-code', '-d', multiple=True, required=True, help='Digital object code. Use multiple times for multiple objects.')
433
+ @click.option('--unlink', is_flag=True, help='Unlink specified digital objects from the entry instead of linking')
434
+ @pass_das_context
435
+ def link_digital_objects(das_ctx, entry_code, digital_object_code, unlink):
436
+ """Link or unlink existing digital objects to an entry by their codes.
437
+
438
+ Examples:
439
+
440
+ \b
441
+ # Link two digital objects to an entry
442
+ das entry link-digital-objects --entry-code ENT001 -d DO001 -d DO002
443
+
444
+ \b
445
+ # Unlink a digital object from an entry
446
+ das entry link-digital-objects --entry-code ENT001 -d DO003 --unlink
447
+ """
448
+ try:
449
+ das_ctx.get_client()
450
+ codes = list(digital_object_code)
451
+ if not codes:
452
+ raise click.UsageError("Please provide at least one --digital-object-code")
453
+
454
+ success = das_ctx.digital_objects_manager.link_existing_digital_objects(
455
+ entry_code=entry_code,
456
+ digital_object_code_list=codes,
457
+ is_unlink=unlink,
458
+ )
459
+
460
+ if success:
461
+ action = "unlinked" if unlink else "linked"
462
+ click.secho(f"✓ Successfully {action} {len(codes)} digital object(s) for entry '{entry_code}'", fg="green")
463
+ else:
464
+ click.secho("Operation did not report success.", fg="yellow")
408
465
  except Exception as e:
409
466
  click.secho(f"Error: {e}", fg="red")
410
467
 
@@ -509,6 +566,41 @@ def create_entry(das_ctx, attribute, file_path=None, data=None):
509
566
  except Exception as e:
510
567
  click.secho(f"Error: {e}", fg="red")
511
568
 
569
+ @entry.command("upload-digital-object")
570
+ @click.option('--entry-code', required=True, help='Entry code to attach the digital object to')
571
+ @click.option('--type', 'digital_object_type', required=True, help='Digital object type name (e.g., Dataset, File, Image)')
572
+ @click.option('--description', 'file_description', default='', help='Description for the uploaded file')
573
+ @click.argument('file_path', required=True)
574
+ @pass_das_context
575
+ def upload_digital_object(das_ctx, entry_code, digital_object_type, file_description, file_path):
576
+ """Upload a file as a digital object and link it to an entry.
577
+
578
+ Examples:
579
+
580
+ \b
581
+ # Upload a dataset file and link to an entry
582
+ das entry upload-digital-object --entry-code ENT001 --type Dataset --description "CTD raw" c:\\data\\ctd.zip
583
+ """
584
+ try:
585
+ # Ensure services are initialized
586
+ das_ctx.get_client()
587
+
588
+ # Perform upload and link
589
+ digital_object_id = das_ctx.digital_objects_manager.upload_digital_object(
590
+ entry_code=entry_code,
591
+ file_description=file_description,
592
+ digital_object_type=digital_object_type,
593
+ file_path=file_path,
594
+ )
595
+
596
+ if digital_object_id:
597
+ click.secho("✓ Digital object uploaded and linked successfully!", fg="green")
598
+ click.echo(f"Digital Object ID: {digital_object_id}")
599
+ else:
600
+ click.secho("Upload completed but no ID was returned.", fg="yellow")
601
+ except Exception as e:
602
+ click.secho(f"Error: {e}", fg="red")
603
+
512
604
  @entry.command("get")
513
605
  @click.option('--code', default=None, help='Entry code')
514
606
  @click.option('--id', type=int, default=None, help='Entry ID')
@@ -915,6 +1007,38 @@ def create_download_request(das_ctx, name, entry, file, from_file, output_format
915
1007
  except Exception as e:
916
1008
  click.secho(f"Error: {e}", fg="red")
917
1009
 
1010
+ @download.command("files")
1011
+ @click.argument('request_id', required=True)
1012
+ @click.option('--out', 'output_path', required=False, default='.', help='Output file path or directory (defaults to current directory)')
1013
+ @click.option('--force', is_flag=True, help='Overwrite existing file if present')
1014
+ @pass_das_context
1015
+ def download_files(das_ctx, request_id, output_path, force):
1016
+ """
1017
+ Download the completed bundle for a download request and save it to disk.
1018
+
1019
+ Examples:
1020
+
1021
+ \b
1022
+ # Save into current directory with server-provided filename
1023
+ das download files 6b0e68e6-00cd-43a7-9c51-d56c9c091123
1024
+
1025
+ \b
1026
+ # Save to a specific folder
1027
+ das download files 6b0e68e6-00cd-43a7-9c51-d56c9c091123 --out C:\\Downloads
1028
+
1029
+ \b
1030
+ # Save to an explicit filename, overwriting if exists
1031
+ das download files 6b0e68e6-00cd-43a7-9c51-d56c9c091123 --out C:\\Downloads\\bundle.zip --force
1032
+ """
1033
+ try:
1034
+ das_ctx.get_client()
1035
+ saved_path = das_ctx.download_manager.save_download(request_id=request_id, output_path=output_path, overwrite=force)
1036
+ click.secho(f"✓ Download saved to: {saved_path}", fg="green")
1037
+ except FileExistsError as e:
1038
+ click.secho(str(e), fg="yellow")
1039
+ except Exception as e:
1040
+ click.secho(f"Error: {e}", fg="red")
1041
+
918
1042
  @download.command("delete-request")
919
1043
  @click.argument('request_id', required=True)
920
1044
  @pass_das_context
das/common/api.py CHANGED
@@ -27,6 +27,26 @@ def get_data(url, headers=None, params=None):
27
27
  print(f"Error fetching API data: {e}")
28
28
  return {"error": str(e)}
29
29
 
30
+ def get_binary_response(url, headers=None, params=None, stream=True):
31
+ """
32
+ Perform a GET request expected to return binary content.
33
+
34
+ Returns the raw requests.Response so callers can inspect headers
35
+ and stream content to disk.
36
+ """
37
+ try:
38
+ response = requests.get(
39
+ url,
40
+ headers=headers,
41
+ params=params,
42
+ verify=load_verify_ssl(),
43
+ stream=stream,
44
+ )
45
+ response.raise_for_status()
46
+ return response
47
+ except requests.RequestException as e:
48
+ return {"error": str(e)}
49
+
30
50
  def post_data(url, headers=None, data=None):
31
51
  """
32
52
  Send data to a REST API endpoint.
das/common/config.py CHANGED
@@ -4,6 +4,9 @@ from pathlib import Path
4
4
  from typing import Optional
5
5
  import logging
6
6
 
7
+ from dotenv import load_dotenv
8
+ load_dotenv()
9
+
7
10
  # Set up logging
8
11
  logging.basicConfig(
9
12
  level=logging.WARNING, # Only show warnings and errors by default
@@ -75,10 +78,21 @@ def load_verify_ssl() -> bool:
75
78
  if CONFIG_FILE.exists():
76
79
  try:
77
80
  config = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
81
+
82
+ if os.getenv("VERIFY_SSL") is not None:
83
+ VERIFY_SSL = os.getenv("VERIFY_SSL") == "True"
84
+ if not VERIFY_SSL:
85
+ print("SSL certificate verification is disabled")
86
+ return VERIFY_SSL
87
+
78
88
  verify = config.get("verify_ssl")
79
89
  if verify is not None:
80
90
  VERIFY_SSL = verify
91
+ if not VERIFY_SSL:
92
+ print("SSL certificate verification is disabled")
81
93
  return verify
94
+ else:
95
+ raise ValueError("SSL certificate verification is not set")
82
96
  except Exception:
83
97
  pass
84
98
  return VERIFY_SSL
@@ -0,0 +1,84 @@
1
+ import os
2
+ import sys
3
+ from das.common.config import load_api_url
4
+ from das.services.search import SearchService
5
+ from das.services.entries import EntriesService
6
+ from das.services.digital_objects import DigitalObjectsService
7
+
8
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
9
+
10
+ class DigitalObjectsManager:
11
+ """Manager for digital objects."""
12
+
13
+ def __init__(self):
14
+ base_url = load_api_url()
15
+ if base_url is None or base_url == "":
16
+ raise ValueError(f"Base URL is required - {self.__class__.__name__}")
17
+
18
+ self.__attribute_id_digital_object_type = 5;
19
+ self.digital_objects_service = DigitalObjectsService(base_url)
20
+ self.entry_service = EntriesService(base_url)
21
+ self.search_service = SearchService(base_url)
22
+
23
+ def link_existing_digital_objects(
24
+ self, entry_code: str, digital_object_code_list: list[str], is_unlink: bool = False
25
+ ) -> bool:
26
+ """Attach or detach (unlink) digital objects to an entry using codes."""
27
+ entry_response = self.entry_service.get_entry(entry_code)
28
+
29
+ if entry_response is None:
30
+ raise ValueError(f"Entry with code '{entry_code}' not found")
31
+
32
+ entry_payload = entry_response.get("entry")
33
+ if entry_payload is None:
34
+ raise ValueError(f"Entry with code '{entry_code}' not found")
35
+
36
+ digital_object_id_list: list[str] = []
37
+
38
+ for code in digital_object_code_list:
39
+ do_response = self.entry_service.get_entry(code)
40
+ do_entry = do_response.get("entry") if do_response else None
41
+ if do_entry is None:
42
+ raise ValueError(f"Digital object with code '{code}' not found")
43
+ digital_object_id_list.append(do_entry.get("id"))
44
+
45
+ result = self.digital_objects_service.link_existing_digital_objects(
46
+ attribute_id=entry_response.get("attributeId"),
47
+ entry_id=entry_payload.get("id"),
48
+ digital_object_id_list=digital_object_id_list,
49
+ is_unlink=is_unlink,
50
+ )
51
+
52
+ return result
53
+
54
+ def upload_digital_object(self, entry_code: str, file_description: str, digital_object_type: str, file_path: str):
55
+ """Upload a digital object to the digital object service."""
56
+ response = self.search_service.search_entries(
57
+ queryString=f"displayname({digital_object_type})",
58
+ attributeId=self.__attribute_id_digital_object_type,
59
+ maxResultCount=1,
60
+ skipCount=0
61
+ )
62
+
63
+ entry_response = self.entry_service.get_entry(entry_code)
64
+ if entry_response is None:
65
+ raise ValueError(f"Entry with code '{entry_code}' not found")
66
+
67
+ if response.get('totalCount', 0) == 0:
68
+ raise ValueError(f"Digital object type '{digital_object_type}' not found")
69
+
70
+ digital_object_type_id = response.get('items', [])[0].get('entry').get('id')
71
+ digital_object_id = self.digital_objects_service.upload_digital_object(file_description, digital_object_type_id, file_path)
72
+
73
+ self.digital_objects_service.link_existing_digital_objects(
74
+ attribute_id=entry_response.get('attributeId'),
75
+ entry_id=entry_response.get('entry').get('id'),
76
+ digital_object_id_list=[digital_object_id]
77
+ )
78
+
79
+ return digital_object_id
80
+
81
+
82
+ if __name__ == "__main__":
83
+ digital_objects_manager = DigitalObjectsManager()
84
+ digital_objects_manager.upload_digital_object(entry_code="zb.b.f7", file_description="test", digital_object_type="Dataset", file_path="my_new_file.txt")
@@ -90,4 +90,46 @@ class DownloadManager:
90
90
 
91
91
  def get_my_requests(self):
92
92
  """Get all download requests for the current user."""
93
- return self.download_request_service.get_my_requests()
93
+ return self.download_request_service.get_my_requests()
94
+
95
+ def download_files(self, request_id: str):
96
+ """Return streaming response for files of a download request."""
97
+ return self.download_request_service.download_files(request_id)
98
+
99
+ def save_download(self, request_id: str, output_path: str, overwrite: bool = False) -> str:
100
+ """
101
+ Download and save the request bundle to disk.
102
+
103
+ Returns the path to the saved file.
104
+ """
105
+ import os
106
+
107
+ resp = self.download_files(request_id)
108
+ # If an error structure was returned from lower layer
109
+ if isinstance(resp, dict) and resp.get('error'):
110
+ raise ValueError(resp['error'])
111
+
112
+ # Determine filename from headers if available
113
+ filename = f"download_{request_id}.zip"
114
+ try:
115
+ cd = resp.headers.get('Content-Disposition') if hasattr(resp, 'headers') else None
116
+ if cd and 'filename=' in cd:
117
+ filename = cd.split('filename=')[-1].strip('"')
118
+ except Exception:
119
+ pass
120
+
121
+ # If output_path is a directory, join with filename
122
+ target_path = output_path
123
+ if os.path.isdir(output_path) or output_path.endswith(os.path.sep):
124
+ target_path = os.path.join(output_path, filename)
125
+
126
+ if os.path.exists(target_path) and not overwrite:
127
+ raise FileExistsError(f"File already exists: {target_path}. Use overwrite to replace.")
128
+
129
+ # Stream to disk
130
+ with open(target_path, 'wb') as f:
131
+ for chunk in resp.iter_content(chunk_size=8192):
132
+ if chunk:
133
+ f.write(chunk)
134
+
135
+ return target_path
@@ -23,6 +23,23 @@ class EntryManager:
23
23
  self.attribute_service = AttributesService(base_url)
24
24
  self.user_service = UsersService(base_url)
25
25
 
26
+ def delete(self, id: str = None, code: str = None) -> bool:
27
+ """Delete an entry by its id or code."""
28
+ if not id and not code:
29
+ raise ValueError("Entry ID or code is required")
30
+ if id:
31
+ if self.entry_service.delete_by_id(id=id) is True:
32
+ return True
33
+ else:
34
+ return False
35
+ elif code:
36
+ if self.entry_service.delete(code=code) is True:
37
+ return True
38
+ else:
39
+ return False
40
+ else:
41
+ raise ValueError("Entry ID or code is required")
42
+
26
43
  def get_entry(self, entry_id: str):
27
44
  """Get entry details by ID"""
28
45
  if not entry_id:
@@ -222,6 +239,9 @@ class EntryManager:
222
239
  raise ValueError(f"Invalid existing entry response: {existing_entry_response}")
223
240
 
224
241
  attribute_id = existing_entry_response.get("attributeId")
242
+
243
+ # make all keys lowercase
244
+ entry = {k.lower(): v for k, v in entry.items()}
225
245
 
226
246
  if not attribute_id:
227
247
  raise ValueError("Attribute ID is missing in the existing entry")
@@ -244,6 +264,10 @@ class EntryManager:
244
264
 
245
265
  if field_name in entry:
246
266
  updated_entry[column_name] = self.__get_value(field, entry[field_name])
267
+ elif column_name in entry:
268
+ updated_entry[column_name] = self.__get_value(field, entry[column_name])
269
+ else:
270
+ updated_entry[column_name] = None
247
271
 
248
272
  return self.entry_service.update(attribute_id=attribute_id, entry=updated_entry)
249
273
 
@@ -276,7 +300,7 @@ class EntryManager:
276
300
  if (datasource is not None and isinstance(datasource, dict) and "attributeid" in datasource):
277
301
  attribute_id = datasource.get("attributeid")
278
302
  except json.JSONDecodeError:
279
- raise ValueError(f"Invalid customdata JSON: {field.get('customdata')}")
303
+ raise ValueError(f"Invalid customdata JSON: {field.get('customdata')}")
280
304
 
281
305
  search_params = {
282
306
  "attributeId": attribute_id,
@@ -285,6 +309,14 @@ class EntryManager:
285
309
  "skipCount": 0
286
310
  }
287
311
 
312
+ # checks if source is GUID, if so, than we search by id
313
+ if self.is_guid(source):
314
+ search_params['queryString'] = f"id({source});"
315
+ search_response = self.search_service.search_entries(**search_params)
316
+ else:
317
+ search_params['queryString'] = f"displayname({source});"
318
+ search_response = self.search_service.search_entries(**search_params)
319
+
288
320
  search_response = self.search_service.search_entries(**search_params)
289
321
 
290
322
  if search_response.get('totalCount', 0) == 0:
@@ -303,6 +335,10 @@ class EntryManager:
303
335
  return json.dumps([result])
304
336
  else:
305
337
  return source
338
+
339
+ def is_guid(self, source: str) -> bool:
340
+ """Helper method to check if a string is a GUID."""
341
+ return len(source) == 36 and source.count('-') == 4
306
342
 
307
343
  def __get_field_value(self, entry_raw, field):
308
344
  """Helper method to safely get field value from entry_raw."""
@@ -3,7 +3,6 @@ from das.services.attributes import AttributesService
3
3
  from das.services.entry_fields import EntryFieldsService
4
4
  from das.services.search import SearchService
5
5
 
6
-
7
6
  class SearchManager:
8
7
  def __init__(self):
9
8
  base_url = load_api_url()
@@ -59,6 +58,22 @@ class SearchManager:
59
58
  "sorting": self.__convert_sorting(entry_fields, sort_by, sort_order)
60
59
  }
61
60
  results = self.search_service.search_entries(**search_params)
62
- return results
61
+
62
+ # Build user-friendly items list while preserving totalCount
63
+ friendly_items = []
64
+ for result in results.get('items', []):
65
+ entry = result.get('entry', {}) if isinstance(result, dict) else {}
66
+ friendly_item = {}
67
+ for field in entry_fields:
68
+ display_name = field.get('displayName')
69
+ column_name = field.get('column')
70
+ if display_name and column_name:
71
+ friendly_item[display_name] = entry.get(column_name)
72
+ friendly_items.append(friendly_item)
73
+
74
+ return {
75
+ 'items': friendly_items,
76
+ 'totalCount': results.get('totalCount', len(friendly_items))
77
+ }
63
78
  except Exception as e:
64
79
  raise ValueError(f"Search failed: {str(e)}")
@@ -0,0 +1,142 @@
1
+ import os
2
+ import sys
3
+ from math import ceil
4
+ from os.path import exists
5
+ import json
6
+ from base64 import b64encode
7
+ from das.common.api import post_data
8
+ from das.common.config import load_token, load_verify_ssl
9
+ from pathlib import Path
10
+ import math
11
+ import uuid
12
+ import requests
13
+
14
+ CHUNK_SIZE = 1000000 # 1MB
15
+ class DigitalObjectsService:
16
+ def __init__(self, base_url):
17
+ self.base_url = f"{base_url}/api/services/app/DigitalObject"
18
+ # Common possible upload endpoints observed across deployments
19
+ self.upload_digital_object_url = f"{base_url}/File/UploadDigitalObject"
20
+
21
+ def link_existing_digital_objects(self, attribute_id: int, entry_id: str, digital_object_id_list: list[str], is_unlink: bool = False):
22
+ """Link existing digital objects to an entry."""
23
+ token = load_token()
24
+
25
+ if token is None or token == "":
26
+ raise ValueError("Authorization token is required")
27
+
28
+ headers = {
29
+ "Authorization": f"Bearer {token}",
30
+ "Content-Type": "application/json",
31
+ }
32
+
33
+ payload = {
34
+ "attributeId": attribute_id,
35
+ "attributeValueId": entry_id,
36
+ "digitalObjects": [],
37
+ }
38
+
39
+ for digital_object_id in digital_object_id_list:
40
+ payload["digitalObjects"].append(
41
+ {
42
+ "attributeId": attribute_id,
43
+ "attributeValueId": entry_id,
44
+ "digitalObjectId": digital_object_id,
45
+ "isDeleted": is_unlink,
46
+ }
47
+ )
48
+
49
+ response = post_data(
50
+ f"{self.base_url}/LinkExistingDigitalObject", data=payload, headers=headers
51
+ )
52
+
53
+ return response.get("success")
54
+
55
+ # This is our chunk reader. This is what gets the next chunk of data ready to send.
56
+ def __read_in_chunks(self, file_object, chunk_size):
57
+ while True:
58
+ data = file_object.read(chunk_size)
59
+ if not data:
60
+ break
61
+ yield data
62
+
63
+
64
+ def upload_digital_object(self, file_description: str, digital_object_type_id: str, file_path: str):
65
+
66
+ if not exists(file_path):
67
+ raise ValueError(f"File '{file_path}' does not exist")
68
+
69
+ head, tail = os.path.split(file_path)
70
+
71
+ metadata = {
72
+ "fileName": tail,
73
+ "fileSize": os.path.getsize(file_path),
74
+ "description": file_description,
75
+ "digitalObjectTypeId": digital_object_type_id,
76
+ "id": str(uuid.uuid4()).lower(),
77
+ "description": file_description,
78
+ "totalCount": ceil(os.path.getsize(file_path) / CHUNK_SIZE),
79
+ "index": 0,
80
+ }
81
+
82
+ binary_file = open(file_path, "rb")
83
+ index = 0
84
+ offset = 0
85
+ digital_object_id = None
86
+ headers = {}
87
+
88
+ try:
89
+ for chunk in self.__read_in_chunks(binary_file, CHUNK_SIZE):
90
+ offset = index + len(chunk)
91
+ headers['Content-Range'] = 'bytes %s-%s/%s' % (index, offset - 1, metadata.get('fileSize'))
92
+ index = offset
93
+ json_metadata = json.dumps(metadata)
94
+ base654_bytes = b64encode(json_metadata.encode('utf-8')).decode('ascii')
95
+ headers['metadata'] = base654_bytes
96
+
97
+ r = self.upload_file(chunk, metadata, headers)
98
+
99
+ if r.get('result', None) is None:
100
+ continue
101
+
102
+ digital_object_id = r.get('result').get('id')
103
+ metadata['index'] = index + 1
104
+
105
+ binary_file.close()
106
+
107
+ except Exception as e:
108
+ raise ValueError(f"Error uploading file '{file_path}': {str(e)}")
109
+ finally:
110
+ binary_file.close()
111
+
112
+ return digital_object_id
113
+
114
+
115
+
116
+ def upload_file(self, file, body, headers):
117
+ """Upload a file to the digital object service."""
118
+ token = load_token()
119
+ headers.update({
120
+ "Accept": "application/json",
121
+ "Authorization": f"Bearer {token}",
122
+ # Do NOT set Content-Type here when sending files; requests will set proper multipart boundary
123
+ })
124
+
125
+ files = {
126
+ "file": ("chunk", file, "application/octet-stream"),
127
+ }
128
+
129
+ try:
130
+ response = requests.post(self.upload_digital_object_url, headers=headers, files=files, verify=load_verify_ssl())
131
+ response.raise_for_status()
132
+ if response.status_code == 200:
133
+ return response.json()
134
+ else:
135
+ raise ValueError(f"Error uploading file: {response.status_code} - {response.text}")
136
+ except requests.RequestException as e:
137
+ raise ValueError(f"Error uploading file: {str(e)}")
138
+
139
+
140
+
141
+
142
+