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/__init__.py +98 -0
- wavesimpro/__main__.py +29 -0
- wavesimpro/binary_utils.py +936 -0
- wavesimpro/client.py +1475 -0
- wavesimpro/config.py +123 -0
- wavesimpro/exceptions.py +40 -0
- wavesimpro/execute.py +461 -0
- wavesimpro/json_helper.py +755 -0
- wavesimpro/py.typed +4 -0
- wavesimpro/setup.py +162 -0
- wavesimpro/simulate.py +162 -0
- wavesimpro/validate.py +251 -0
- wavesimpro-0.9.0.dist-info/METADATA +21 -0
- wavesimpro-0.9.0.dist-info/RECORD +16 -0
- wavesimpro-0.9.0.dist-info/WHEEL +5 -0
- wavesimpro-0.9.0.dist-info/top_level.txt +1 -0
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
|
+
|