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