openbb-cli 1.0.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.
Files changed (42) hide show
  1. openbb_cli/__init__.py +1 -0
  2. openbb_cli/argparse_translator/__init__.py +0 -0
  3. openbb_cli/argparse_translator/argparse_class_processor.py +148 -0
  4. openbb_cli/argparse_translator/argparse_translator.py +627 -0
  5. openbb_cli/argparse_translator/obbject_registry.py +94 -0
  6. openbb_cli/assets/routines/routine_example.openbb +21 -0
  7. openbb_cli/assets/styles/default/Consolas.ttf +0 -0
  8. openbb_cli/assets/styles/default/dark.mpfstyle.json +47 -0
  9. openbb_cli/assets/styles/default/dark.mplrc.json +7 -0
  10. openbb_cli/assets/styles/default/dark.mplstyle +96 -0
  11. openbb_cli/assets/styles/default/dark.pltstyle.json +132 -0
  12. openbb_cli/assets/styles/default/dark.richstyle.json +9 -0
  13. openbb_cli/assets/styles/default/light.mpfstyle.json +47 -0
  14. openbb_cli/assets/styles/default/light.mplrc.json +7 -0
  15. openbb_cli/assets/styles/default/light.mplstyle +95 -0
  16. openbb_cli/assets/styles/default/light.pltstyle.json +871 -0
  17. openbb_cli/assets/styles/default/light.richstyle.json +9 -0
  18. openbb_cli/assets/styles/default/tables.pltstyle.json +102 -0
  19. openbb_cli/assets/styles/user/openbb.richstyle.json +9 -0
  20. openbb_cli/cli.py +20 -0
  21. openbb_cli/config/__init__.py +1 -0
  22. openbb_cli/config/completer.py +427 -0
  23. openbb_cli/config/console.py +93 -0
  24. openbb_cli/config/constants.py +80 -0
  25. openbb_cli/config/menu_text.py +165 -0
  26. openbb_cli/config/setup.py +11 -0
  27. openbb_cli/config/style.py +108 -0
  28. openbb_cli/controllers/base_controller.py +941 -0
  29. openbb_cli/controllers/base_platform_controller.py +349 -0
  30. openbb_cli/controllers/choices.py +324 -0
  31. openbb_cli/controllers/cli_controller.py +946 -0
  32. openbb_cli/controllers/hub_service.py +107 -0
  33. openbb_cli/controllers/platform_controller_factory.py +58 -0
  34. openbb_cli/controllers/script_parser.py +488 -0
  35. openbb_cli/controllers/settings_controller.py +373 -0
  36. openbb_cli/controllers/utils.py +967 -0
  37. openbb_cli/models/settings.py +73 -0
  38. openbb_cli/session.py +92 -0
  39. openbb_cli-1.0.0.dist-info/METADATA +91 -0
  40. openbb_cli-1.0.0.dist-info/RECORD +42 -0
  41. openbb_cli-1.0.0.dist-info/WHEEL +4 -0
  42. openbb_cli-1.0.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,107 @@
