wavesimpro 0.9.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.
wavesimpro/client.py ADDED
@@ -0,0 +1,1475 @@
1
+ """
2
+ Main client class for interacting with RayfosJobServer
3
+ """
4
+
5
+ import os
6
+ import logging
7
+ from typing import Dict, List, Optional, Any, BinaryIO
8
+ from pathlib import Path
9
+ import requests
10
+ from requests.adapters import HTTPAdapter
11
+ from urllib3.util.retry import Retry
12
+
13
+ from .config import ClientConfig
14
+ from .exceptions import (
15
+ RayfosAPIError,
16
+ AuthenticationError,
17
+ NotFoundError,
18
+ ValidationError,
19
+ ServerError,
20
+ ConfigurationError,
21
+ )
22
+
23
+ # Set up logger for the client
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class RayfosClient:
28
+ """
29
+ Client for interacting with RayfosJobServer API
30
+
31
+ This client supports both cloud and local deployment modes and provides
32
+ methods for managing projects, versions, files, and commands.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ api_token: str,
38
+ server_url: str = "https://server.rayfos.com",
39
+ machine_service_url: Optional[str] = None,
40
+ timeout: int = 30,
41
+ verify_ssl: bool = True,
42
+ verify_ssl_machine_service: bool = True,
43
+ ):
44
+ """
45
+ Initialize the Rayfos API client
46
+
47
+ Args:
48
+ api_token: User API token for authentication
49
+ server_url: Base URL of RayfosJobServer for project management
50
+ machine_service_url: Optional URL of RayfosMachineService for file uploads
51
+ timeout: Request timeout in seconds
52
+ verify_ssl: Whether to verify SSL certificates for server_url
53
+ verify_ssl_machine_service: Whether to verify SSL for machine_service_url
54
+ """
55
+ self.config = ClientConfig(
56
+ api_token=api_token,
57
+ server_url=server_url,
58
+ machine_service_url=machine_service_url,
59
+ timeout=timeout,
60
+ verify_ssl=verify_ssl,
61
+ verify_ssl_machine_service=verify_ssl_machine_service,
62
+ )
63
+
64
+ # Create session with retry logic
65
+ self.session = self._create_session()
66
+
67
+ @classmethod
68
+ def from_env(cls) -> "RayfosClient":
69
+ """
70
+ Create client from environment variables
71
+
72
+ Environment variables:
73
+ RAYFOS_API_TOKEN: API token (required)
74
+ RAYFOS_SERVER_URL: Project server URL (optional, defaults to server.rayfos.com)
75
+ RAYFOS_MACHINE_SERVICE_URL: Machine service URL for file uploads (optional)
76
+
77
+ Returns:
78
+ RayfosClient instance
79
+ """
80
+ config = ClientConfig.from_env()
81
+ return cls(
82
+ api_token=config.api_token,
83
+ server_url=config.server_url,
84
+ machine_service_url=config.machine_service_url,
85
+ timeout=config.timeout,
86
+ verify_ssl=config.verify_ssl,
87
+ verify_ssl_machine_service=config.verify_ssl_machine_service,
88
+ )
89
+
90
+ def _create_session(self) -> requests.Session:
91
+ """Create requests session with retry logic and authentication"""
92
+ session = requests.Session()
93
+
94
+ # Add retry logic
95
+ retry_strategy = Retry(
96
+ total=3,
97
+ backoff_factor=1,
98
+ status_forcelist=[429, 500, 502, 503, 504],
99
+ allowed_methods=["HEAD", "GET", "OPTIONS", "POST", "PUT", "DELETE"],
100
+ )
101
+ adapter = HTTPAdapter(max_retries=retry_strategy)
102
+ session.mount("http://", adapter)
103
+ session.mount("https://", adapter)
104
+
105
+ # Set default headers with Bearer token authentication (industry standard)
106
+ # Works for both Firebase JWT tokens and RayfosJobServer API tokens
107
+ session.headers.update({
108
+ "Authorization": f"Bearer {self.config.api_token}",
109
+ "Content-Type": "application/json",
110
+ "Accept": "application/json",
111
+ })
112
+
113
+ return session
114
+
115
+ def _handle_response(self, response: requests.Response) -> Any:
116
+ """
117
+ Handle API response and raise appropriate exceptions
118
+
119
+ Args:
120
+ response: Response object from requests
121
+
122
+ Returns:
123
+ Parsed JSON response
124
+
125
+ Raises:
126
+ AuthenticationError: For 401 responses
127
+ NotFoundError: For 404 responses
128
+ ValidationError: For 400 responses
129
+ ServerError: For 5xx responses
130
+ RayfosAPIError: For other error responses
131
+ """
132
+ try:
133
+ response.raise_for_status()
134
+
135
+ # Log response details for debugging
136
+ logger.debug(f"Response status: {response.status_code}, Content-Type: {response.headers.get('Content-Type')}")
137
+
138
+ # Return None for 204 No Content
139
+ if response.status_code == 204:
140
+ logger.debug("Returning None for 204 No Content")
141
+ return None
142
+
143
+ # Try to parse JSON response
144
+ try:
145
+ json_data = response.json()
146
+ logger.debug(f"Parsed JSON response with keys: {list(json_data.keys()) if isinstance(json_data, dict) else type(json_data)}")
147
+ return json_data
148
+ except ValueError:
149
+ logger.debug(f"Could not parse JSON, returning text (length: {len(response.text)})")
150
+ return response.text
151
+
152
+ except requests.exceptions.HTTPError as e:
153
+ status_code = response.status_code
154
+
155
+ # Try to get error message from response
156
+ try:
157
+ error_data = response.json()
158
+ message = error_data.get("message") or error_data.get("error") or str(e)
159
+ except ValueError:
160
+ message = response.text or str(e)
161
+
162
+ # Raise specific exception based on status code
163
+ if status_code == 401:
164
+ raise AuthenticationError(message, status_code, response)
165
+ elif status_code == 404:
166
+ raise NotFoundError(message, status_code, response)
167
+ elif status_code == 400:
168
+ raise ValidationError(message, status_code, response)
169
+ elif status_code >= 500:
170
+ raise ServerError(message, status_code, response)
171
+ else:
172
+ raise RayfosAPIError(message, status_code, response)
173
+
174
+ def _get(self, endpoint: str, params: Optional[Dict] = None) -> Any:
175
+ """Make GET request"""
176
+ url = self.config.get_api_url(endpoint)
177
+ logger.info(f"GET {url} [Cloud Service]")
178
+ response = self.session.get(
179
+ url,
180
+ params=params,
181
+ timeout=self.config.timeout,
182
+ verify=self.config.verify_ssl,
183
+ )
184
+ return self._handle_response(response)
185
+
186
+ def _post(self, endpoint: str, data: Optional[Dict] = None, json: Optional[Dict] = None) -> Any:
187
+ """Make POST request"""
188
+ url = self.config.get_api_url(endpoint)
189
+ logger.info(f"POST {url} [Cloud Service]")
190
+ response = self.session.post(
191
+ url,
192
+ data=data,
193
+ json=json,
194
+ timeout=self.config.timeout,
195
+ verify=self.config.verify_ssl,
196
+ )
197
+ return self._handle_response(response)
198
+
199
+ def _put(self, endpoint: str, data: Optional[Dict] = None, json: Optional[Dict] = None) -> Any:
200
+ """Make PUT request"""
201
+ url = self.config.get_api_url(endpoint)
202
+ logger.info(f"PUT {url} [Cloud Service]")
203
+ response = self.session.put(
204
+ url,
205
+ data=data,
206
+ json=json,
207
+ timeout=self.config.timeout,
208
+ verify=self.config.verify_ssl,
209
+ )
210
+ return self._handle_response(response)
211
+
212
+ def _delete(self, endpoint: str) -> Any:
213
+ """Make DELETE request"""
214
+ url = self.config.get_api_url(endpoint)
215
+ logger.info(f"DELETE {url} [Cloud Service]")
216
+ response = self.session.delete(
217
+ url,
218
+ timeout=self.config.timeout,
219
+ verify=self.config.verify_ssl,
220
+ )
221
+ return self._handle_response(response)
222
+
223
+ # ========================================================================
224
+ # PROJECT MANAGEMENT METHODS
225
+ # ========================================================================
226
+
227
+ def get_projects(self) -> List[Dict[str, Any]]:
228
+ """
229
+ Get all projects for the authenticated user
230
+
231
+ Returns:
232
+ List of project dictionaries with structure matching ProjectDto
233
+
234
+ Example:
235
+ >>> projects = client.get_projects()
236
+ >>> for project in projects:
237
+ ... print(f"{project['name']}: {len(project['versions'])} versions")
238
+ """
239
+ return self._get("/api/projects")
240
+
241
+ def get_project(self, project_id: str) -> Dict[str, Any]:
242
+ """
243
+ Get a specific project by ID
244
+
245
+ Args:
246
+ project_id: Project UUID
247
+
248
+ Returns:
249
+ Project dictionary
250
+ """
251
+ return self._get(f"/api/projects/{project_id}")
252
+
253
+ def create_project(
254
+ self,
255
+ name: str,
256
+ app: str,
257
+ description: Optional[str] = None
258
+ ) -> Dict[str, Any]:
259
+ """
260
+ Create a new project
261
+
262
+ Args:
263
+ name: Project name
264
+ app: Application type (e.g., "wavesim")
265
+ description: Optional project description
266
+
267
+ Returns:
268
+ Created project dictionary
269
+
270
+ Example:
271
+ >>> project = client.create_project(
272
+ ... name="My Simulation",
273
+ ... app="wavesim",
274
+ ... description="Test simulation project"
275
+ ... )
276
+ """
277
+ data = {
278
+ "name": name,
279
+ "app": app,
280
+ }
281
+ if description:
282
+ data["description"] = description
283
+
284
+ return self._post("/api/projects", json=data)
285
+
286
+ def delete_project(self, project_id: str) -> bool:
287
+ """
288
+ Delete a project
289
+
290
+ Args:
291
+ project_id: Project UUID
292
+
293
+ Returns:
294
+ True if successful
295
+ """
296
+ self._delete(f"/api/projects/{project_id}")
297
+ return True
298
+
299
+ # ========================================================================
300
+ # PROJECT VERSION METHODS
301
+ # ========================================================================
302
+
303
+ def get_project_versions(self, project_id: str) -> List[Dict[str, Any]]:
304
+ """
305
+ Get all versions of a project
306
+
307
+ Args:
308
+ project_id: Project UUID
309
+
310
+ Returns:
311
+ List of version dictionaries
312
+ """
313
+ return self._get(f"/api/projects/{project_id}/versions")
314
+
315
+ def get_project_version(self, project_id: str, version_id: str) -> Dict[str, Any]:
316
+ """
317
+ Get a specific version with full hierarchy (command and output files)
318
+
319
+ This endpoint returns the complete ProjectVersionDtoV2 including:
320
+ - Command with OutputFiles metadata (FileMetadataDtoV2)
321
+ - ProjectVersionFiles with file metadata
322
+ - All version properties
323
+
324
+ Args:
325
+ project_id: Project UUID
326
+ version_id: Version UUID
327
+
328
+ Returns:
329
+ ProjectVersionDtoV2 dictionary with full hierarchy
330
+
331
+ Example:
332
+ >>> version = client.get_project_version(project_id, version_id)
333
+ >>> if version.get('command') and version['command'].get('outputFiles'):
334
+ ... for file in version['command']['outputFiles']:
335
+ ... print(f"Output file: {file['originalFileName']}")
336
+ """
337
+ return self._get(f"/api/projects/{project_id}/versions/{version_id}")
338
+
339
+ def create_project_version(
340
+ self,
341
+ project_id: str,
342
+ notes: str = "",
343
+ app_version: str = "1.0.0",
344
+ new_major_version: bool = False
345
+ ) -> Dict[str, Any]:
346
+ """
347
+ Create a new project version
348
+
349
+ Args:
350
+ project_id: Project UUID
351
+ notes: Version notes
352
+ app_version: Target application version
353
+ new_major_version: Whether to increment major version (default: False for minor)
354
+
355
+ Returns:
356
+ Created version dictionary
357
+ """
358
+ data = {
359
+ "Notes": notes,
360
+ "AppVersion": app_version,
361
+ "NewMajorVersion": new_major_version,
362
+ }
363
+ return self._post(f"/api/projects/{project_id}/versions", json=data)
364
+
365
+ def delete_project_version(self, project_id: str, version_id: str) -> bool:
366
+ """
367
+ Delete a project version
368
+
369
+ Args:
370
+ project_id: Project UUID
371
+ version_id: Version UUID
372
+
373
+ Returns:
374
+ True if successful
375
+ """
376
+ self._delete(f"/api/projects/{project_id}/versions/{version_id}")
377
+ return True
378
+
379
+ def update_project_properties(
380
+ self,
381
+ properties_id: str,
382
+ wavesim_properties: Dict[str, Any]
383
+ ) -> bool:
384
+ """
385
+ Update project version properties with complete WavesimProperties JSON
386
+
387
+ Args:
388
+ properties_id: Properties UUID (from version.propertiesId)
389
+ wavesim_properties: Complete WavesimProperties dictionary
390
+
391
+ Returns:
392
+ True if successful
393
+
394
+ Example:
395
+ >>> # Get version to get properties_id
396
+ >>> version = client.get_project_version(project_id, version_id)
397
+ >>> properties_id = version['propertiesId']
398
+ >>>
399
+ >>> # Build wavesim properties
400
+ >>> wavesim_props = create_wavesim_properties(...)
401
+ >>>
402
+ >>> # Update properties on server
403
+ >>> client.update_project_properties(properties_id, wavesim_props)
404
+ """
405
+ import json
406
+
407
+ # Wrap in ProjectVersionPropertiesDtoV2 structure
408
+ properties_dto = {
409
+ "Id": properties_id,
410
+ "JsonContent": json.dumps(wavesim_properties)
411
+ }
412
+
413
+ self._post(f"/api/projects/{properties_id}/propertiesUpdate", json=properties_dto)
414
+ return True
415
+
416
+ # ========================================================================
417
+ # FILE OPERATIONS
418
+ # ========================================================================
419
+
420
+ def upload_file(
421
+ self,
422
+ project_id: str,
423
+ version_id: str,
424
+ file_path: str,
425
+ file_type: str = "input",
426
+ progress_callback: Optional[callable] = None
427
+ ) -> Dict[str, Any]:
428
+ """
429
+ Upload a file to a project version
430
+
431
+ Args:
432
+ project_id: Project UUID
433
+ version_id: Version UUID
434
+ file_path: Path to the file to upload
435
+ file_type: Type of file ("input" or "output")
436
+ progress_callback: Optional callback function for upload progress
437
+
438
+ Returns:
439
+ File metadata dictionary
440
+
441
+ Example:
442
+ >>> def progress(bytes_sent, total_bytes):
443
+ ... print(f"Uploaded {bytes_sent}/{total_bytes} bytes")
444
+ >>>
445
+ >>> file_meta = client.upload_file(
446
+ ... project_id="...",
447
+ ... version_id="...",
448
+ ... file_path="/path/to/file.gds",
449
+ ... progress_callback=progress
450
+ ... )
451
+ """
452
+ file_path = Path(file_path)
453
+
454
+ if not file_path.exists():
455
+ raise FileNotFoundError(f"File not found: {file_path}")
456
+
457
+ # Get file name and size
458
+ file_name = file_path.name
459
+ file_size = file_path.stat().st_size
460
+
461
+ # Use machine service for file upload if configured
462
+ if self.config.machine_service_url:
463
+ return self._upload_file_machine_service(
464
+ project_id, version_id, file_path, file_type, progress_callback
465
+ )
466
+ else:
467
+ return self._upload_file_cloud(
468
+ project_id, version_id, file_path, file_type, progress_callback
469
+ )
470
+
471
+ def _upload_file_cloud(
472
+ self,
473
+ project_id: str,
474
+ version_id: str,
475
+ file_path: Path,
476
+ file_type: str,
477
+ progress_callback: Optional[callable]
478
+ ) -> Dict[str, Any]:
479
+ """Upload file in cloud mode (via Azure Blob Storage)"""
480
+ # Step 1: Request SAS token from server
481
+ endpoint = "/api/files/upload/token"
482
+ params = {
483
+ "ProjectId": project_id,
484
+ "VersionId": version_id,
485
+ "FileName": file_path.name,
486
+ "FileType": file_type,
487
+ "ContentType": "application/octet-stream"
488
+ }
489
+
490
+ sas_response = self._post(endpoint, json=params)
491
+ sas_url = sas_response.get("sasUrl") or sas_response.get("SasUrl")
492
+ blob_name = sas_response.get("blobName") or sas_response.get("BlobName")
493
+
494
+ if not sas_url:
495
+ raise RayfosAPIError("Server did not return SAS URL")
496
+
497
+ # Step 2: Upload directly to Azure Blob Storage
498
+ file_size = 0
499
+ with open(file_path, "rb") as f:
500
+ file_data = f.read()
501
+ file_size = len(file_data)
502
+
503
+ headers = {
504
+ "x-ms-blob-type": "BlockBlob",
505
+ }
506
+
507
+ # Upload file
508
+ upload_response = requests.put(
509
+ sas_url,
510
+ data=file_data,
511
+ headers=headers,
512
+ timeout=self.config.timeout * 10, # Longer timeout for file upload
513
+ )
514
+ upload_response.raise_for_status()
515
+
516
+ # Step 3: Notify server of completion
517
+ complete_endpoint = "/api/files/upload/complete"
518
+ complete_params = {
519
+ "FileId": blob_name, # BlobName is used as FileId
520
+ "SizeInBytes": file_size
521
+ }
522
+ result = self._post(complete_endpoint, json=complete_params)
523
+
524
+ # Add fileId to result for consistency with machine service response
525
+ if result and isinstance(result, dict):
526
+ result['fileId'] = blob_name
527
+ result['blobName'] = blob_name
528
+
529
+ return result
530
+
531
+ def _upload_file_machine_service(
532
+ self,
533
+ project_id: str,
534
+ version_id: str,
535
+ file_path: Path,
536
+ file_type: str,
537
+ progress_callback: Optional[callable]
538
+ ) -> Dict[str, Any]:
539
+ """Upload file via machine service"""
540
+ if not self.config.machine_service_url:
541
+ raise ConfigurationError("Machine service URL is not configured")
542
+
543
+ # Upload via the machine service HTTP endpoint
544
+ # Correct endpoint: /api/files/projects/{projectId}/versions/{versionId}/upload
545
+ endpoint = f"/api/files/projects/{project_id}/versions/{version_id}/upload"
546
+
547
+ with open(file_path, "rb") as f:
548
+ files = {
549
+ "file": (file_path.name, f, "application/octet-stream")
550
+ }
551
+ data = {
552
+ "fileType": file_type,
553
+ }
554
+
555
+ # Remove Content-Type header to let requests set it with boundary
556
+ headers = dict(self.session.headers)
557
+ headers.pop("Content-Type", None)
558
+
559
+ url = self.config.get_machine_service_url(endpoint)
560
+ logger.info(f"POST {url} [Machine Service]")
561
+ response = requests.post(
562
+ url,
563
+ files=files,
564
+ data=data,
565
+ headers=headers,
566
+ timeout=self.config.timeout * 10, # Longer timeout for upload
567
+ verify=self.config.verify_ssl_machine_service,
568
+ )
569
+
570
+ return self._handle_response(response)
571
+
572
+ def download_file(
573
+ self,
574
+ project_id: str,
575
+ version_id: str,
576
+ file_id: str,
577
+ output_path: str,
578
+ command_id: Optional[str] = None,
579
+ progress_callback: Optional[callable] = None
580
+ ) -> str:
581
+ """
582
+ Download a file from a project version
583
+
584
+ Args:
585
+ project_id: Project UUID
586
+ version_id: Version UUID
587
+ file_id: File UUID
588
+ output_path: Path where to save the downloaded file
589
+ command_id: Optional command ID (avoids re-fetching version for machine service)
590
+ progress_callback: Optional callback for download progress
591
+
592
+ Returns:
593
+ Path to downloaded file
594
+
595
+ Example:
596
+ >>> output = client.download_file(
597
+ ... project_id="...",
598
+ ... version_id="...",
599
+ ... file_id="...",
600
+ ... output_path="/path/to/output/file.vtk"
601
+ ... )
602
+ """
603
+ output_path = Path(output_path)
604
+ output_path.parent.mkdir(parents=True, exist_ok=True)
605
+
606
+ # Use machine service for file download if configured
607
+ if self.config.machine_service_url:
608
+ return self._download_file_machine_service(
609
+ project_id, version_id, file_id, output_path, command_id, progress_callback
610
+ )
611
+ else:
612
+ return self._download_file_cloud(
613
+ project_id, version_id, file_id, output_path, progress_callback
614
+ )
615
+
616
+ def _download_file_cloud(
617
+ self,
618
+ project_id: str,
619
+ version_id: str,
620
+ file_id: str,
621
+ output_path: Path,
622
+ progress_callback: Optional[callable]
623
+ ) -> str:
624
+ """Download file in cloud mode (via Azure Blob Storage)"""
625
+ # Step 1: Request SAS token for download
626
+ # file_id is a blob name - URL-encode it for slashes
627
+ from urllib.parse import quote
628
+ encoded_blob_name = quote(file_id, safe='')
629
+ endpoint = f"/api/files/download/token/{encoded_blob_name}"
630
+ sas_response = self._get(endpoint)
631
+
632
+ # Server may return dict with sasUrl key, or URL string directly
633
+ if isinstance(sas_response, str):
634
+ sas_url = sas_response
635
+ elif isinstance(sas_response, dict):
636
+ sas_url = sas_response.get("sasUrl") or sas_response.get("SasUrl")
637
+ else:
638
+ sas_url = None
639
+
640
+ if not sas_url:
641
+ raise RayfosAPIError("Server did not return SAS URL for download")
642
+
643
+ # Step 2: Download from Azure Blob Storage
644
+ response = requests.get(sas_url, stream=True, timeout=self.config.timeout * 10)
645
+ response.raise_for_status()
646
+
647
+ # Write to file with progress tracking
648
+ total_size = int(response.headers.get("content-length", 0))
649
+ bytes_downloaded = 0
650
+
651
+ # Use larger chunk size (1MB) for better performance on local networks
652
+ chunk_size = 1024 * 1024 # 1MB chunks
653
+
654
+ with open(output_path, "wb", buffering=8*1024*1024) as f: # 8MB buffer
655
+ for chunk in response.iter_content(chunk_size=chunk_size):
656
+ if chunk:
657
+ f.write(chunk)
658
+ bytes_downloaded += len(chunk)
659
+ if progress_callback:
660
+ progress_callback(bytes_downloaded, total_size)
661
+
662
+ return str(output_path)
663
+
664
+ def _download_file_machine_service(
665
+ self,
666
+ project_id: str,
667
+ version_id: str,
668
+ file_id: str,
669
+ output_path: Path,
670
+ command_id: Optional[str],
671
+ progress_callback: Optional[callable]
672
+ ) -> str:
673
+ """Download file via machine service"""
674
+ if not self.config.machine_service_url:
675
+ raise ConfigurationError("Machine service URL is not configured")
676
+
677
+ # Get commandId if not provided
678
+ if not command_id:
679
+ version = self.get_project_version(project_id, version_id)
680
+ if version.get('command') and version['command'].get('id'):
681
+ command_id = version['command']['id']
682
+ elif version.get('commandId'):
683
+ command_id = version['commandId']
684
+ else:
685
+ raise ConfigurationError("Version does not have an associated command")
686
+
687
+ # Machine service endpoint: /api/files/commands/{commandId}/files/{fileId}/download
688
+ endpoint = f"/api/files/commands/{command_id}/files/{file_id}/download"
689
+ url = self.config.get_machine_service_url(endpoint)
690
+ logger.info(f"GET {url} [Machine Service]")
691
+
692
+ response = self.session.get(
693
+ url,
694
+ stream=True,
695
+ timeout=self.config.timeout * 10,
696
+ verify=self.config.verify_ssl_machine_service,
697
+ )
698
+ response.raise_for_status()
699
+
700
+ # Write to file
701
+ total_size = int(response.headers.get("content-length", 0))
702
+ bytes_downloaded = 0
703
+
704
+ # Use larger chunk size (1MB) for better performance on local networks
705
+ chunk_size = 1024 * 1024 # 1MB chunks
706
+
707
+ with open(output_path, "wb", buffering=8*1024*1024) as f: # 8MB buffer
708
+ for chunk in response.iter_content(chunk_size=chunk_size):
709
+ if chunk:
710
+ f.write(chunk)
711
+ bytes_downloaded += len(chunk)
712
+ if progress_callback:
713
+ progress_callback(bytes_downloaded, total_size)
714
+
715
+ return str(output_path)
716
+
717
+ def download_file_data(
718
+ self,
719
+ project_id: str,
720
+ version_id: str,
721
+ file_id: str,
722
+ command_id: Optional[str] = None
723
+ ) -> bytes:
724
+ """
725
+ Download a file directly to memory (returns bytes)
726
+
727
+ Args:
728
+ project_id: Project UUID
729
+ version_id: Version UUID
730
+ file_id: File UUID
731
+ command_id: Optional command ID (avoids re-fetching version for machine service)
732
+
733
+ Returns:
734
+ File contents as bytes
735
+
736
+ Example:
737
+ >>> data = client.download_file_data(project_id, version_id, file_id)
738
+ >>> # Use data directly without saving to disk
739
+ >>> field = read_binary_field(data, dimensions, 'Complex64')
740
+ """
741
+ if self.config.machine_service_url:
742
+ return self._download_file_data_machine_service(
743
+ project_id, version_id, file_id, command_id
744
+ )
745
+ else:
746
+ return self._download_file_data_cloud(
747
+ project_id, version_id, file_id
748
+ )
749
+
750
+ def _download_file_data_cloud(
751
+ self,
752
+ project_id: str,
753
+ version_id: str,
754
+ file_id: str
755
+ ) -> bytes:
756
+ """Download file data in cloud mode (via Azure Blob Storage)"""
757
+ # Step 1: Request SAS token for download
758
+ # file_id is a blob name - URL-encode it for slashes
759
+ from urllib.parse import quote
760
+ encoded_blob_name = quote(file_id, safe='')
761
+ endpoint = f"/api/files/download/token/{encoded_blob_name}"
762
+ sas_response = self._get(endpoint)
763
+ if isinstance(sas_response, str):
764
+ sas_url = sas_response
765
+ elif isinstance(sas_response, dict):
766
+ sas_url = sas_response.get("sasUrl") or sas_response.get("SasUrl")
767
+ else:
768
+ sas_url = None
769
+
770
+ if not sas_url:
771
+ raise RayfosAPIError("Server did not return SAS URL for download")
772
+
773
+ # Step 2: Download from Azure Blob Storage
774
+ response = requests.get(sas_url, timeout=self.config.timeout * 10)
775
+ response.raise_for_status()
776
+
777
+ return response.content
778
+
779
+ def _download_file_data_machine_service(
780
+ self,
781
+ project_id: str,
782
+ version_id: str,
783
+ file_id: str,
784
+ command_id: Optional[str]
785
+ ) -> bytes:
786
+ """Download file data via machine service"""
787
+ if not self.config.machine_service_url:
788
+ raise ConfigurationError("Machine service URL is not configured")
789
+
790
+ # Get commandId if not provided
791
+ if not command_id:
792
+ version = self.get_project_version(project_id, version_id)
793
+ if version.get('command') and version['command'].get('id'):
794
+ command_id = version['command']['id']
795
+ elif version.get('commandId'):
796
+ command_id = version['commandId']
797
+ else:
798
+ raise ConfigurationError("Version does not have an associated command")
799
+
800
+ # Machine service endpoint: /api/files/commands/{commandId}/files/{fileId}/download
801
+ endpoint = f"/api/files/commands/{command_id}/files/{file_id}/download"
802
+ url = self.config.get_machine_service_url(endpoint)
803
+ logger.info(f"GET {url} [Machine Service]")
804
+
805
+ response = self.session.get(
806
+ url,
807
+ timeout=self.config.timeout * 10,
808
+ verify=self.config.verify_ssl_machine_service,
809
+ )
810
+ response.raise_for_status()
811
+
812
+ return response.content
813
+
814
+ def get_file_info(self, project_id: str, version_id: str, file_id: str) -> Dict[str, Any]:
815
+ """
816
+ Get file metadata
817
+
818
+ Args:
819
+ project_id: Project UUID
820
+ version_id: Version UUID
821
+ file_id: File UUID
822
+
823
+ Returns:
824
+ File metadata dictionary
825
+ """
826
+ return self._get(f"/api/projects/{project_id}/versions/{version_id}/files/{file_id}")
827
+
828
+ # ========================================================================
829
+ # COMMAND OPERATIONS
830
+ # ========================================================================
831
+
832
+ def get_commands(self) -> List[Dict[str, Any]]:
833
+ """
834
+ Get all commands for the authenticated user
835
+
836
+ Returns:
837
+ List of command dictionaries
838
+ """
839
+ return self._get("/api/commands")
840
+
841
+ def get_command(self, command_id: str) -> Dict[str, Any]:
842
+ """
843
+ Get a specific command
844
+
845
+ Args:
846
+ command_id: Command UUID
847
+
848
+ Returns:
849
+ Command dictionary
850
+ """
851
+ return self._get(f"/api/commands/{command_id}")
852
+
853
+ def submit_command(
854
+ self,
855
+ project_id: str,
856
+ version_id: str,
857
+ command_type: str,
858
+ parameters: Optional[Dict[str, Any]] = None
859
+ ) -> Dict[str, Any]:
860
+ """
861
+ Submit a command for execution
862
+
863
+ Args:
864
+ project_id: Project UUID
865
+ version_id: Version UUID
866
+ command_type: Type of command (e.g., "simulation", "voxelization")
867
+ parameters: Command parameters
868
+
869
+ Returns:
870
+ Created command dictionary
871
+ """
872
+ data = {
873
+ "projectId": project_id,
874
+ "versionId": version_id,
875
+ "commandType": command_type,
876
+ "parameters": parameters or {},
877
+ }
878
+ return self._post("/api/commands", json=data)
879
+
880
+ def cancel_command(self, command_id: str) -> bool:
881
+ """
882
+ Cancel a running command
883
+
884
+ Args:
885
+ command_id: Command UUID
886
+
887
+ Returns:
888
+ True if successful
889
+ """
890
+ self._post(f"/api/commands/{command_id}/cancel")
891
+ return True
892
+
893
+ def create_command(
894
+ self,
895
+ project_id: str,
896
+ version_id: str,
897
+ engine_preference: str = 'Any',
898
+ use_gpu: Optional[bool] = None,
899
+ ) -> Dict[str, Any]:
900
+ """
901
+ Create a simulation command for a project version.
902
+
903
+ The command will read WavesimProperties from the project version's properties
904
+ that were previously saved using update_project_properties().
905
+
906
+ WORKFLOW:
907
+ 1. First save properties: client.update_project_properties(properties_id, wavesim_props)
908
+ 2. Then create command: client.create_command(project_id, version_id)
909
+ 3. Command reads properties from database automatically
910
+
911
+ Args:
912
+ project_id: Project UUID
913
+ version_id: Version UUID
914
+ engine_preference: Engine to use — 'Any' (default), 'Python', or 'Cpp'.
915
+ 'Any' lets the job server pick the best available machine.
916
+ use_gpu: GPU selection — None (auto, default), True (require GPU), False (force CPU).
917
+ Ignored when using a local machine service URL.
918
+
919
+ Returns:
920
+ Command status dictionary with the created command ID and status
921
+
922
+ Example:
923
+ >>> command = client.create_command(project_id, version_id,
924
+ ... engine_preference='Python', use_gpu=True)
925
+ >>> print(f"Command created: {command['id']}")
926
+ """
927
+ if self.config.machine_service_url:
928
+ return self._create_command_machine_service(project_id, version_id)
929
+ else:
930
+ return self._create_command_cloud(project_id, version_id, engine_preference, use_gpu)
931
+
932
+ def _create_command_machine_service(
933
+ self,
934
+ project_id: str,
935
+ version_id: str
936
+ ) -> Dict[str, Any]:
937
+ """Create command via RayfosMachineService (local mode)"""
938
+ if not self.config.machine_service_url:
939
+ raise ConfigurationError("Machine service URL is not configured")
940
+
941
+ # Use the machine service command creation endpoint with route parameters
942
+ endpoint = f"/api/commands/{project_id}/{version_id}"
943
+
944
+ url = f"{self.config.machine_service_url.rstrip('/')}{endpoint}"
945
+ logger.info(f"POST {url} [Machine Service]")
946
+ response = self.session.post(
947
+ url,
948
+ timeout=self.config.timeout,
949
+ verify=self.config.verify_ssl_machine_service,
950
+ )
951
+ return self._handle_response(response)
952
+
953
+ def _create_command_cloud(
954
+ self,
955
+ project_id: str,
956
+ version_id: str,
957
+ engine_preference: str = 'Any',
958
+ use_gpu: Optional[bool] = None,
959
+ ) -> Dict[str, Any]:
960
+ """Create command via RayfosJobServer (cloud mode)"""
961
+ data: Dict[str, Any] = {}
962
+ if engine_preference and engine_preference != 'Any':
963
+ data['enginePreference'] = engine_preference
964
+ if use_gpu is not None:
965
+ data['useGpu'] = use_gpu
966
+ return self._post(f"/api/projects/{project_id}/versions/{version_id}/command", json=data)
967
+
968
+ # ========================================================================
969
+ # COMMAND EXECUTION
970
+ # ========================================================================
971
+
972
+ @staticmethod
973
+ def _parse_command_status(status_raw) -> str:
974
+ """
975
+ Parse commandStatus from C# enum (integer) to string name
976
+
977
+ Args:
978
+ status_raw: Either integer enum value or string name
979
+
980
+ Returns:
981
+ String name of the status
982
+ """
983
+ # CommandStatus enum from C# RayfosCommandModels
984
+ COMMAND_STATUS_MAP = {
985
+ 0: 'New',
986
+ 1: 'Assigned',
987
+ 2: 'SentToMachine',
988
+ 3: 'SendToMachineForceCancellation',
989
+ 4: 'SendToMachineForceDeleteTask',
990
+ 5: 'RequestedForceCancellationInMachine',
991
+ 6: 'RequestedForceStopTaskInMachine',
992
+ 7: 'Cancelled',
993
+ 8: 'Deleted',
994
+ 9: 'Running',
995
+ 10: 'Completed',
996
+ 11: 'Failed',
997
+ 12: 'FailedRemovedFromMachine'
998
+ }
999
+
1000
+ if isinstance(status_raw, int):
1001
+ return COMMAND_STATUS_MAP.get(status_raw, f'Unknown({status_raw})')
1002
+ return status_raw
1003
+
1004
+ @staticmethod
1005
+ def _parse_progress_data(progress_obj) -> Dict[str, Any]:
1006
+ """
1007
+ Parse CommandProgressDto with nested JSON strings
1008
+
1009
+ Args:
1010
+ progress_obj: CommandProgressDto dictionary with Status, Progress, Data fields
1011
+
1012
+ Returns:
1013
+ Dictionary with parsed progress information
1014
+ {
1015
+ 'status': str, # e.g., "Running"
1016
+ 'iteration': int, # Current iteration
1017
+ 'percentage': float, # Progress percentage (0-100)
1018
+ 'max_iterations': int, # Total iterations
1019
+ 'residual_norm': float, # Current residual (if available)
1020
+ 'threshold': float, # Target threshold (if available)
1021
+ 'raw_progress': str, # Original Progress JSON string
1022
+ 'raw_data': str # Original Data JSON string
1023
+ }
1024
+ """
1025
+ import json
1026
+
1027
+ if not progress_obj:
1028
+ return {}
1029
+
1030
+ result = {
1031
+ 'status': progress_obj.get('status', 'Unknown'),
1032
+ 'raw_progress': progress_obj.get('progress', '{}'),
1033
+ 'raw_data': progress_obj.get('data', '{}')
1034
+ }
1035
+
1036
+ # Parse Progress JSON string: {"iteration": 83, "percentage": 100.0, "max_iterations": 10000}
1037
+ try:
1038
+ if isinstance(progress_obj.get('progress'), str):
1039
+ progress_data = json.loads(progress_obj['progress'])
1040
+ result['iteration'] = progress_data.get('iteration', 0)
1041
+ result['percentage'] = progress_data.get('percentage', 0.0)
1042
+ result['max_iterations'] = progress_data.get('max_iterations', 0)
1043
+ elif isinstance(progress_obj.get('progress'), dict):
1044
+ # Already parsed
1045
+ result['iteration'] = progress_obj['progress'].get('iteration', 0)
1046
+ result['percentage'] = progress_obj['progress'].get('percentage', 0.0)
1047
+ result['max_iterations'] = progress_obj['progress'].get('max_iterations', 0)
1048
+ except (json.JSONDecodeError, KeyError, TypeError):
1049
+ pass
1050
+
1051
+ # Parse Data JSON string: {"residual_norm": 9.74e-07, "residual_norm_threshold": 1e-06}
1052
+ try:
1053
+ if isinstance(progress_obj.get('data'), str):
1054
+ data_obj = json.loads(progress_obj['data'])
1055
+ result['residual_norm'] = data_obj.get('residual_norm')
1056
+ result['threshold'] = data_obj.get('residual_norm_threshold')
1057
+ elif isinstance(progress_obj.get('data'), dict):
1058
+ # Already parsed
1059
+ result['residual_norm'] = progress_obj['data'].get('residual_norm')
1060
+ result['threshold'] = progress_obj['data'].get('residual_norm_threshold')
1061
+ except (json.JSONDecodeError, KeyError, TypeError):
1062
+ pass
1063
+
1064
+ return result
1065
+
1066
+ def get_command_status(self, command_id: str) -> Dict[str, Any]:
1067
+ """
1068
+ Get the current status of a command
1069
+
1070
+ This method polls the command status to check its progress.
1071
+ Always calls RayfosJobServer directly (regardless of local/cloud mode).
1072
+
1073
+ Args:
1074
+ command_id: Command UUID
1075
+
1076
+ Returns:
1077
+ CommandStatusDto dictionary with status, progress, and output files
1078
+ Note: commandStatus is returned as integer from C#, use _parse_command_status() to convert
1079
+
1080
+ Example:
1081
+ >>> status = client.get_command_status("command-uuid")
1082
+ >>> status_name = client._parse_command_status(status['commandStatus'])
1083
+ >>> print(f"Status: {status_name}")
1084
+ >>> print(f"Progress: {status.get('progressValue', 0)}%")
1085
+ >>> if status['commandStatus'] == 10: # Completed
1086
+ ... print(f"Output files: {status.get('outputFileIds', [])}")
1087
+ """
1088
+ # Always call RayfosJobServer directly for command status (user endpoint)
1089
+ # Machine service is only for file operations, not command status
1090
+ return self._get(f"/api/user/commands/{command_id}")
1091
+
1092
+ def wait_for_command_completion(
1093
+ self,
1094
+ command_id: str,
1095
+ poll_interval: float = 2.0,
1096
+ timeout: Optional[float] = None,
1097
+ progress_callback: Optional[callable] = None
1098
+ ) -> Dict[str, Any]:
1099
+ """
1100
+ Wait for a command to complete (synchronous execution)
1101
+
1102
+ This method polls the command status until it reaches a terminal state
1103
+ (Completed, Failed, Cancelled). Useful for blocking until simulation finishes.
1104
+
1105
+ Args:
1106
+ command_id: Command UUID to wait for
1107
+ poll_interval: Seconds between status checks (default: 2.0)
1108
+ timeout: Maximum seconds to wait before raising TimeoutError (default: None - no timeout)
1109
+ progress_callback: Optional callback(status_dict) called on each status check
1110
+
1111
+ Returns:
1112
+ Final CommandStatusDto dictionary when command completes
1113
+
1114
+ Raises:
1115
+ TimeoutError: If timeout is reached before completion
1116
+ RayfosAPIError: If command fails or is cancelled
1117
+
1118
+ Example:
1119
+ >>> # Create command
1120
+ >>> command = client.create_command(project_id, version_id, args_json)
1121
+ >>>
1122
+ >>> # Wait for completion with progress updates
1123
+ >>> def show_progress(status):
1124
+ ... print(f"Progress: {status.get('progressValue', 0)}% - {status.get('statusText', 'Running')}")
1125
+ >>>
1126
+ >>> final_status = client.wait_for_command_completion(
1127
+ ... command['id'],
1128
+ ... progress_callback=show_progress,
1129
+ ... timeout=3600 # 1 hour timeout
1130
+ ... )
1131
+ >>> print(f"Command completed! Output files: {final_status.get('outputFileIds', [])}")
1132
+ """
1133
+ import time
1134
+
1135
+ # CommandStatus enum values from C# (serialized as integers)
1136
+ # New=0, Assigned=1, SentToMachine=2, etc.
1137
+ COMMAND_STATUS = {
1138
+ 0: 'New',
1139
+ 1: 'Assigned',
1140
+ 2: 'SentToMachine',
1141
+ 3: 'SendToMachineForceCancellation',
1142
+ 4: 'SendToMachineForceDeleteTask',
1143
+ 5: 'RequestedForceCancellationInMachine',
1144
+ 6: 'RequestedForceStopTaskInMachine',
1145
+ 7: 'Cancelled',
1146
+ 8: 'Deleted',
1147
+ 9: 'Running',
1148
+ 10: 'Completed',
1149
+ 11: 'Failed',
1150
+ 12: 'FailedRemovedFromMachine'
1151
+ }
1152
+
1153
+ # Terminal states (by integer value and name)
1154
+ TERMINAL_STATUS_INTS = {7, 8, 10, 11, 12} # Cancelled, Deleted, Completed, Failed, FailedRemovedFromMachine
1155
+ TERMINAL_STATUS_NAMES = {'Cancelled', 'Deleted', 'Completed', 'Failed', 'FailedRemovedFromMachine'}
1156
+
1157
+ start_time = time.time()
1158
+
1159
+ while True:
1160
+ # Check timeout
1161
+ if timeout and (time.time() - start_time) > timeout:
1162
+ raise TimeoutError(f"Command {command_id} did not complete within {timeout} seconds")
1163
+
1164
+ # Get current status
1165
+ status = self.get_command_status(command_id)
1166
+ command_status_raw = status.get('commandStatus')
1167
+
1168
+ # Handle both integer (from C# enum) and string formats
1169
+ if isinstance(command_status_raw, int):
1170
+ command_status = COMMAND_STATUS.get(command_status_raw, f'Unknown({command_status_raw})')
1171
+ is_terminal = command_status_raw in TERMINAL_STATUS_INTS
1172
+ else:
1173
+ command_status = command_status_raw
1174
+ is_terminal = command_status in TERMINAL_STATUS_NAMES
1175
+
1176
+ # Call progress callback if provided
1177
+ if progress_callback:
1178
+ progress_callback(status)
1179
+
1180
+ # Check if command reached terminal state
1181
+ if is_terminal:
1182
+ if command_status in ('Completed', 10):
1183
+ return status
1184
+ elif command_status in ('Failed', 'FailedRemovedFromMachine', 11, 12):
1185
+ error_msg = status.get('statusText', 'Command failed')
1186
+ raise RayfosAPIError(f"Command {command_id} failed: {error_msg}")
1187
+ elif command_status in ('Cancelled', 7):
1188
+ raise RayfosAPIError(f"Command {command_id} was cancelled")
1189
+ elif command_status in ('Deleted', 8):
1190
+ raise RayfosAPIError(f"Command {command_id} was deleted")
1191
+
1192
+ # Wait before next poll
1193
+ time.sleep(poll_interval)
1194
+
1195
+ def create_command_and_wait(
1196
+ self,
1197
+ project_id: str,
1198
+ version_id: str,
1199
+ local_machine_service_id: Optional[str] = None,
1200
+ poll_interval: float = 2.0,
1201
+ timeout: Optional[float] = None,
1202
+ progress_callback: Optional[callable] = None
1203
+ ) -> Dict[str, Any]:
1204
+ """
1205
+ Create a command and wait for it to complete (synchronous execution)
1206
+
1207
+ This is a convenience method that combines create_command() and
1208
+ wait_for_command_completion() into a single call.
1209
+
1210
+ Args:
1211
+ project_id: Project UUID
1212
+ version_id: Version UUID
1213
+ local_machine_service_id: Optional machine service ID for local execution
1214
+ poll_interval: Seconds between status checks (default: 2.0)
1215
+ timeout: Maximum seconds to wait (default: None - no timeout)
1216
+ progress_callback: Optional callback(status_dict) for progress updates
1217
+
1218
+ Returns:
1219
+ Final CommandStatusDto dictionary when command completes
1220
+
1221
+ Raises:
1222
+ TimeoutError: If timeout is reached before completion
1223
+ RayfosAPIError: If command fails or is cancelled
1224
+
1225
+ Example:
1226
+ >>> # First update properties
1227
+ >>> client.update_project_properties(properties_id, wavesim_props)
1228
+ >>>
1229
+ >>> # This will block until simulation completes
1230
+ >>> result = client.create_command_and_wait(
1231
+ ... project_id="...",
1232
+ ... version_id="...",
1233
+ ... progress_callback=lambda s: print(f"Progress: {s.get('progressValue', 0)}%")
1234
+ ... )
1235
+ >>>
1236
+ >>> # Download output files
1237
+ >>> for file_id in result.get('outputFileIds', []):
1238
+ ... client.download_file(project_id, version_id, file_id, f"output/{file_id}")
1239
+ """
1240
+ # Create the command (async)
1241
+ command_status = self.create_command(project_id, version_id, local_machine_service_id)
1242
+ command_id = command_status.get('id')
1243
+
1244
+ if not command_id:
1245
+ raise RayfosAPIError("Failed to create command: no command ID returned")
1246
+
1247
+ # Wait for completion (sync)
1248
+ return self.wait_for_command_completion(
1249
+ command_id,
1250
+ poll_interval=poll_interval,
1251
+ timeout=timeout,
1252
+ progress_callback=progress_callback
1253
+ )
1254
+
1255
+ def download_command_files(
1256
+ self,
1257
+ command_id: str,
1258
+ output_dir: str,
1259
+ file_pattern: Optional[str] = None
1260
+ ) -> List[str]:
1261
+ """
1262
+ Download all output files from a completed command
1263
+
1264
+ This method lists all files associated with a command and downloads them
1265
+ to the specified directory. Only works with local machine service mode.
1266
+
1267
+ Args:
1268
+ command_id: Command UUID
1269
+ output_dir: Directory where to save downloaded files
1270
+ file_pattern: Optional glob pattern to filter files (e.g., "*.vtk")
1271
+
1272
+ Returns:
1273
+ List of paths to downloaded files
1274
+
1275
+ Raises:
1276
+ ConfigurationError: If machine service URL is not configured
1277
+
1278
+ Example:
1279
+ >>> # After command completes
1280
+ >>> downloaded = client.download_command_files(
1281
+ ... command_id="...",
1282
+ ... output_dir="./results",
1283
+ ... file_pattern="*.vtk" # Only download VTK files
1284
+ ... )
1285
+ >>> print(f"Downloaded {len(downloaded)} files")
1286
+ """
1287
+ if not self.config.machine_service_url:
1288
+ raise ConfigurationError(
1289
+ "download_command_files() only works with machine service URL configured"
1290
+ )
1291
+
1292
+ import fnmatch
1293
+ from pathlib import Path
1294
+
1295
+ output_path = Path(output_dir)
1296
+ output_path.mkdir(parents=True, exist_ok=True)
1297
+
1298
+ # List all files for this command
1299
+ endpoint = f"/api/files/commands/{command_id}/files"
1300
+ url = self.config.get_machine_service_url(endpoint)
1301
+ headers = dict(self.session.headers)
1302
+ logger.info(f"GET {url} [Machine Service]")
1303
+
1304
+ response = requests.get(
1305
+ url,
1306
+ headers=headers,
1307
+ timeout=self.config.timeout,
1308
+ verify=self.config.verify_ssl_machine_service,
1309
+ )
1310
+
1311
+ files = self._handle_response(response)
1312
+
1313
+ if not files:
1314
+ return []
1315
+
1316
+ # Filter files by pattern if provided
1317
+ if file_pattern:
1318
+ files = [f for f in files if fnmatch.fnmatch(f.get('fileName', ''), file_pattern)]
1319
+
1320
+ # Download each file
1321
+ downloaded_paths = []
1322
+ for file_info in files:
1323
+ file_id = file_info.get('blobName')
1324
+ file_name = file_info.get('fileName', file_id)
1325
+
1326
+ if not file_id:
1327
+ continue
1328
+
1329
+ # Download file by ID using the local files endpoint (requires commandId)
1330
+ download_endpoint = f"/api/files/commands/{command_id}/files/{file_id}/download"
1331
+ download_url = self.config.get_machine_service_url(download_endpoint)
1332
+ logger.info(f"GET {download_url} [Machine Service] - Downloading {file_name}")
1333
+
1334
+ download_response = requests.get(
1335
+ download_url,
1336
+ headers=headers,
1337
+ stream=True,
1338
+ timeout=self.config.timeout * 10,
1339
+ verify=self.config.verify_ssl_machine_service,
1340
+ )
1341
+
1342
+ if download_response.status_code == 200:
1343
+ file_path = output_path / file_name
1344
+ # Use larger chunk size (1MB) for better performance
1345
+ chunk_size = 1024 * 1024 # 1MB chunks
1346
+ with open(file_path, 'wb', buffering=8*1024*1024) as f: # 8MB buffer
1347
+ for chunk in download_response.iter_content(chunk_size=chunk_size):
1348
+ if chunk:
1349
+ f.write(chunk)
1350
+
1351
+ downloaded_paths.append(str(file_path))
1352
+
1353
+ return downloaded_paths
1354
+
1355
+ # ========================================================================
1356
+ # UTILITY METHODS
1357
+ # ========================================================================
1358
+
1359
+ def test_connection(self) -> bool:
1360
+ """
1361
+ Test the connection to the server
1362
+
1363
+ Returns:
1364
+ True if connection is successful
1365
+
1366
+ Raises:
1367
+ RayfosAPIError: If connection fails
1368
+ """
1369
+ try:
1370
+ # Try to fetch projects as a connection test
1371
+ self.get_projects()
1372
+ return True
1373
+ except Exception as e:
1374
+ raise RayfosAPIError(f"Connection test failed: {str(e)}")
1375
+
1376
+ def get_server_info(self) -> Dict[str, Any]:
1377
+ """
1378
+ Get server information
1379
+
1380
+ Returns:
1381
+ Server information dictionary
1382
+ """
1383
+ return {
1384
+ "server_url": self.config.server_url,
1385
+ "machine_service_url": self.config.machine_service_url,
1386
+ "uses_machine_service": self.config.machine_service_url is not None,
1387
+ }
1388
+
1389
+ def get_file_preview(
1390
+ self, project_id: str, version_id: str, file_id: str
1391
+ ) -> Dict[str, Any]:
1392
+ """
1393
+ Get file preview/metadata for a file in a project version.
1394
+
1395
+ Used in cloud mode to look up file details (e.g., originalFileName)
1396
+ when searching for output.bin among outputFileIds.
1397
+
1398
+ Args:
1399
+ project_id: Project UUID
1400
+ version_id: Version UUID
1401
+ file_id: File UUID
1402
+
1403
+ Returns:
1404
+ dict: File metadata including originalFileName, blobName, etc.
1405
+ """
1406
+ return self._get(
1407
+ f"/api/Projects/{project_id}/versions/{version_id}/preview/{file_id}"
1408
+ )
1409
+
1410
+ def list_command_files(self, command_id: str) -> List[Dict[str, Any]]:
1411
+ """
1412
+ List files produced by a command (machine service mode).
1413
+
1414
+ Args:
1415
+ command_id: Command UUID
1416
+
1417
+ Returns:
1418
+ list: File metadata dicts with fileName, blobName, etc.
1419
+ """
1420
+ if not self.config.machine_service_url:
1421
+ raise ConfigurationError(
1422
+ "machine_service_url is required for list_command_files"
1423
+ )
1424
+ url = f"{self.config.machine_service_url}/api/files/commands/{command_id}/files"
1425
+ logger.info(f"GET {url} [Machine Service]")
1426
+ response = self.session.get(
1427
+ url,
1428
+ timeout=self.config.timeout,
1429
+ verify=self.config.verify_ssl_machine_service,
1430
+ )
1431
+ return self._handle_response(response)
1432
+
1433
+ def parse_command_status(self, status_value: int) -> str:
1434
+ """
1435
+ Parse commandStatus from C# enum (integer) to string name
1436
+
1437
+ Args:
1438
+ status_value: Integer status code from CommandStatusDto
1439
+
1440
+ Returns:
1441
+ Human-readable status name
1442
+
1443
+ CommandStatus enum values from C# RayfosCommandModels:
1444
+ 0 = New
1445
+ 1 = Assigned
1446
+ 2 = SentToMachine
1447
+ 3 = SendToMachineForceCancellation
1448
+ 4 = SendToMachineForceDeleteTask
1449
+ 5 = RequestedForceCancellationInMachine
1450
+ 6 = RequestedForceStopTaskInMachine
1451
+ 7 = Cancelled
1452
+ 8 = Deleted
1453
+ 9 = Running
1454
+ 10 = Completed
1455
+ 11 = Failed
1456
+ 12 = FailedRemovedFromMachine
1457
+ """
1458
+ status_map = {
1459
+ 0: "New",
1460
+ 1: "Assigned",
1461
+ 2: "SentToMachine",
1462
+ 3: "SendToMachineForceCancellation",
1463
+ 4: "SendToMachineForceDeleteTask",
1464
+ 5: "RequestedForceCancellationInMachine",
1465
+ 6: "RequestedForceStopTaskInMachine",
1466
+ 7: "Cancelled",
1467
+ 8: "Deleted",
1468
+ 9: "Running",
1469
+ 10: "Completed",
1470
+ 11: "Failed",
1471
+ 12: "FailedRemovedFromMachine",
1472
+ }
1473
+
1474
+ return status_map.get(status_value, f"Unknown({status_value})")
1475
+