psr-factory 4.1.0b5__py3-none-manylinux_2_28_x86_64.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.
psr/cloud/cloud.py ADDED
@@ -0,0 +1,1079 @@
1
+ # PSR Cloud. Copyright (C) PSR, Inc - All Rights Reserved
2
+ # Unauthorized copying of this file, via any medium is strictly prohibited
3
+ # Proprietary and confidential
4
+
5
+ import copy
6
+ import functools
7
+ import hashlib
8
+ import logging
9
+ import os
10
+ import re
11
+ import subprocess
12
+ import sys
13
+ import warnings
14
+ import xml.etree.ElementTree as ET
15
+ from datetime import datetime, timedelta
16
+ from pathlib import Path
17
+ from time import sleep, time
18
+ from typing import List, Optional, Union
19
+
20
+ import pefile
21
+ import zeep
22
+ from filelock import FileLock
23
+
24
+ from .data import Case, CloudError, CloudInputError
25
+ from .desktop import import_case
26
+ from .log import enable_log_timestamp, get_logger
27
+ from .status import FAULTY_TERMINATION_STATUS, STATUS_MAP_TEXT, ExecutionStatus
28
+ from .tempfile import CreateTempFile
29
+ from .version import __version__
30
+ from .xml import create_case_xml
31
+
32
+ INTERFACE_VERSION = "PyCloud " + __version__ + ", binding for " + sys.version
33
+
34
+
35
+ def thread_safe():
36
+ """
37
+ Decorator to make a function thread-safe using filelock.
38
+ :param lock_file: Path to the lock file. If None, it will be automatically generated.
39
+ """
40
+
41
+ def decorator(func):
42
+ @functools.wraps(func)
43
+ def wrapper(*args, **kwargs):
44
+ with FileLock("pycloud.lock"):
45
+ return func(*args, **kwargs)
46
+
47
+ return wrapper
48
+
49
+ return decorator
50
+
51
+
52
+ def _md5sum(value: str) -> str:
53
+ return hashlib.md5(value.encode("utf-8")).hexdigest() # nosec
54
+
55
+
56
+ def hash_password(password: str) -> str:
57
+ return _md5sum(password).upper()
58
+
59
+
60
+ def _check_for_errors(
61
+ xml: ET.ElementTree, logger: Optional[logging.Logger] = None
62
+ ) -> None:
63
+ error = xml.find("./Parametro[@nome='erro']")
64
+ if error is not None:
65
+ if logger is not None:
66
+ logger.error(error.text)
67
+ raise CloudError(error.text)
68
+
69
+
70
+ def _hide_password(params: str) -> str:
71
+ pattern = r'(<Parametro nome="senha"[^>]*>)(.*?)(</Parametro>)'
72
+ result = re.sub(pattern, r"\1********\3", params)
73
+ return result
74
+
75
+
76
+ def _xml_to_str(xml_content: ET.ElementTree) -> str:
77
+ # Remove <Parametro nome="senha" ...> tag, if found in the xml_content
78
+ xml_str = ET.tostring(
79
+ xml_content.getroot(), encoding="utf-8", method="xml"
80
+ ).decode()
81
+ return _hide_password(xml_str)
82
+
83
+
84
+ def _handle_relative_path(path: str) -> str:
85
+ if not os.path.isabs(path):
86
+ return os.path.abspath(path)
87
+ return path
88
+
89
+
90
+ _PSRCLOUD_PATH = r"C:\PSR\PSRCloud"
91
+
92
+ _CONSOLE_REL_PARENT_PATH = r"Oper\Console"
93
+
94
+ _CONSOLE_APP = r"FakeConsole.exe"
95
+
96
+ _ALLOWED_PROGRAMS = ["SDDP", "OPTGEN", "PSRIO", "GRAF"]
97
+
98
+ if os.name == "nt":
99
+ _PSRCLOUD_CREDENTIALS_PATH = os.path.expandvars(
100
+ os.path.join("%appdata%", "PSR", "PSRCloud", "EPSRConfig.xml")
101
+ )
102
+ else:
103
+ _PSRCLOUD_CREDENTIALS_PATH = ""
104
+
105
+ _PSRCLOUD_USER_ENV_VAR = "PSR_CLOUD_USER"
106
+ _PSRCLOUD_PASSWORD_HASH_ENV_VAR = "PSR_CLOUD_PASSWORD_HASH" # nosec
107
+ _PSRCLOUD_CONSOLE_ENV_VAR = "PSR_CLOUD_CONSOLE_PATH"
108
+
109
+ _auth_error_message = f"Please set {_PSRCLOUD_USER_ENV_VAR} and {_PSRCLOUD_PASSWORD_HASH_ENV_VAR} environment variables."
110
+
111
+ # FIXME uninspired name
112
+ _DEFAULT_GET_CASES_SINCE_DAYS = 7
113
+
114
+
115
+ _DEFAULT_CLUSTER = {
116
+ "name": "PSR-US",
117
+ "pretty_name": "External",
118
+ "url": "https://psrcloud.psr-inc.com/CamadaGerenciadoraServicoWeb/DespachanteWS.asmx",
119
+ }
120
+
121
+
122
+ class Client:
123
+ def __init__(self, **kwargs) -> None:
124
+ self.cwd = Path.cwd()
125
+
126
+ # Caches (avoiding multiple soap requests)
127
+ self._cloud_version_xml_cache = None
128
+ self._cloud_clusters_xml_cache = None
129
+ self._instance_type_map = None
130
+
131
+ # Options
132
+ self._selected_cluster = kwargs.get("cluster", _DEFAULT_CLUSTER["pretty_name"])
133
+ self._import_desktop = kwargs.get("import_desktop", True)
134
+ self._debug_mode = kwargs.get("debug", False)
135
+ self._dry_run = kwargs.get("dry_run", False)
136
+ self._timeout = kwargs.get("timeout", None)
137
+
138
+ # Client version
139
+ self.application_version = kwargs.get("application_version", None)
140
+
141
+ # Logging setup
142
+ self._quiet = kwargs.get("quiet", False)
143
+ self._verbose = kwargs.get("verbose", False)
144
+ if self._debug_mode:
145
+ self._quiet = False
146
+ self._verbose = True
147
+ log_id = id(self)
148
+ self._logger = get_logger(
149
+ log_id, quiet=self._quiet, debug_mode=self._debug_mode
150
+ )
151
+
152
+ self._logger.info(f"Client uid {log_id} initialized.")
153
+
154
+ self._console_path_setup(**kwargs)
155
+
156
+ self._credentials_setup(**kwargs)
157
+
158
+ self._cluster_setup(self._selected_cluster)
159
+
160
+ def _console_path_setup(self, **kwargs) -> None:
161
+ # For common users - provide PSR Cloud install path
162
+ if "psrcloud_path" in kwargs:
163
+ psrcloud_path = Path(kwargs["psrcloud_path"])
164
+ self._console_path = psrcloud_path / _CONSOLE_REL_PARENT_PATH / _CONSOLE_APP
165
+ if not os.path.exists(self._console_path):
166
+ err_msg = (
167
+ f"PSR Cloud application not found at {self._console_path} "
168
+ f"Make sure the path is correct and PSR Cloud is installed."
169
+ )
170
+ self._logger.error(err_msg)
171
+ self._logger.info("Provided psrcloud_path: " + str(psrcloud_path))
172
+ raise CloudError(err_msg)
173
+ # For advanced users or tests - provide full FakeConsole.exe path.
174
+ elif "fakeconsole_path" in kwargs:
175
+ self._console_path = Path(kwargs["fakeconsole_path"])
176
+ if not os.path.exists(self._console_path):
177
+ err_msg = (
178
+ f"PSR Cloud application not found at {self._console_path} "
179
+ f"Make sure the path is correct and PSR Cloud is installed."
180
+ )
181
+ self._logger.error(err_msg)
182
+ self._logger.info(
183
+ "Provided fakeconsole_path: " + str(self._console_path)
184
+ )
185
+ raise CloudError(err_msg)
186
+ # For advanced users or tests - provide PSR Cloud console path as environment variable.
187
+ elif _PSRCLOUD_CONSOLE_ENV_VAR in os.environ:
188
+ self._console_path = Path(os.environ[_PSRCLOUD_CONSOLE_ENV_VAR]).resolve()
189
+ if not os.path.exists(self._console_path):
190
+ err_msg = (
191
+ f"PSR Cloud application not found at {self._console_path} "
192
+ f"Make sure the path is correct and PSR Cloud is installed."
193
+ )
194
+ self._logger.error(err_msg)
195
+ self._logger.info("Provided console path: " + str(self._console_path))
196
+ raise CloudError(err_msg)
197
+ else:
198
+ self._console_path = (
199
+ Path(_PSRCLOUD_PATH) / _CONSOLE_REL_PARENT_PATH / _CONSOLE_APP
200
+ )
201
+ if not os.path.exists(self._console_path):
202
+ err_msg = (
203
+ f"PSR Cloud application not found at {self._console_path} "
204
+ f"Make sure the path is correct and PSR Cloud is installed."
205
+ )
206
+ self._logger.error(err_msg)
207
+ self._logger.info("Using default console path.")
208
+ raise CloudError(err_msg)
209
+
210
+ self._logger.info(f"PSR Cloud console path: {self._console_path}")
211
+ self._logger.info(f"PSR Cloud console version: {self._get_console_version()}")
212
+
213
+ def _credentials_setup(self, **kwargs) -> None:
214
+ self.username = kwargs.get("username", None)
215
+ self.__password = None
216
+ if self.username is not None:
217
+ self.username = kwargs["username"]
218
+ self.__password = hash_password(kwargs["password"])
219
+ self._logger.info(
220
+ "Using provided credentials from PSR Cloud console arguments."
221
+ )
222
+ self._logger.warning(
223
+ "For security reasons, it is highly recommended to use environment variables to store your credentials.\n"
224
+ + f"({_PSRCLOUD_USER_ENV_VAR}, {_PSRCLOUD_PASSWORD_HASH_ENV_VAR})"
225
+ )
226
+ else:
227
+ if (
228
+ _PSRCLOUD_USER_ENV_VAR in os.environ
229
+ and _PSRCLOUD_PASSWORD_HASH_ENV_VAR in os.environ
230
+ ):
231
+ self.username = os.environ[_PSRCLOUD_USER_ENV_VAR]
232
+ self.__password = os.environ[_PSRCLOUD_PASSWORD_HASH_ENV_VAR].upper()
233
+ self._logger.info("Using credentials from environment variables")
234
+ elif os.path.exists(_PSRCLOUD_CREDENTIALS_PATH):
235
+ self._logger.info(
236
+ "Environment variables for Cloud credentials not found"
237
+ )
238
+ xml = ET.parse(
239
+ _PSRCLOUD_CREDENTIALS_PATH, parser=ET.XMLParser(encoding="utf-16")
240
+ )
241
+ root = xml.getroot()
242
+ username = None
243
+ _password = None
244
+ for elem in root.iter("Aplicacao"):
245
+ username = elem.attrib.get("SrvUsuario")
246
+ _password = elem.attrib.get("SrvSenha")
247
+ break
248
+ if username is None or _password is None:
249
+ err_msg = "Credentials not provided. " + _auth_error_message
250
+ self._logger.info(
251
+ "Loading credentials from file: " + _PSRCLOUD_CREDENTIALS_PATH
252
+ )
253
+ self._logger.error(err_msg)
254
+ raise CloudInputError(err_msg)
255
+ self.username = username
256
+ self.__password = _password
257
+ self._logger.info("Using credentials from PSR Cloud Desktop cache")
258
+ else:
259
+ err_msg = "Username and password not provided." + _auth_error_message
260
+ self._logger.info(
261
+ "Trying to get credentials from environment variables."
262
+ )
263
+ self._logger.error(err_msg)
264
+ raise CloudInputError(err_msg)
265
+ self._logger.info(f"Logged as {self.username}")
266
+
267
+ def _cluster_setup(self, cluster_str: str) -> None:
268
+ """
269
+ Get cluster object by name.
270
+ If the cluster is the default one, select it directly. If not, check using default cluster to get
271
+ the available clusters for this user and select the one that matches the provided name.
272
+ """
273
+
274
+ if (
275
+ _DEFAULT_CLUSTER["name"].upper() == cluster_str.upper()
276
+ or _DEFAULT_CLUSTER["pretty_name"].capitalize() == cluster_str.capitalize()
277
+ ):
278
+ self.cluster = _DEFAULT_CLUSTER
279
+ else:
280
+ self.cluster = None
281
+ clusters = self._get_clusters_by_user()
282
+ for cluster in clusters:
283
+ if (
284
+ cluster["name"].upper() == cluster_str.upper()
285
+ or cluster["pretty_name"].capitalize() == cluster_str.capitalize()
286
+ ):
287
+ self.cluster = cluster
288
+
289
+ if self.cluster is not None:
290
+ self._logger.info(
291
+ f"Running on Cluster {self.cluster['name']} ({self.cluster['pretty_name']})"
292
+ )
293
+ else:
294
+ raise CloudInputError(f"Cluster {cluster_str} not found")
295
+
296
+ def set_cluster(self, cluster_str: str) -> None:
297
+ self._cluster_setup(cluster_str)
298
+ # Clear caches
299
+ self._cloud_version_xml_cache = None
300
+ self._cloud_clusters_xml_cache = None
301
+ self._instance_type_map = None
302
+
303
+ def _get_console_path(self) -> Path:
304
+ return self._console_path
305
+
306
+ def _get_console_parent_path(self) -> Path:
307
+ return self._console_path.parent
308
+
309
+ def _get_console_version(self) -> str:
310
+ console_path = self._get_console_path()
311
+ pe = pefile.PE(console_path)
312
+ for file_info in getattr(pe, "FileInfo", []):
313
+ for entry in file_info:
314
+ for st in getattr(entry, "StringTable", []):
315
+ product_version = st.entries.get(b"ProductVersion")
316
+ if product_version:
317
+ return product_version.decode()
318
+
319
+ @staticmethod
320
+ def _check_xml(xml_content: str) -> None:
321
+ try:
322
+ ET.fromstring(xml_content)
323
+ except ET.ParseError:
324
+ _hide_password(xml_content)
325
+ raise CloudInputError(
326
+ f"Invalid XML content.\n"
327
+ f"Contact PSR support at psrcloud@psr-inc.com with following data:\n\n{xml_content}\n\n"
328
+ )
329
+
330
+ def _get_clusters_by_user(self) -> list:
331
+ try:
332
+ previous_cluster = self.cluster
333
+ self.cluster = _DEFAULT_CLUSTER
334
+ xml = self._make_soap_request("listarCluster", "listaCluster")
335
+
336
+ clusters = []
337
+ for cluster in xml.findall("Cluster"):
338
+ nome = cluster.attrib.get("nome")
339
+ url = cluster.attrib.get("urlServico") + "/DespachanteWS.asmx"
340
+ pretty_name = cluster.attrib.get("legenda", nome)
341
+ clusters.append({"name": nome, "pretty_name": pretty_name, "url": url})
342
+
343
+ self.cluster = previous_cluster
344
+ except Exception as e:
345
+ self.cluster = previous_cluster
346
+ raise e
347
+ return clusters
348
+
349
+ def get_clusters(self) -> List[str]:
350
+ clusters = self._get_clusters_by_user()
351
+ return [cluster["pretty_name"] for cluster in clusters]
352
+
353
+ def _run_console(self, xml_content: str) -> None:
354
+ self._check_xml(xml_content)
355
+ delete_xml = not self._debug_mode
356
+ with CreateTempFile(
357
+ str(self.cwd), "psr_cloud_", xml_content, delete_xml
358
+ ) as xml_file:
359
+ xml_file.close()
360
+ command = [self._get_console_path(), xml_file.name]
361
+ command_str = " ".join(map(str, command))
362
+ self._logger.debug(f"Running console command {command_str}")
363
+ quiet_goes_to_log = subprocess.PIPE if self._debug_mode else None
364
+ if self._verbose:
365
+ proc_stdout = subprocess.PIPE
366
+ proc_stderr = subprocess.PIPE
367
+ else:
368
+ if self._quiet:
369
+ proc_stdout = quiet_goes_to_log
370
+ proc_stderr = quiet_goes_to_log
371
+ else:
372
+ proc_stdout = subprocess.PIPE
373
+ proc_stderr = None
374
+ try:
375
+ process = subprocess.Popen(
376
+ command, stdout=proc_stdout, stderr=proc_stderr, shell=False
377
+ )
378
+ enable_log_timestamp(self._logger, False)
379
+ if proc_stdout is not None:
380
+ with process.stdout:
381
+ for line in iter(process.stdout.readline, b""):
382
+ if self._verbose:
383
+ self._logger.info(line.decode().strip())
384
+ else:
385
+ self._logger.debug(line.decode().strip())
386
+ if proc_stderr is not None:
387
+ with process.stderr:
388
+ for line in iter(process.stderr.readline, b""):
389
+ self._logger.error(line.decode().strip())
390
+ enable_log_timestamp(self._logger, True)
391
+ result = process.wait(timeout=self._timeout)
392
+
393
+ if result != 0:
394
+ err_msg = (
395
+ f"PSR Cloud console command failed with return code {result}"
396
+ )
397
+ self._logger.error(err_msg)
398
+ raise CloudError(err_msg)
399
+ except subprocess.CalledProcessError as e:
400
+ err_msg = f"PSR Cloud console command failed with exception: {str(e)}"
401
+ self._logger.error(err_msg)
402
+ raise CloudError(err_msg)
403
+
404
+ def _validate_case(self, case: "Case") -> "Case":
405
+ if not case.program:
406
+ raise CloudInputError("Program not provided")
407
+ elif case.program not in self.get_programs():
408
+ raise CloudInputError(
409
+ f"Program {case.program} not found. Available programs are: {', '.join(self.get_programs())}"
410
+ )
411
+
412
+ if not case.memory_per_process_ratio:
413
+ raise CloudInputError("Memory per process ratio not provided")
414
+ elif case.memory_per_process_ratio not in self.get_memory_per_process_ratios():
415
+ raise CloudInputError(
416
+ f"Memory per process ratio {case.memory_per_process_ratio} not found. Available ratios are: {', '.join(self.get_memory_per_process_ratios())}"
417
+ )
418
+
419
+ if case.number_of_processes < 1 or case.number_of_processes > 512:
420
+ raise CloudInputError("Number of processes must be between 1 and 512")
421
+
422
+ if case.data_path and not Path(case.data_path).exists():
423
+ raise CloudInputError("Data path does not exist")
424
+
425
+ if case.parent_case_id is None:
426
+ case.parent_case_id = 0
427
+
428
+ def validate_selection(
429
+ selection, available_options, selection_name, program_name
430
+ ):
431
+ if selection is None:
432
+ raise CloudInputError(
433
+ f"{selection_name} of program {program_name} not provided"
434
+ )
435
+ elif isinstance(selection, str):
436
+ if selection not in available_options.values():
437
+ raise CloudInputError(
438
+ f"{selection_name} {selection} of program {program_name} not found. Available {selection_name.lower()}s are: {', '.join(available_options.values())}"
439
+ )
440
+ return next(
441
+ key
442
+ for key, value in available_options.items()
443
+ if value == selection
444
+ )
445
+ elif selection not in available_options:
446
+ raise CloudInputError(
447
+ f"{selection_name} id {selection} of program {program_name} not found. Available {selection_name.lower()} ids are: {', '.join(map(str,available_options.keys()))}"
448
+ )
449
+ return selection
450
+
451
+ program_versions = self.get_program_versions(case.program)
452
+ case.program_version = validate_selection(
453
+ case.program_version, program_versions, "Version", case.program
454
+ )
455
+
456
+ execution_types = self.get_execution_types(case.program, case.program_version)
457
+ case.execution_type = validate_selection(
458
+ case.execution_type, execution_types, "Execution type", case.program
459
+ )
460
+
461
+ instance_type_map = self._get_instance_type_map()
462
+ if all(value[1] == False for value in instance_type_map.values()):
463
+ is_spot_disabled = True
464
+ else:
465
+ is_spot_disabled = False
466
+
467
+ if case.price_optimized == True and is_spot_disabled == True:
468
+ raise CloudError("Price Optimized is temporarily unavailable.")
469
+
470
+ repository_durations = self.get_repository_durations()
471
+ case.repository_duration = validate_selection(
472
+ case.repository_duration,
473
+ repository_durations,
474
+ "Repository duration",
475
+ case.program,
476
+ )
477
+
478
+ if case.budget:
479
+ budgets = self.get_budgets()
480
+ match_list = _budget_matches_list(case.budget, budgets)
481
+ if len(match_list) == 0:
482
+ raise CloudInputError(
483
+ f'Budget "{case.budget}" not found. Get a list of available budgets using Client().get_budgets().'
484
+ )
485
+ elif len(match_list) > 1:
486
+ raise CloudInputError(
487
+ f'Multiple budgets found for "{case.budget}". Please use the budget id instead of the name.\n'
488
+ "\n".join([f' - "{budget}"' for budget in match_list])
489
+ )
490
+ else:
491
+ # Replace partial with complete budget name
492
+ case.budget = match_list[0]
493
+
494
+ return case
495
+
496
+ def _pre_process_graph(self, path: str, case_id: int) -> None:
497
+ # This method is only used for testing the graf cloud execution.
498
+ # Error handling is already done on the tests module.
499
+ parameters = {
500
+ "urlServico": self.cluster["url"],
501
+ "usuario": self.username,
502
+ "senha": self.__password,
503
+ "idioma": "3",
504
+ "modelo": "Graf",
505
+ "comando": "PreProcessamento",
506
+ "cluster": self.cluster["name"],
507
+ "repositorioId": str(case_id),
508
+ "diretorioDestino": path,
509
+ "tipoExecucao": "1",
510
+ }
511
+
512
+ xml_content = create_case_xml(parameters)
513
+ self._run_console(xml_content)
514
+
515
+ def _check_until_status(
516
+ self, case_id: int, requested_status: "ExecutionStatus", timeout: int = 60 * 60
517
+ ) -> bool:
518
+ """
519
+ Check the status of a case until the requested status is reached or timeout occurs.
520
+
521
+ :param case_id: The ID of the case to check.
522
+ :param requested_status: The status to wait for.
523
+ :param timeout: The maximum time to wait in seconds (default is 3600 seconds or 1 hour).
524
+ :return: True if the requested status is reached, False if timeout occurs.
525
+ """
526
+ status = None
527
+ start_time = time()
528
+ original_quiet_flag = self._quiet
529
+ original_verbose_flag = self._verbose
530
+ original_debug_flag = self._debug_mode
531
+ self._quiet, self._verbose, self._debug_mode = True, False, False
532
+ try:
533
+ while status not in FAULTY_TERMINATION_STATUS + [
534
+ ExecutionStatus.SUCCESS,
535
+ requested_status,
536
+ ]:
537
+ if time() - start_time > timeout:
538
+ self._logger.error(
539
+ f"Timeout reached while waiting for status {requested_status}"
540
+ )
541
+ return False
542
+ status, _ = self.get_status(case_id)
543
+ sleep(20)
544
+ finally:
545
+ self._quiet = original_quiet_flag
546
+ self._verbose = original_verbose_flag
547
+ self._debug_mode = original_debug_flag
548
+
549
+ return status == requested_status
550
+
551
+ def _clean_folder(self, folder):
552
+ for root, dirs, files in os.walk(folder, topdown=False):
553
+ for file in files:
554
+ file_path = os.path.join(root, file)
555
+ os.remove(file_path)
556
+
557
+ for dir in dirs:
558
+ dir_path = os.path.join(root, dir)
559
+ os.rmdir(dir_path)
560
+
561
+ @thread_safe()
562
+ def run_case(self, case: "Case", **kwargs) -> int:
563
+ self._validate_case(case)
564
+ instance_type_map = self._get_instance_type_map()
565
+ instance_type_id = next(
566
+ key
567
+ for key, value in instance_type_map.items()
568
+ if value[0] == case.memory_per_process_ratio
569
+ and value[1] == case.price_optimized
570
+ )
571
+ case.data_path = _handle_relative_path(case.data_path)
572
+
573
+ if case.program == "GRAF":
574
+ wait = True
575
+ else:
576
+ wait = kwargs.get("wait", False)
577
+
578
+ if self.application_version:
579
+ interface_version = self.application_version + " - " + INTERFACE_VERSION
580
+ else:
581
+ interface_version = INTERFACE_VERSION
582
+ parameters = {
583
+ "urlServico": self.cluster["url"],
584
+ "usuario": self.username,
585
+ "senha": self.__password,
586
+ "idioma": "3",
587
+ "modelo": case.program,
588
+ "comando": "executar",
589
+ "cluster": self.cluster["name"],
590
+ "diretorioDados": case.data_path,
591
+ "origemDados": "LOCAL",
592
+ "s3Dados": "",
593
+ "nproc": case.number_of_processes,
594
+ "repositorioId": "0",
595
+ "repositorioPai": case.parent_case_id,
596
+ "instanciaTipo": instance_type_id,
597
+ "validacaoModelo": "True",
598
+ "validacaoUsuario": "False",
599
+ "idVersao": case.program_version,
600
+ "pathModelo": "C:\\PSR",
601
+ "idTipoExecucao": case.execution_type,
602
+ "nomeCaso": case.name,
603
+ "tipoExecucao": str(int(not wait)),
604
+ "deveAgendar": "False",
605
+ "userTag": "(Untagged)",
606
+ "lifecycle": case.repository_duration,
607
+ "versaoInterface": interface_version,
608
+ }
609
+
610
+ if case.budget:
611
+ parameters["budget"] = case.budget
612
+ if case.upload_only is not None:
613
+ parameters["saveInCloud"] = case.upload_only
614
+
615
+ xml_content = create_case_xml(parameters)
616
+
617
+ if self._dry_run:
618
+ return xml_content
619
+
620
+ self._run_console(xml_content)
621
+ xml = ET.parse(
622
+ f"{self._get_console_parent_path()}\\fake{case.program}_async.xml"
623
+ )
624
+ _check_for_errors(xml, self._logger)
625
+ id_parameter = xml.find("./Parametro[@nome='repositorioId']")
626
+ if id_parameter is None:
627
+ xml_str = _xml_to_str(xml)
628
+ raise CloudError(
629
+ f"Case id not found on returned XML response.\n"
630
+ f"Contact PSR support at psrcloud@psr-inc.com with following data:\n\n{xml_str}\n\n"
631
+ )
632
+
633
+ case_id = int(id_parameter.text)
634
+ if not wait:
635
+ self._logger.info(f"Case {case.name} started with id {case_id}")
636
+
637
+ if self._import_desktop and case.program != "GRAF":
638
+ try:
639
+ case_copy = copy.deepcopy(case)
640
+ case_copy.id = case_id
641
+ replace_case_str_values(self, case_copy)
642
+ import_case(case_copy, self.cluster["name"], instance_type_id)
643
+ except Exception as e:
644
+ msg = f"Failed to import case {case.name} to desktop:\n{str(e)}"
645
+ self._logger.error(msg)
646
+ warnings.warn(msg)
647
+
648
+ return case_id
649
+
650
+ def get_status(self, case_id: int) -> tuple["ExecutionStatus", str]:
651
+ # case = self.get_case(case_id)
652
+ delete_xml = not self._debug_mode
653
+ xml_content = ""
654
+ with CreateTempFile(
655
+ str(self.cwd), "psr_cloud_status_", xml_content, delete_xml
656
+ ) as xml_file:
657
+ status_xml_path = os.path.abspath(xml_file.name)
658
+
659
+ parameters = {
660
+ "urlServico": self.cluster["url"],
661
+ "usuario": self.username,
662
+ "senha": self.__password,
663
+ "idioma": "3",
664
+ "idFila": str(case_id),
665
+ "modelo": "SDDP",
666
+ "comando": "obterstatusresultados",
667
+ "arquivoSaida": status_xml_path,
668
+ }
669
+ run_xml_content = create_case_xml(parameters)
670
+
671
+ self._run_console(run_xml_content)
672
+ xml = ET.parse(status_xml_path)
673
+ parameter_status = xml.find("./Parametro[@nome='statusExecucao']")
674
+ if parameter_status is None:
675
+ xml_str = _xml_to_str(xml)
676
+ raise CloudError(
677
+ f"Status not found on returned XML response.\n"
678
+ f"Contact PSR support at psrcloud@psr-inc.com with following data:\n\n{xml_str}\n\n"
679
+ )
680
+ try:
681
+ status = ExecutionStatus(int(parameter_status.text))
682
+ except CloudError:
683
+ xml_str = _xml_to_str(xml)
684
+ raise CloudError(
685
+ f"Unrecognized status on returned XML response.\n"
686
+ f"Contact PSR support at psrcloud@psr-inc.com with following data:\n\n{xml_str}\n\n"
687
+ )
688
+
689
+ self._logger.info(f"Status: {STATUS_MAP_TEXT[status]}")
690
+ return status, STATUS_MAP_TEXT[status]
691
+
692
+ def list_download_files(self, case_id: int) -> List[str]:
693
+ xml_files = self._make_soap_request(
694
+ "prepararListaArquivosRemotaDownload",
695
+ "listaArquivoRemota",
696
+ additional_arguments={
697
+ "cluster": self.cluster["name"],
698
+ "filtro": "(.*)",
699
+ "diretorioRemoto": str(case_id),
700
+ },
701
+ )
702
+
703
+ files = []
704
+
705
+ for file in xml_files.findall("Arquivo"):
706
+ file_info = {
707
+ "name": file.attrib.get("nome"),
708
+ "filesize": file.attrib.get("filesize"),
709
+ "filedate": file.attrib.get("filedate"),
710
+ }
711
+ files.append(file_info)
712
+
713
+ return files
714
+
715
+ def download_results(
716
+ self,
717
+ case_id: int,
718
+ output_path: Union[str, Path],
719
+ files: Optional[List[str]] = None,
720
+ extensions: Optional[List[str]] = None,
721
+ ) -> None:
722
+ filter = ".*("
723
+
724
+ if not extensions and not files:
725
+ extensions = ["csv", "log", "hdr", "bin", "out", "ok"]
726
+
727
+ filter_elements = []
728
+
729
+ if extensions:
730
+ Client._validate_extensions(extensions)
731
+ filter_elements.extend([f".*.{ext}$" for ext in extensions])
732
+
733
+ if files:
734
+ filter_elements.extend(files)
735
+
736
+ filter += "|".join(filter_elements)
737
+ filter += ")$"
738
+
739
+ self._logger.info("Download filter: " + filter)
740
+ case = self.get_case(case_id)
741
+ output_path = _handle_relative_path(output_path)
742
+ parameters = {
743
+ "urlServico": self.cluster["url"],
744
+ "usuario": self.username,
745
+ "senha": self.__password,
746
+ "idioma": "3",
747
+ "_cluster": self.cluster["name"],
748
+ "modelo": case.program,
749
+ "comando": "download",
750
+ "diretorioDestino": output_path,
751
+ "repositorioId": str(case_id),
752
+ "filtroDownloadPorMascara": filter,
753
+ }
754
+
755
+ xml_content = create_case_xml(parameters)
756
+ self._run_console(xml_content)
757
+ self._logger.info(f"Results downloaded to {output_path}")
758
+
759
+ def cancel_case(self, case_id: int, wait: bool = False) -> bool:
760
+ parameters = {
761
+ "urlServico": self.cluster["url"],
762
+ "usuario": self.username,
763
+ "senha": self.__password,
764
+ "idioma": "3",
765
+ "modelo": "SDDP",
766
+ "comando": "cancelarfila",
767
+ "cancelamentoForcado": "False",
768
+ "idFila": str(case_id),
769
+ }
770
+
771
+ xml_content = create_case_xml(parameters)
772
+ self._run_console(xml_content)
773
+ self._logger.info(f"Request to cancel case {case_id} was sent")
774
+
775
+ if wait:
776
+ self._logger.info(f"Waiting for case {case_id} to be canceled")
777
+ if self._check_until_status(
778
+ case_id, ExecutionStatus.CANCELLED, timeout=60 * 10
779
+ ):
780
+ self._logger.info(f"Case {case_id} was successfully canceled")
781
+ return True
782
+ else:
783
+ self._logger.error(f"Failed to cancel case {case_id}")
784
+ return False
785
+ else:
786
+ return True
787
+
788
+ def _cases_from_xml(self, xml: ET.Element) -> List["Case"]:
789
+ instance_type_map = self._get_instance_type_map()
790
+ cases = []
791
+ for fila in xml.findall("Fila"):
792
+ try:
793
+ case = Case(
794
+ name=fila.attrib.get("nomeCaso"),
795
+ data_path=None,
796
+ program=fila.attrib.get("programa"),
797
+ program_version=int(fila.attrib.get("idVersao")),
798
+ execution_type=int(fila.attrib.get("idTipoExecucao")),
799
+ price_optimized=bool(fila.attrib.get("flagSpot")),
800
+ number_of_processes=int(fila.attrib.get("numeroProcesso")),
801
+ id=int(fila.attrib.get("repositorioId")),
802
+ user=fila.attrib.get("usuario"),
803
+ execution_date=datetime.strptime(
804
+ fila.attrib.get("dataInicio"), "%d/%m/%Y %H:%M"
805
+ ),
806
+ parent_case_id=int(fila.attrib.get("repositorioPai"))
807
+ if fila.attrib.get("repositorioPai")
808
+ else 0,
809
+ memory_per_process_ratio=(
810
+ instance_type_map[int(fila.attrib.get("instanciaTipo"))][0]
811
+ if fila.attrib.get("instanciaTipo") in instance_type_map
812
+ else min([value[0] for value in instance_type_map.values()])
813
+ ),
814
+ repository_duration=int(fila.attrib.get("duracaoRepositorio")),
815
+ budget=fila.attrib.get("budget"),
816
+ )
817
+ cases.append(case)
818
+ except (TypeError, ValueError):
819
+ pass
820
+ # case_id = fila.attrib.get("repositorioId")
821
+ # Optionally, log the error or handle it as needed
822
+ # self._logger.error(f"Error processing case with ID {case_id}: {e}")
823
+
824
+ # sort cases by execution date desc
825
+ cases.sort(key=lambda x: x.execution_date, reverse=True)
826
+ return cases
827
+
828
+ def get_all_cases_since(
829
+ self, since: Union[int, datetime] = _DEFAULT_GET_CASES_SINCE_DAYS
830
+ ) -> List["Case"]:
831
+ if isinstance(since, int):
832
+ initial_date = datetime.now() - timedelta(days=since)
833
+ initial_date_iso = initial_date.isoformat().replace("T", " ")[:-7]
834
+ else:
835
+ initial_date_iso = since.strftime("%Y-%m-%d %H:%M:%S")
836
+
837
+ xml = self._make_soap_request(
838
+ "listarFila",
839
+ "dados",
840
+ additional_arguments={"dataInicial": initial_date_iso},
841
+ )
842
+
843
+ return self._cases_from_xml(xml)
844
+
845
+ def get_case(self, case_id: int) -> "Case":
846
+ cases = self.get_cases([case_id])
847
+ if cases and len(cases) > 0:
848
+ return cases[0]
849
+ raise CloudInputError(f"Case {case_id} not found")
850
+
851
+ def get_cases(self, case_ids: List[int]) -> List["Case"]:
852
+ case_ids_str = ",".join(map(str, case_ids))
853
+ xml = self._make_soap_request(
854
+ "listarFila",
855
+ "dados",
856
+ additional_arguments={"listaRepositorio": case_ids_str},
857
+ )
858
+ return self._cases_from_xml(xml)
859
+
860
+ def get_budgets(self) -> list:
861
+ xml = self._make_soap_request(
862
+ "listarCluster",
863
+ "listaCluster",
864
+ )
865
+
866
+ budgets = []
867
+ for cluster in xml.findall("Cluster"):
868
+ if cluster.attrib.get("nome").lower() == self.cluster["name"].lower():
869
+ collection = cluster.findall("ColecaoBudget")[0]
870
+ budgets = [
871
+ budget.attrib.get("nome") for budget in collection.findall("Budget")
872
+ ]
873
+ break
874
+ budgets.sort()
875
+ return budgets
876
+
877
+ def _make_soap_request(self, service: str, name: str = "", **kwargs) -> ET.Element:
878
+ portal_ws = zeep.Client(self.cluster["url"] + "?WSDL")
879
+ section = str(id(self))
880
+ password_md5 = _md5sum(self.username + self.__password + section).upper()
881
+ password_md5 = (
882
+ password_md5
883
+ if self.cluster["name"] == "PSR-US"
884
+ or self.cluster["name"] == "PSR-HOTFIX"
885
+ or self.cluster["name"] == "PSR-US_OHIO"
886
+ else self.__password.upper()
887
+ )
888
+ additional_arguments = kwargs.get("additional_arguments", None)
889
+ parameters = {
890
+ "sessao_id": section,
891
+ "tipo_autenticacao": "portal"
892
+ if self.cluster["name"] == "PSR-US"
893
+ or self.cluster["name"] == "PSR-HOTFIX"
894
+ or self.cluster["name"] == "PSR-US_OHIO"
895
+ else "bcrypt",
896
+ "idioma": "3",
897
+ }
898
+ if additional_arguments:
899
+ parameters.update(additional_arguments)
900
+
901
+ # FIXME make additional arguments work as a dictionary to work with this code
902
+ xml_input = create_case_xml(parameters)
903
+
904
+ try:
905
+ xml_output_str = portal_ws.service.despacharServico(
906
+ service, self.username, password_md5, xml_input
907
+ )
908
+ except zeep.exceptions.Fault as e:
909
+ # Log the full exception details
910
+ self._logger.error(f"Zeep Fault: {str(e)}")
911
+ raise CloudError(
912
+ "Failed to connect to PSR Cloud service. Contact PSR support: psrcloud@psr-inc.com"
913
+ )
914
+ # Remove control characters - this is a thing
915
+ xml_output_str = xml_output_str.replace("&amp;#x1F;", "")
916
+
917
+ xml_output = ET.fromstring(xml_output_str)
918
+
919
+ if name:
920
+ for child in xml_output:
921
+ if child.attrib.get("nome") == name:
922
+ xml_output = ET.fromstring(child.text)
923
+ break
924
+ else:
925
+ raise ValueError(
926
+ f"Invalid XML response from PSR Cloud: {xml_output_str}. Please contact PSR support at psrcloud@psr-inc.com"
927
+ )
928
+ return xml_output
929
+
930
+ def _get_cloud_versions_xml(self) -> ET.Element:
931
+ if self._cloud_version_xml_cache is not None:
932
+ return self._cloud_version_xml_cache
933
+ self._cloud_version_xml_cache = self._make_soap_request("obterVersoes", "dados")
934
+ return self._cloud_version_xml_cache
935
+
936
+ def _get_cloud_clusters_xml(self) -> ET.Element:
937
+ if self._cloud_clusters_xml_cache is not None:
938
+ return self._cloud_clusters_xml_cache
939
+ self._cloud_clusters_xml_cache = self._make_soap_request(
940
+ "listarCluster", "listaCluster"
941
+ )
942
+ return self._cloud_clusters_xml_cache
943
+
944
+ def get_programs(self) -> List[str]:
945
+ xml = self._get_cloud_versions_xml()
946
+ programs = [model.attrib["nome"] for model in xml]
947
+ return [program for program in programs if program in _ALLOWED_PROGRAMS]
948
+
949
+ def get_program_versions(self, program: str) -> dict[int, str]:
950
+ if not isinstance(program, str):
951
+ raise CloudInputError("Program must be a string")
952
+ elif program not in self.get_programs():
953
+ raise CloudInputError(
954
+ f"Program {program} not found. Available programs: {', '.join(self.get_programs())}"
955
+ )
956
+ xml = self._get_cloud_versions_xml()
957
+ versions = {}
958
+
959
+ for model in xml:
960
+ if model.attrib["nome"] == program:
961
+ for version_child in model.findall(".//Versao"):
962
+ version_id = int(version_child.attrib["id"])
963
+ version_name = version_child.attrib["versao"]
964
+ versions[version_id] = version_name
965
+
966
+ return versions
967
+
968
+ def get_execution_types(
969
+ self, program: str, version: Union[str, int]
970
+ ) -> dict[int, str]:
971
+ if not isinstance(program, str):
972
+ raise CloudInputError("Program must be a string")
973
+ elif program not in self.get_programs():
974
+ raise CloudInputError(
975
+ f"Program {program} not found. Available programs: {', '.join(self.get_programs())}"
976
+ )
977
+ if isinstance(version, int):
978
+ if version not in self.get_program_versions(program):
979
+ raise CloudInputError(
980
+ f"Version id {version} of program {program} not found. Available version ids: {', '.join(map(str, list(self.get_program_versions(program).keys())))}"
981
+ )
982
+ version = next(
983
+ v for k, v in self.get_program_versions(program).items() if k == version
984
+ )
985
+ elif version not in self.get_program_versions(program).values():
986
+ raise CloudInputError(
987
+ f"Version {version} of program {program} not found. Available versions: {', '.join(self.get_program_versions(program).values())}"
988
+ )
989
+ xml = self._get_cloud_versions_xml()
990
+ return {
991
+ int(execution_type.attrib["id"]): execution_type.attrib["nome"]
992
+ for program_child in xml
993
+ if program_child.attrib["nome"] == program
994
+ for version_child in program_child[0][0][0]
995
+ if version_child.attrib["versao"] == version
996
+ for execution_type in version_child[0]
997
+ }
998
+
999
+ def get_memory_per_process_ratios(self) -> List[str]:
1000
+ xml = self._get_cloud_clusters_xml()
1001
+ return sorted(
1002
+ list(
1003
+ {
1004
+ f"{instance_type.attrib['memoriaPorCore']}:1"
1005
+ for cluster in xml
1006
+ if cluster.attrib["nome"] == self.cluster["name"]
1007
+ for colection in cluster
1008
+ if colection.tag == "ColecaoInstanciaTipo"
1009
+ for instance_type in colection
1010
+ }
1011
+ )
1012
+ )
1013
+
1014
+ def get_repository_durations(self) -> dict[int, str]:
1015
+ if self.cluster == "PSR-US":
1016
+ return {
1017
+ 2: "Normal (1 month)",
1018
+ }
1019
+
1020
+ else:
1021
+ return {
1022
+ 1: "Short (1 week)",
1023
+ 2: "Normal (1 month)",
1024
+ 3: "Extended (6 months)",
1025
+ 4: "Long (2 years)",
1026
+ }
1027
+
1028
+ def _get_instance_type_map(self) -> dict[int, tuple[str, bool]]:
1029
+ if self._instance_type_map is not None:
1030
+ return self._instance_type_map
1031
+ xml = self._get_cloud_clusters_xml()
1032
+ self._instance_type_map = {
1033
+ int(instance_type.attrib["id"]): (
1034
+ f'{instance_type.attrib["memoriaPorCore"]}:1',
1035
+ "Price Optimized" in instance_type.attrib["descricao"],
1036
+ )
1037
+ for cluster in xml
1038
+ if cluster.attrib["nome"] == self.cluster["name"]
1039
+ for collection in cluster
1040
+ if collection.tag == "ColecaoInstanciaTipo"
1041
+ for instance_type in collection
1042
+ }
1043
+ return self._instance_type_map
1044
+
1045
+ @staticmethod
1046
+ def _validate_extensions(extensions: List[str]):
1047
+ for ext in extensions:
1048
+ if not ext.isalnum():
1049
+ raise CloudInputError(
1050
+ f"Invalid extension '{ext}' detected. Extensions must be alphanumeric."
1051
+ )
1052
+
1053
+
1054
+ def _budget_matches_list(budget_part: str, all_budgets: List[str]) -> List[str]:
1055
+ """Tests if a part of a budget name is in the list all_budgets and returns a list of matches."""
1056
+ lowered_budget_part = budget_part.lower()
1057
+ return [budget for budget in all_budgets if lowered_budget_part in budget.lower()]
1058
+
1059
+
1060
+ def replace_case_str_values(client: Client, case: Case) -> Case:
1061
+ """Create a new case object using internal integer IDs instead of string values."""
1062
+ # Model Version
1063
+ if isinstance(case.program_version, str):
1064
+ program_versions = client.get_program_versions(case.program)
1065
+ case.program_version = next(
1066
+ key
1067
+ for key, value in program_versions.items()
1068
+ if value == case.program_version
1069
+ )
1070
+
1071
+ # Execution Type
1072
+ if isinstance(case.execution_type, str):
1073
+ execution_types = client.get_execution_types(case.program, case.program_version)
1074
+ case.execution_type = next(
1075
+ key
1076
+ for key, value in execution_types.items()
1077
+ if value == case.execution_type
1078
+ )
1079
+ return case