1
+ """Routines handler module."""
2
+
3
+ from typing import Optional
4
+
5
+ import requests
6
+ from openbb_cli.config.constants import (
7
+ CONNECTION_ERROR_MSG,
8
+ CONNECTION_TIMEOUT_MSG,
9
+ TIMEOUT,
10
+ )
11
+ from openbb_cli.session import Session
12
+
13
+ # created dictionaries for personal and default routines with the structure
14
+ # {"file_name" :["script","personal/default"]}
15
+ # and stored dictionaries in list
16
+ # created new directory structure to account for personal and default routines
17
+
18
+
19
+ session = Session()
20
+
21
+
22
+ # pylint: disable=too-many-arguments
23
+ def upload_routine(
24
+ auth_header: str,
25
+ name: str = "",
26
+ description: str = "",
27
+ routine: str = "",
28
+ override: bool = False,
29
+ tags: str = "",
30
+ public: bool = False,
31
+ timeout: int = TIMEOUT,
32
+ ) -> Optional[requests.Response]:
33
+ """Send a routine to the server.
34
+
35
+ Parameters
36
+ ----------
37
+ auth_header : str
38
+ The authorization header, e.g. "Bearer <token>".
39
+ name : str
40
+ The name of the routine.
41
+ routine : str
42
+ The routine.
43
+ override : bool
44
+ Whether to override the routine if it already exists.
45
+ tags : str
46
+ The tags of the routine.
47
+ public : bool
48
+ Whether to make the routine public or not.
49
+ timeout : int
50
+ The timeout, by default TIMEOUT
51
+
52
+ Returns
53
+ -------
54
+ Optional[requests.Response]
55
+ The response from the post request.
56
+ """
57
+ data = {
58
+ "name": name,
59
+ "description": description,
60
+ "script": routine,
61
+ "override": override,
62
+ "tags": tags,
63
+ "version": session.settings.VERSION,
64
+ "public": public,
65
+ }
66
+ _console = session.console
67
+ try:
68
+ response = requests.post(
69
+ headers={"Authorization": auth_header},
70
+ url=session.settings.BASE_URL + "/terminal/script",
71
+ json=data,
72
+ timeout=timeout,
73
+ )
74
+ if response.status_code == 200:
75
+ username = getattr(session.user.profile.hub_session, "username", None)
76
+ if not username:
77
+ _console.print("[red]No username found.[/red]")
78
+ _console.print("[red]Failed to upload your routine.[/red]")
79
+ return None
80
+ _console.print("[green]Successfully uploaded your routine.[/]")
81
+
82
+ hub_url = session.settings.HUB_URL
83
+
84
+ if public:
85
+ _console.print(
86
+ f"\n[yellow]Share or edit it at {hub_url}/u/{username}/routine/{name.replace(' ', '-')}[/]"
87
+ )
88
+ else:
89
+ _console.print(f"\n[yellow]Go to {hub_url} to edit this script,[/]")
90
+ _console.print(
91
+ f"[yellow]or even make it public so you can access it at "
92
+ f"{hub_url}/u/{username}/routine/{name.replace(' ', '-')}[/]"
93
+ )
94
+ elif response.status_code != 409: # 409: routine already exists
95
+ _console.print(
96
+ "[red]" + response.json().get("detail", "Unknown error.") + "[/red]"
97
+ )
98
+ return response
99
+ except requests.exceptions.ConnectionError:
100
+ _console.print(f"\n{CONNECTION_ERROR_MSG}")
101
+ return None
102
+ except requests.exceptions.Timeout:
103
+ _console.print(f"\n{CONNECTION_TIMEOUT_MSG}")
104
+ return None
105
+ except Exception:
106
+ _console.print("[red]Failed to upload your routine.[/red]")
107
+ return None
@@ -0,0 +1,58 @@
1
+ """Platform controller factory to create a platform controller."""
2
+
3
+ from typing import Dict, List, Union
4
+
5
+ from openbb_cli.argparse_translator.argparse_class_processor import (
6
+ ArgparseClassProcessor,
7
+ )
8
+ from openbb_cli.controllers.base_platform_controller import PlatformController
9
+
10
+
11
+ class PlatformControllerFactory:
12
+ """Factory to create a platform controller."""
13
+
14
+ def __init__(self, platform_router: type, **kwargs):
15
+ """Create the controller name."""
16
+ self.platform_router = platform_router
17
+ self._translated_target = ArgparseClassProcessor(
18
+ target_class=self.platform_router, reference=kwargs.get("reference", {})
19
+ )
20
+ self.router_name = (
21
+ str(type(self.platform_router))
22
+ .rsplit(".", maxsplit=1)[-1]
23
+ .replace("'>", "")
24
+ .replace("ROUTER_", "")
25
+ .lower()
26
+ )
27
+ self.controller_name = f"{self.router_name.capitalize()}Controller"
28
+
29
+ def create(self) -> type:
30
+ """Create the platform controller."""
31
+ ClassName = self.controller_name
32
+ Parents = (PlatformController,)
33
+ Attributes: Dict[str, Union[bool, List[str]]] = {"CHOICES_GENERATION": True}
34
+
35
+ # Menu and Command choices generation
36
+ choices_menus: List[str] = []
37
+ choices_commands: List[str] = []
38
+ translators = self._translated_target.translators
39
+ paths = self._translated_target.paths
40
+ # menus
41
+ for key, value in paths.items():
42
+ if value == "path":
43
+ continue
44
+ choices_menus.append(key)
45
+ # commands
46
+ for name, _ in translators.items():
47
+ if any(f"{self.router_name}_{path}" in name for path in paths):
48
+ continue
49
+ new_name = name.replace(f"{self.router_name}_", "")
50
+ choices_commands.append(new_name)
51
+
52
+ Attributes["CHOICES_MENUS"] = choices_menus
53
+ Attributes["CHOICES_COMMANDS"] = choices_commands
54
+
55
+ # Use type to create the class
56
+ DynamicClass = type(ClassName, Parents, Attributes)
57
+
58
+ return DynamicClass
@@ -0,0 +1,488 @@
1
+ """Routine functions for OpenBB Platform CLI."""
2
+
3
+ import re
4
+ from datetime import datetime, timedelta
5
+ from typing import Dict, List, Match, Optional, Tuple, Union
6
+
7
+ from dateutil.relativedelta import relativedelta
8
+ from openbb_cli.session import Session
9
+
10
+ session = Session()
11
+
12
+ # pylint: disable=too-many-statements,eval-used,consider-iterating-dictionary
13
+ # pylint: disable=too-many-branches,too-many-return-statements
14
+
15
+ # Necessary for OpenBB keywords
16
+ MONTHS_VALUE = {
17
+ "JANUARY": 1,
18
+ "FEBRUARY": 2,
19
+ "MARCH": 3,
20
+ "APRIL": 4,
21
+ "MAY": 5,
22
+ "JUNE": 6,
23
+ "JULY": 7,
24
+ "AUGUST": 8,
25
+ "SEPTEMBER": 9,
26
+ "OCTOBER": 10,
27
+ "NOVEMBER": 11,
28
+ "DECEMBER": 12,
29
+ }
30
+
31
+ WEEKDAY_VALUE = {
32
+ "MONDAY": 0,
33
+ "TUESDAY": 1,
34
+ "WEDNESDAY": 2,
35
+ "THURSDAY": 3,
36
+ "FRIDAY": 4,
37
+ "SATURDAY": 5,
38
+ "SUNDAY": 6,
39
+ }
40
+
41
+
42
+ def is_reset(command: str) -> bool:
43
+ """Test whether a command is a reset command.
44
+
45
+ Parameters
46
+ ----------
47
+ command : str
48
+ The command to test
49
+
50
+ Returns
51
+ -------
52
+ answer : bool
53
+ Whether the command is a reset command
54
+ """
55
+ if "reset" in command:
56
+ return True
57
+ if command == "r":
58
+ return True
59
+ if command == "r\n":
60
+ return True
61
+ return False
62
+
63
+
64
+ def match_and_return_openbb_keyword_date(keyword: str) -> str: # noqa: PLR0911
65
+ """Return OpenBB keyword into date.
66
+
67
+ Parameters
68
+ ----------
69
+ keyword : str
70
+ String with potential OpenBB keyword (e.g. 1MONTHAGO,LASTFRIDAY,3YEARSFROMNOW,NEXTTUESDAY)
71
+
72
+ Returns
73
+ ----------
74
+ str: Date with format YYYY-MM-DD
75
+ """
76
+ now = datetime.now()
77
+ for i, regex in enumerate([r"^\$(\d+)([A-Z]+)AGO$", r"^\$(\d+)([A-Z]+)FROMNOW$"]):
78
+ match = re.match(regex, keyword)
79
+ if match:
80
+ integer_value = int(match.group(1))
81
+ time_unit = match.group(2)
82
+ clean_time = time_unit.upper()
83
+ if "DAYS" in clean_time or "MONTHS" in clean_time or "YEARS" in clean_time:
84
+ kwargs = {time_unit.lower(): integer_value}
85
+ if i == 0:
86
+ return (now - relativedelta(**kwargs)).strftime("%Y-%m-%d") # type: ignore
87
+ return (now + relativedelta(**kwargs)).strftime("%Y-%m-%d") # type: ignore
88
+
89
+ match = re.search(r"\$LAST(\w+)", keyword)
90
+ if match:
91
+ time_unit = match.group(1)
92
+ # Check if it corresponds to a month
93
+ if time_unit in list(MONTHS_VALUE.keys()):
94
+ the_year = now.year
95
+ # Calculate the year and month for last month date
96
+ if now.month <= MONTHS_VALUE[time_unit]:
97
+ # If the current month is greater than the last date month, it means it is this year
98
+ the_year = now.year - 1
99
+ return datetime(the_year, MONTHS_VALUE[time_unit], 1).strftime("%Y-%m-%d")
100
+
101
+ # Check if it corresponds to a week day
102
+ if time_unit in list(WEEKDAY_VALUE.keys()):
103
+ if datetime.weekday(now) > WEEKDAY_VALUE[time_unit]:
104
+ return (
105
+ now
106
+ - timedelta(datetime.weekday(now))
107
+ + timedelta(WEEKDAY_VALUE[time_unit])
108
+ ).strftime("%Y-%m-%d")
109
+ return (
110
+ now
111
+ - timedelta(7)
112
+ - timedelta(datetime.weekday(now))
113
+ + timedelta(WEEKDAY_VALUE[time_unit])
114
+ ).strftime("%Y-%m-%d")
115
+
116
+ match = re.search(r"\$NEXT(\w+)", keyword)
117
+ if match:
118
+ time_unit = match.group(1)
119
+ # Check if it corresponds to a month
120
+ if time_unit in list(MONTHS_VALUE.keys()):
121
+ # Calculate the year and month for next month date
122
+ if now.month < MONTHS_VALUE[time_unit]:
123
+ # If the current month is greater than the last date month, it means it is this year
124
+ return datetime(now.year, MONTHS_VALUE[time_unit], 1).strftime(
125
+ "%Y-%m-%d"
126
+ )
127
+
128
+ return datetime(now.year + 1, MONTHS_VALUE[time_unit], 1).strftime(
129
+ "%Y-%m-%d"
130
+ )
131
+
132
+ # Check if it corresponds to a week day
133
+ if time_unit in list(WEEKDAY_VALUE.keys()):
134
+ if datetime.weekday(now) < WEEKDAY_VALUE[time_unit]:
135
+ return (
136
+ now
137
+ - timedelta(datetime.weekday(now))
138
+ + timedelta(WEEKDAY_VALUE[time_unit])
139
+ ).strftime("%Y-%m-%d")
140
+ return (
141
+ now
142
+ + timedelta(7)
143
+ - timedelta(datetime.weekday(now))
144
+ + timedelta(WEEKDAY_VALUE[time_unit])
145
+ ).strftime("%Y-%m-%d")
146
+
147
+ return ""
148
+
149
+
150
+ def parse_openbb_script( # noqa: PLR0911,PLR0912
151
+ raw_lines: List[str],
152
+ script_inputs: Optional[List[str]] = None,
153
+ ) -> Tuple[str, str]:
154
+ """Parse .openbb script.
155
+
156
+ Parameters
157
+ ----------
158
+ raw_lines : List[str]
159
+ Lines from .openbb script
160
+ script_inputs: str, optional
161
+ Inputs to the script that come externally
162
+
163
+ Returns
164
+ -------
165
+ str
166
+ Error that occurred - if empty means no error
167
+ str
168
+ Processed string from .openbb script that can be run by the OpenBB Platform CLI
169
+ """
170
+ ROUTINE_VARS: Dict[str, Union[str, List[str]]] = dict()
171
+ if script_inputs:
172
+ ROUTINE_VARS["$ARGV"] = script_inputs
173
+
174
+ ## PRE PROCESSING
175
+ # Remove reset commands, comments, empty lines and trailing/leading whitespaces
176
+ raw_lines = [
177
+ x.strip()
178
+ for x in raw_lines
179
+ if (not is_reset(x)) and ("#" not in x) and x.strip()
180
+ ]
181
+
182
+ ## LOOK FOR NEW VARIABLES BEING DECLARED FROM USERS
183
+ lines_without_declarations = list()
184
+ for line in raw_lines:
185
+ # Check if this line has a variable attribution
186
+ # This currently allows user to override ARGV parameter
187
+ if "$" in line and "=" in line:
188
+ match = re.search(r"\$(\w+)\s*=\s*([\w\d,-.\s]+)", line)
189
+ if match:
190
+ VAR_NAME = match.group(1)
191
+ VAR_VALUES = match.group(2)
192
+ ROUTINE_VARS["$" + VAR_NAME] = (
193
+ VAR_VALUES if "," not in VAR_VALUES else VAR_VALUES.split(",")
194
+ )
195
+
196
+ # Just throw a warning when user uses wrong convention
197
+ numdollars = len(re.findall(r"\$", line))
198
+ if numdollars > 1:
199
+ session.console.print(
200
+ f"The variable {VAR_NAME} should not be declared as "
201
+ f"{'$' * numdollars}{VAR_NAME}. Instead it will be "
202
+ f"converted into ${VAR_NAME}."
203
+ )
204
+
205
+ else:
206
+ lines_without_declarations.append(line)
207
+ else:
208
+ lines_without_declarations.append(line)
209
+
210
+ # At this stage our ROUTINE_VARS should be completed coming from external AND from internal
211
+ # Now we want to replace the ROUTINE_VARS to where applicable throughout the .openbb script
212
+ # Due to this implementation, a variable declared at the end will still be effective
213
+
214
+ lines_with_vars_replaced = list()
215
+ foreach_loop_found = False
216
+ for line in lines_without_declarations:
217
+ # Save temporary line to ensure that all vars get replaced by correct vars
218
+ templine = line
219
+
220
+ # Found 'end' keyword which means that a loop has terminated
221
+ if re.match(r"^\s*end\s*$", line, re.IGNORECASE):
222
+ # Check whether the foreach loop has started or not
223
+ if not foreach_loop_found:
224
+ return (
225
+ "[red]The script has a foreach loop that terminates before it gets started. "
226
+ "Add the keyword 'foreach' to explicitly start loop[/red]",
227
+ "",
228
+ )
229
+ foreach_loop_found = False
230
+
231
+ else:
232
+ # Found 'foreach' keyword which means there needs to be a matching 'end'
233
+ if re.search(r"foreach", line, re.IGNORECASE):
234
+ foreach_loop_found = True
235
+
236
+ # Regular expression pattern to match variables starting with $
237
+ pattern = r"(?<!\$)(\$(\w+)(\[[^\]]*\])?)(?=(?:[^\]]*\]*))"
238
+
239
+ # Find all matches of the pattern in the line
240
+ matches: Optional[List[Match[str]]] = re.findall(pattern, line)
241
+
242
+ if matches:
243
+ for match in matches:
244
+ if match:
245
+ VAR_NAME = "$" + match[1]
246
+ VAR_SLICE = match[2][1:-1] if match[2] else ""
247
+
248
+ # Within a list refers to a single element
249
+ if VAR_SLICE.isdigit():
250
+ # This is an edge case for when the user has a variable such as $DATE = 2022-01-01
251
+ # We want the user to be able to access it with $DATE or $DATE[0] and the latest
252
+ # in python will only take the first '2'
253
+ if VAR_SLICE == "0":
254
+ if VAR_NAME in ROUTINE_VARS:
255
+ values = eval( # noqa: S307
256
+ f'ROUTINE_VARS["{VAR_NAME}"]'
257
+ )
258
+ if isinstance(values, list):
259
+ templine = templine.replace(
260
+ match[0],
261
+ eval(f"values[{VAR_SLICE}]"), # noqa: S307
262
+ )
263
+ else:
264
+ templine = templine.replace(match[0], values)
265
+ else:
266
+ return (
267
+ f"[red]Variable {VAR_NAME} not given "
268
+ "for current routine script.[/red]",
269
+ "",
270
+ )
271
+
272
+ # Only enters here when any other index from 0 is used
273
+ elif VAR_NAME in ROUTINE_VARS:
274
+ variable = eval( # noqa: S307
275
+ f'ROUTINE_VARS["{VAR_NAME}"]'
276
+ )
277
+ length_variable = (
278
+ len(variable) if isinstance(variable, list) else 1
279
+ )
280
+
281
+ # We use <= because we are using 0 index based lists
282
+ if length_variable <= int(VAR_SLICE):
283
+ return (
284
+ f"[red]Variable {VAR_NAME} only has "
285
+ f"{length_variable} elements and there "
286
+ f"was an attempt to access it with index {VAR_SLICE}.[/red]",
287
+ "",
288
+ )
289
+ templine = templine.replace(
290
+ match[0],
291
+ variable[int(VAR_SLICE)],
292
+ )
293
+ else:
294
+ return (
295
+ f"[red]Variable {VAR_NAME} not given for current routine script.[/red]",
296
+ "",
297
+ )
298
+
299
+ # Involves slicing which is a bit more tricky to use eval on
300
+ elif (
301
+ ":" in VAR_SLICE
302
+ and len(VAR_SLICE.split(":")) == 2
303
+ and (
304
+ VAR_SLICE.split(":")[0].isdigit()
305
+ or VAR_SLICE.split(":")[1].isdigit()
306
+ )
307
+ ):
308
+ slicing_tuple = "slice("
309
+ slicing_tuple += (
310
+ VAR_SLICE.split(":")[0]
311
+ if VAR_SLICE.split(":")[0].isdigit()
312
+ else "None"
313
+ )
314
+ slicing_tuple += ","
315
+ slicing_tuple += (
316
+ VAR_SLICE.split(":")[1]
317
+ if VAR_SLICE.split(":")[1].isdigit()
318
+ else "None"
319
+ )
320
+ slicing_tuple += ")"
321
+
322
+ vars_to_loop = eval( # noqa: S307
323
+ f'ROUTINE_VARS["{VAR_NAME}"][{slicing_tuple}]'
324
+ )
325
+
326
+ # Check whether the slicing was successful or not
327
+ if vars_to_loop:
328
+ templine = templine.replace(
329
+ match[0],
330
+ ",".join(vars_to_loop),
331
+ )
332
+ else:
333
+ return (
334
+ f"[red]The foreach loop cannot run with input: {match[0]}.[/red]",
335
+ "",
336
+ )
337
+
338
+ # Just replace value without slicing or list
339
+ else:
340
+ if VAR_SLICE:
341
+ # Check if the string starts with a minus sign
342
+ if VAR_SLICE.startswith("-"):
343
+ if not VAR_SLICE[1:].isdigit():
344
+ return (
345
+ f"[red]Index '{VAR_SLICE}' is not a value[/red]",
346
+ "",
347
+ )
348
+ if int(VAR_SLICE) < 0:
349
+ return (
350
+ f"[red]Negative index on {VAR_NAME} is not allowed[/red]",
351
+ "",
352
+ )
353
+ if not VAR_SLICE.isdigit():
354
+ return (
355
+ f"[red]Index '{VAR_SLICE}' is not a value[/red]",
356
+ "",
357
+ )
358
+
359
+ if VAR_NAME in ROUTINE_VARS:
360
+ value = eval( # noqa: S307
361
+ f'ROUTINE_VARS["{VAR_NAME}"]'
362
+ )
363
+
364
+ # If the value is a list, we want to replace it with the whole list
365
+ if isinstance(value, list):
366
+ templine = templine.replace(
367
+ match[0],
368
+ ",".join(value),
369
+ )
370
+ else:
371
+ templine = templine.replace(match[0], value)
372
+
373
+ else:
374
+ # Check if this is an OpenBB keyword variable like
375
+ # 1MONTHAGO,LASTFRIDAY,3YEARSFROMNOW,NEXTTUESDAY
376
+ # and decode it into the right date if it exists
377
+ potential_date_match = (
378
+ match_and_return_openbb_keyword_date(VAR_NAME)
379
+ )
380
+ if potential_date_match:
381
+ templine = templine.replace(
382
+ match[0], potential_date_match
383
+ )
384
+ else:
385
+ return (
386
+ f"[red]Variable {VAR_NAME} not given for "
387
+ "current routine script.[/red]",
388
+ "",
389
+ )
390
+
391
+ lines_with_vars_replaced.append(templine)
392
+
393
+ # If this flags ends in True it means that the script routine has a foreach loop that never terminates
394
+ if foreach_loop_found:
395
+ return (
396
+ "[red]The script has a foreach loop that doesn't terminate. "
397
+ "Add the keyword 'end' to explicitly terminate loop[/red]",
398
+ "",
399
+ )
400
+
401
+ # Finally the only remaining thing to address are the foreach loops. For that we'll go through
402
+ # those lines and unroll the arguments that will be iterated by.
403
+ # Note that the fact that we checked before that the amount of foreach and end matches allow us
404
+ # to be confident that the script has no clear issues.
405
+
406
+ within_foreach = False
407
+ foreach_lines_loop: List[str] = list()
408
+
409
+ parsed_script = ""
410
+ final_lines = list()
411
+ varname = "VAR"
412
+ varused_inside = False
413
+ for line in lines_with_vars_replaced:
414
+ # Found 'foreach' header associated with loop
415
+ match = re.search(
416
+ r"foreach \$\$([A-Za-z\_]+) in ([A-Za-z0-9,-.]+)", line, re.IGNORECASE
417
+ )
418
+ if match:
419
+ varname = match.group(1)
420
+ foreach_loop = match.group(2).split(",")
421
+ within_foreach = True
422
+
423
+ # We are inside a loop and this is a line that we will want to replicate,
424
+ # so we need to temporarily store it until we reach the end
425
+ elif within_foreach:
426
+ # Found 'end' keyword which means that the foreach loop has reached the end
427
+ if re.match(r"^\s*end\s*$", line, re.IGNORECASE):
428
+ # Now we want to process what we were waiting for before
429
+
430
+ # Iterate through main foreach header
431
+ for var in foreach_loop:
432
+ # Iterate through all lines within foreach and end loop
433
+ for foreach_line_loop in foreach_lines_loop:
434
+ if f"$${varname}" in foreach_line_loop:
435
+ final_lines.append(
436
+ foreach_line_loop.replace(f"$${varname}", var).strip()
437
+ )
438
+ varused_inside = True
439
+ elif "$$" in foreach_line_loop:
440
+ return (
441
+ "[red]The script has a foreach loop that iterates through "
442
+ f"{','.join(foreach_loop)} with variable $${varname} "
443
+ "but another var name is being utilized instead[/red]",
444
+ "",
445
+ )
446
+ else:
447
+ final_lines.append(foreach_line_loop.strip())
448
+
449
+ if not varused_inside:
450
+ session.console.print(
451
+ f"The variable {varname} was used in foreach header "
452
+ "but it wasn't used inside the loop."
453
+ )
454
+ varused_inside = False
455
+
456
+ # Since this has been processed we reset the foreach loop lines
457
+ within_foreach = False
458
+ foreach_lines_loop = list()
459
+
460
+ else:
461
+ foreach_lines_loop.append(line)
462
+
463
+ else:
464
+ final_lines.append(line)
465
+
466
+ # If the list is non null, then we want to convert this into a parsed string that is
467
+ # recognized by the OpenBB Platform CLI
468
+ if final_lines:
469
+ parsed_script = f"{'/'.join([line.rstrip() for line in final_lines])}".replace(
470
+ "//", "/home/"
471
+ )
472
+ if parsed_script[0] == "/":
473
+ # If the user had added a / at the beginning, then it was converted to //home/
474
+ # and we need to remove it
475
+ if parsed_script.startswith("//home"):
476
+ parsed_script = parsed_script[6:]
477
+ else:
478
+ # We want the script to start from the home menu, hence we add it if the user
479
+ # didn't add it
480
+ parsed_script = "/" + parsed_script
481
+
482
+ # If the script finishes with // it means that we converted it to /home/
483
+ # This means that we are expecting a command to follow, but since this is
484
+ # the end of the script, we need to remove the trailing /
485
+ if parsed_script.endswith("/home/"):
486
+ parsed_script = parsed_script[:-1]
487
+
488
+ return "", parsed_script