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,967 @@
1
+ """Utils."""
2
+
3
+ import argparse
4
+ import os
5
+ import random
6
+ import re
7
+ import shutil
8
+ import sys
9
+ from contextlib import contextmanager
10
+ from datetime import (
11
+ datetime,
12
+ )
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+ import requests
19
+ from openbb import obb
20
+ from openbb_charting.core.backend import create_backend, get_backend
21
+ from openbb_cli.config.constants import AVAILABLE_FLAIRS, ENV_FILE_SETTINGS
22
+ from openbb_cli.session import Session
23
+ from openbb_core.app.model.charts.charting_settings import ChartingSettings
24
+ from pytz import all_timezones, timezone
25
+ from rich.table import Table
26
+
27
+ if TYPE_CHECKING:
28
+ from openbb_charting.core.openbb_figure import OpenBBFigure
29
+
30
+ # pylint: disable=R1702,R0912
31
+
32
+
33
+ # pylint: disable=too-many-statements,no-member,too-many-branches,C0302
34
+
35
+
36
+ def remove_file(path: Path) -> bool:
37
+ """Remove path.
38
+
39
+ Parameters
40
+ ----------
41
+ path : Path
42
+ The file path.
43
+
44
+ Returns
45
+ -------
46
+ bool
47
+ The status of the removal.
48
+ """
49
+ # TODO: Check why module level import leads to circular import.
50
+ try:
51
+ if os.path.isfile(path):
52
+ os.remove(path)
53
+ elif os.path.isdir(path):
54
+ shutil.rmtree(path)
55
+ return True
56
+ except Exception:
57
+ Session().console.print(
58
+ f"\n[bold red]Failed to remove {path}"
59
+ "\nPlease delete this manually![/bold red]"
60
+ )
61
+ return False
62
+
63
+
64
+ def print_goodbye():
65
+ """Print a goodbye message when quitting the terminal."""
66
+ # LEGACY GOODBYE MESSAGES - You'll live in our hearts forever.
67
+ # "An informed ape, is a strong ape."
68
+ # "Remember that stonks only go up."
69
+ # "Diamond hands."
70
+ # "Apes together strong."
71
+ # "This is our way."
72
+ # "Keep the spacesuit ape, we haven't reached the moon yet."
73
+ # "I am not a cat. I'm an ape."
74
+ # "We like the terminal."
75
+ # "...when offered a flight to the moon, nobody asks about what seat."
76
+
77
+ text = """
78
+ [param]Thank you for using the OpenBB Platform CLI and being part of this journey.[/param]
79
+
80
+ We hope you'll find the new OpenBB Platform CLI a valuable tool.
81
+
82
+ To stay tuned, sign up for our newsletter: [cmds]https://openbb.co/newsletter.[/]
83
+
84
+ Please feel free to check out our other products:
85
+
86
+ [bold]OpenBB Terminal Pro[/]: [cmds]https://openbb.co/products/pro[/cmds]
87
+ [bold]OpenBB Platform:[/] [cmds]https://openbb.co/products/platform[/cmds]
88
+ [bold]OpenBB Bot[/]: [cmds]https://openbb.co/products/bot[/cmds]
89
+ """
90
+ Session().console.print(text)
91
+
92
+
93
+ def print_guest_block_msg():
94
+ """Block guest users from using the cli."""
95
+ if Session().is_local():
96
+ Session().console.print(
97
+ "[info]You are currently logged as a guest.[/info]\n"
98
+ "[info]Login to use this feature.[/info]\n\n"
99
+ "[info]If you don't have an account, you can create one here: [/info]"
100
+ f"[cmds]{Session().settings.HUB_URL + '/register'}\n[/cmds]"
101
+ )
102
+
103
+
104
+ def bootup():
105
+ """Bootup the cli."""
106
+ if sys.platform == "win32":
107
+ # Enable VT100 Escape Sequence for WINDOWS 10 Ver. 1607
108
+ os.system("") # nosec # noqa: S605,S607
109
+
110
+ try:
111
+ if os.name == "nt":
112
+ # pylint: disable=E1101
113
+ sys.stdin.reconfigure(encoding="utf-8") # type: ignore
114
+ # pylint: disable=E1101
115
+ sys.stdout.reconfigure(encoding="utf-8") # type: ignore
116
+ except Exception as e:
117
+ Session().console.print(e, "\n")
118
+
119
+
120
+ def welcome_message():
121
+ """Print the welcome message.
122
+
123
+ Prints first welcome message, help and a notification if updates are available.
124
+ """
125
+ Session().console.print(
126
+ f"\nWelcome to OpenBB Platform CLI v{Session().settings.VERSION}"
127
+ )
128
+
129
+
130
+ def reset(queue: Optional[List[str]] = None):
131
+ """Reset the CLI.
132
+
133
+ Allows for checking code without quitting.
134
+ """
135
+ Session().console.print("resetting...")
136
+ Session().reset()
137
+ debug = Session().settings.DEBUG_MODE
138
+ dev = Session().settings.DEV_BACKEND
139
+
140
+ try:
141
+ # remove the hub routines
142
+ if not Session().is_local():
143
+ remove_file(
144
+ Path(Session().user.preferences.export_directory, "routines", "hub")
145
+ )
146
+
147
+ # if not get_current_user().profile.remember:
148
+ # Local.remove(HIST_FILE_PROMPT)
149
+
150
+ # we clear all openbb_cli modules from sys.modules
151
+ for module in list(sys.modules.keys()):
152
+ parts = module.split(".")
153
+ if parts[0] == "openbb_cli":
154
+ del sys.modules[module]
155
+
156
+ queue_list = ["/".join(queue) if len(queue) > 0 else ""] # type: ignore
157
+ # pylint: disable=import-outside-toplevel
158
+ # we run the cli again
159
+ if Session().is_local():
160
+ from openbb_cli.controllers.cli_controller import main
161
+
162
+ main(debug, dev, queue_list, module="") # type: ignore
163
+ else:
164
+ from openbb_cli.controllers.cli_controller import launch
165
+
166
+ launch(queue=queue_list)
167
+
168
+ except Exception as e:
169
+ Session().console.print(f"Unfortunately, resetting wasn't possible: {e}\n")
170
+ print_goodbye()
171
+
172
+
173
+ @contextmanager
174
+ def suppress_stdout():
175
+ """Suppress the stdout."""
176
+ with open(os.devnull, "w") as devnull:
177
+ old_stdout = sys.stdout
178
+ old_stderr = sys.stderr
179
+ sys.stdout = devnull
180
+ sys.stderr = devnull
181
+ try:
182
+ yield
183
+ finally:
184
+ sys.stdout = old_stdout
185
+ sys.stderr = old_stderr
186
+
187
+
188
+ def first_time_user() -> bool:
189
+ """Check whether a user is a first time user.
190
+
191
+ A first time user is someone with an empty .env file.
192
+ If this is true, it also adds an env variable to make sure this does not run again.
193
+
194
+ Returns
195
+ -------
196
+ bool
197
+ Whether or not the user is a first time user
198
+ """
199
+ if ENV_FILE_SETTINGS.stat().st_size == 0:
200
+ Session().settings.set_item("PREVIOUS_USE", True)
201
+ return True
202
+ return False
203
+
204
+
205
+ def parse_and_split_input(an_input: str, custom_filters: List) -> List[str]:
206
+ """Filter and split the input queue.
207
+
208
+ Uses regex to filters command arguments that have forward slashes so that it doesn't
209
+ break the execution of the command queue.
210
+ Currently handles unix paths and sorting settings for screener menus.
211
+
212
+ Parameters
213
+ ----------
214
+ an_input : str
215
+ User input as string
216
+ custom_filters : List
217
+ Additional regular expressions to match
218
+
219
+ Returns
220
+ -------
221
+ List[str]
222
+ Command queue as list
223
+ """
224
+ # Make sure that the user can go back to the root when doing "/"
225
+ if an_input and an_input == "/":
226
+ an_input = "home"
227
+
228
+ # everything from ` -f ` to the next known extension
229
+ file_flag = r"(\ -f |\ --file )"
230
+ up_to = r".*?"
231
+ known_extensions = r"(\.(xlsx|csv|xls|tsv|json|yaml|ini|openbb|ipynb))"
232
+ unix_path_arg_exp = f"({file_flag}{up_to}{known_extensions})"
233
+
234
+ # Add custom expressions to handle edge cases of individual controllers
235
+ custom_filter = ""
236
+ for exp in custom_filters:
237
+ if exp is not None:
238
+ custom_filter += f"|{exp}"
239
+ del exp
240
+
241
+ slash_filter_exp = f"({unix_path_arg_exp}){custom_filter}"
242
+
243
+ filter_input = True
244
+ placeholders: Dict[str, str] = {}
245
+ while filter_input:
246
+ match = re.search(pattern=slash_filter_exp, string=an_input)
247
+ if match is not None:
248
+ placeholder = f"{{placeholder{len(placeholders)+1}}}"
249
+ placeholders[placeholder] = an_input[
250
+ match.span()[0] : match.span()[1] # noqa:E203
251
+ ]
252
+ an_input = (
253
+ an_input[: match.span()[0]]
254
+ + placeholder
255
+ + an_input[match.span()[1] :] # noqa:E203
256
+ )
257
+ else:
258
+ filter_input = False
259
+
260
+ commands = an_input.split("/") if "-t" not in an_input else [an_input]
261
+
262
+ for command_num, command in enumerate(commands):
263
+ if command == commands[command_num] == commands[-1] == "":
264
+ return list(filter(None, commands))
265
+ matching_placeholders = [tag for tag in placeholders if tag in command]
266
+ if len(matching_placeholders) > 0:
267
+ for tag in matching_placeholders:
268
+ commands[command_num] = command.replace(tag, placeholders[tag])
269
+ return commands
270
+
271
+
272
+ def return_colored_value(value: str):
273
+ """Return the string value based on condition.
274
+
275
+ Return it with green, yellow, red or white color based on
276
+ whether the number is positive, negative, zero or other, respectively.
277
+
278
+ Parameters
279
+ ----------
280
+ value: str
281
+ string to be checked
282
+
283
+ Returns
284
+ -------
285
+ value: str
286
+ string with color based on value of number if it exists
287
+ """
288
+ values = re.findall(r"[-+]?(?:\d*\.\d+|\d+)", value)
289
+
290
+ # Finds exactly 1 number in the string
291
+ if len(values) == 1:
292
+ if float(values[0]) > 0:
293
+ return f"[green]{value}[/green]"
294
+
295
+ if float(values[0]) < 0:
296
+ return f"[red]{value}[/red]"
297
+
298
+ if float(values[0]) == 0:
299
+ return f"[yellow]{value}[/yellow]"
300
+
301
+ return f"{value}"
302
+
303
+
304
+ def _get_backend():
305
+ """Get the Platform charting backend."""
306
+ try:
307
+ return get_backend()
308
+ except ValueError:
309
+ # backend might not be created yet
310
+ charting_settings = ChartingSettings(
311
+ system_settings=obb.system, user_settings=obb.user # type: ignore
312
+ )
313
+ create_backend(charting_settings)
314
+ get_backend().start(debug=charting_settings.debug_mode)
315
+ return get_backend()
316
+
317
+
318
+ # pylint: disable=too-many-arguments
319
+ def print_rich_table( # noqa: PLR0912
320
+ df: pd.DataFrame,
321
+ show_index: bool = False,
322
+ title: str = "",
323
+ index_name: str = "",
324
+ headers: Optional[Union[List[str], pd.Index]] = None,
325
+ floatfmt: Union[str, List[str]] = ".2f",
326
+ show_header: bool = True,
327
+ automatic_coloring: bool = False,
328
+ columns_to_auto_color: Optional[List[str]] = None,
329
+ rows_to_auto_color: Optional[List[str]] = None,
330
+ export: bool = False,
331
+ limit: Optional[int] = 1000,
332
+ columns_keep_types: Optional[List[str]] = None,
333
+ use_tabulate_df: bool = True,
334
+ ):
335
+ """Prepare a table from df in rich.
336
+
337
+ Parameters
338
+ ----------
339
+ df: pd.DataFrame
340
+ Dataframe to turn into table
341
+ show_index: bool
342
+ Whether to include index
343
+ title: str
344
+ Title for table
345
+ index_name : str
346
+ Title for index column
347
+ headers: List[str]
348
+ Titles for columns
349
+ floatfmt: Union[str, List[str]]
350
+ Float number formatting specs as string or list of strings. Defaults to ".2f"
351
+ show_header: bool
352
+ Whether to show the header row.
353
+ automatic_coloring: bool
354
+ Automatically color a table based on positive and negative values
355
+ columns_to_auto_color: List[str]
356
+ Columns to automatically color
357
+ rows_to_auto_color: List[str]
358
+ Rows to automatically color
359
+ export: bool
360
+ Whether we are exporting the table to a file. If so, we don't want to print it.
361
+ limit: Optional[int]
362
+ Limit the number of rows to show.
363
+ columns_keep_types: Optional[List[str]]
364
+ Columns to keep their types, i.e. not convert to numeric
365
+ """
366
+ if export:
367
+ return
368
+
369
+ MAX_COLS = Session().settings.ALLOWED_NUMBER_OF_COLUMNS
370
+ MAX_ROWS = Session().settings.ALLOWED_NUMBER_OF_ROWS
371
+
372
+ # Make a copy of the dataframe to avoid SettingWithCopyWarning
373
+ df = df.copy()
374
+
375
+ show_index = not isinstance(df.index, pd.RangeIndex) and show_index
376
+ # convert non-str that are not timestamp or int into str
377
+ # eg) praw.models.reddit.subreddit.Subreddit
378
+ for col in df.columns:
379
+ if columns_keep_types is not None and col in columns_keep_types:
380
+ continue
381
+ try:
382
+ if not any(
383
+ isinstance(df[col].iloc[x], pd.Timestamp)
384
+ for x in range(min(10, len(df)))
385
+ ):
386
+ df[col] = pd.to_numeric(df[col], errors="ignore")
387
+ except (ValueError, TypeError):
388
+ df[col] = df[col].astype(str)
389
+
390
+ def _get_headers(_headers: Union[List[str], pd.Index]) -> List[str]:
391
+ """Check if headers are valid and return them."""
392
+ output = _headers
393
+ if isinstance(_headers, pd.Index):
394
+ output = list(_headers)
395
+ if len(output) != len(df.columns):
396
+ raise ValueError("Length of headers does not match length of DataFrame.")
397
+ return output
398
+
399
+ if Session().settings.USE_INTERACTIVE_DF:
400
+ df_outgoing = df.copy()
401
+ # If headers are provided, use them
402
+ if headers is not None:
403
+ # We check if headers are valid
404
+ df_outgoing.columns = _get_headers(headers)
405
+
406
+ if show_index and index_name not in df_outgoing.columns:
407
+ # If index name is provided, we use it
408
+ df_outgoing.index.name = index_name or "Index"
409
+ df_outgoing = df_outgoing.reset_index()
410
+
411
+ for col in df_outgoing.columns:
412
+ if col == "":
413
+ df_outgoing = df_outgoing.rename(columns={col: " "})
414
+
415
+ # ensure everything on the dataframe is a string
416
+ df_outgoing = df_outgoing.applymap(str)
417
+
418
+ _get_backend().send_table(
419
+ df_table=df_outgoing,
420
+ title=title,
421
+ theme=Session().user.preferences.table_style,
422
+ )
423
+ return
424
+
425
+ df = df.copy() if not limit else df.copy().iloc[:limit]
426
+ if automatic_coloring:
427
+ if columns_to_auto_color:
428
+ for col in columns_to_auto_color:
429
+ # checks whether column exists
430
+ if col in df.columns:
431
+ df[col] = df[col].apply(lambda x: return_colored_value(str(x)))
432
+ if rows_to_auto_color:
433
+ for row in rows_to_auto_color:
434
+ # checks whether row exists
435
+ if row in df.index:
436
+ df.loc[row] = df.loc[row].apply(
437
+ lambda x: return_colored_value(str(x))
438
+ )
439
+
440
+ if columns_to_auto_color is None and rows_to_auto_color is None:
441
+ df = df.applymap(lambda x: return_colored_value(str(x)))
442
+
443
+ exceeds_allowed_columns = len(df.columns) > MAX_COLS
444
+ exceeds_allowed_rows = len(df) > MAX_ROWS
445
+
446
+ if exceeds_allowed_columns:
447
+ original_columns = df.columns.tolist()
448
+ trimmed_columns = df.columns.tolist()[:MAX_COLS]
449
+ df = df[trimmed_columns]
450
+ trimmed_columns = [
451
+ col for col in original_columns if col not in trimmed_columns
452
+ ]
453
+
454
+ if exceeds_allowed_rows:
455
+ n_rows = len(df.index)
456
+ max_rows = MAX_ROWS
457
+ df = df[:max_rows]
458
+ trimmed_rows_count = n_rows - max_rows
459
+
460
+ if use_tabulate_df:
461
+ table = Table(title=title, show_lines=True, show_header=show_header)
462
+
463
+ if show_index:
464
+ table.add_column(index_name)
465
+
466
+ if headers is not None:
467
+ headers = _get_headers(headers)
468
+ for header in headers:
469
+ table.add_column(str(header))
470
+ else:
471
+ for column in df.columns:
472
+ table.add_column(str(column))
473
+
474
+ if isinstance(floatfmt, list) and len(floatfmt) != len(df.columns):
475
+ raise (
476
+ ValueError(
477
+ "Length of floatfmt list does not match length of DataFrame columns."
478
+ )
479
+ )
480
+ if isinstance(floatfmt, str):
481
+ floatfmt = [floatfmt for _ in range(len(df.columns))]
482
+
483
+ for idx, values in zip(df.index.tolist(), df.values.tolist()):
484
+ # remove hour/min/sec from timestamp index - Format: YYYY-MM-DD # make better
485
+ row_idx = [str(idx)] if show_index else []
486
+ row_idx += [
487
+ (
488
+ str(x)
489
+ if not isinstance(x, float) and not isinstance(x, np.float64)
490
+ else (
491
+ f"{x:{floatfmt[idx]}}"
492
+ if isinstance(floatfmt, list)
493
+ else (
494
+ f"{x:.2e}"
495
+ if 0 < abs(float(x)) <= 0.0001
496
+ else f"{x:floatfmt}"
497
+ )
498
+ )
499
+ )
500
+ for idx, x in enumerate(values)
501
+ ]
502
+ table.add_row(*row_idx)
503
+ Session().console.print(table)
504
+ else:
505
+ Session().console.print(df.to_string(col_space=0))
506
+
507
+ if exceeds_allowed_columns:
508
+ Session().console.print(
509
+ f"[yellow]\nAllowed number of columns exceeded ({Session().settings.ALLOWED_NUMBER_OF_COLUMNS}).\n"
510
+ f"The following columns were removed from the output: {', '.join(trimmed_columns)}.\n[/yellow]"
511
+ )
512
+
513
+ if exceeds_allowed_rows:
514
+ Session().console.print(
515
+ f"[yellow]\nAllowed number of rows exceeded ({Session().settings.ALLOWED_NUMBER_OF_ROWS}).\n"
516
+ f"{trimmed_rows_count} rows were removed from the output.\n[/yellow]"
517
+ )
518
+
519
+ if exceeds_allowed_columns or exceeds_allowed_rows:
520
+ Session().console.print(
521
+ "Use the `--export` flag to analyse the full output on a file."
522
+ )
523
+
524
+
525
+ def check_non_negative(value) -> int:
526
+ """Argparse type to check non negative int."""
527
+ new_value = int(value)
528
+ if new_value < 0:
529
+ raise argparse.ArgumentTypeError(f"{value} is negative")
530
+ return new_value
531
+
532
+
533
+ def check_positive(value) -> int:
534
+ """Argparse type to check positive int."""
535
+ new_value = int(value)
536
+ if new_value <= 0:
537
+ raise argparse.ArgumentTypeError(f"{value} is an invalid positive int value")
538
+ return new_value
539
+
540
+
541
+ def get_user_agent() -> str:
542
+ """Get a not very random user agent."""
543
+ user_agent_strings = [
544
+ "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.10; rv:86.1) Gecko/20100101 Firefox/86.1",
545
+ "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:86.1) Gecko/20100101 Firefox/86.1",
546
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:82.1) Gecko/20100101 Firefox/82.1",
547
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:86.0) Gecko/20100101 Firefox/86.0",
548
+ "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:86.0) Gecko/20100101 Firefox/86.0",
549
+ "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.10; rv:83.0) Gecko/20100101 Firefox/83.0",
550
+ "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:84.0) Gecko/20100101 Firefox/84.0",
551
+ ]
552
+
553
+ return random.choice(user_agent_strings) # nosec # noqa: S311
554
+
555
+
556
+ def get_flair() -> str:
557
+ """Get a flair icon."""
558
+ current_flair = str(Session().settings.FLAIR)
559
+ flair = AVAILABLE_FLAIRS.get(current_flair, current_flair)
560
+ return flair
561
+
562
+
563
+ def get_dtime() -> str:
564
+ """Get a datetime string."""
565
+ dtime = ""
566
+ if Session().settings.USE_DATETIME and get_user_timezone_or_invalid() != "INVALID":
567
+ dtime = datetime.now(timezone(get_user_timezone())).strftime("%Y %b %d, %H:%M")
568
+ return dtime
569
+
570
+
571
+ def get_flair_and_username() -> str:
572
+ """Get a flair icon and username."""
573
+ flair = get_flair()
574
+ dtime = get_dtime()
575
+
576
+ if dtime:
577
+ dtime = f"{dtime} "
578
+
579
+ username = getattr(Session().user.profile.hub_session, "username", "")
580
+ if username:
581
+ username = f"[{username}] "
582
+
583
+ return f"{dtime}{username}{flair}"
584
+
585
+
586
+ def is_timezone_valid(user_tz: str) -> bool:
587
+ """Check whether user timezone is valid.
588
+
589
+ Parameters
590
+ ----------
591
+ user_tz: str
592
+ Timezone to check for validity
593
+
594
+ Returns
595
+ -------
596
+ bool
597
+ True if timezone provided is valid
598
+ """
599
+ return user_tz in all_timezones
600
+
601
+
602
+ def get_user_timezone() -> str:
603
+ """Get user timezone if it is a valid one.
604
+
605
+ Returns
606
+ -------
607
+ str
608
+ user timezone based on .env file
609
+ """
610
+ return Session().settings.TIMEZONE
611
+
612
+
613
+ def get_user_timezone_or_invalid() -> str:
614
+ """Get user timezone if it is a valid one.
615
+
616
+ Returns
617
+ -------
618
+ str
619
+ user timezone based on timezone.openbb file or INVALID
620
+ """
621
+ user_tz = get_user_timezone()
622
+ if is_timezone_valid(user_tz):
623
+ return f"{user_tz}"
624
+ return "INVALID"
625
+
626
+
627
+ def check_file_type_saved(valid_types: Optional[List[str]] = None):
628
+ """Provide valid types for the user to be able to select.
629
+
630
+ Parameters
631
+ ----------
632
+ valid_types: List[str]
633
+ List of valid types to export data
634
+
635
+ Returns
636
+ -------
637
+ check_filenames: Optional[List[str]]
638
+ Function that returns list of filenames to export data
639
+ """
640
+
641
+ def check_filenames(filenames: str = "") -> str:
642
+ """Check if filenames are valid.
643
+
644
+ Parameters
645
+ ----------
646
+ filenames: str
647
+ filenames to be saved separated with comma
648
+
649
+ Returns
650
+ ----------
651
+ str
652
+ valid filenames separated with comma
653
+ """
654
+ if not filenames or not valid_types:
655
+ return ""
656
+ valid_filenames = list()
657
+ for filename in filenames.split(","):
658
+ if filename.endswith(tuple(valid_types)):
659
+ valid_filenames.append(filename)
660
+ else:
661
+ Session().console.print(
662
+ f"[red]Filename '{filename}' provided is not valid!\nPlease use one of the following file types:"
663
+ f"{','.join(valid_types)}[/red]\n"
664
+ )
665
+ return ",".join(valid_filenames)
666
+
667
+ return check_filenames
668
+
669
+
670
+ def remove_timezone_from_dataframe(df: pd.DataFrame) -> pd.DataFrame:
671
+ """Remove timezone information from a dataframe.
672
+
673
+ Parameters
674
+ ----------
675
+ df : pd.DataFrame
676
+ The dataframe to remove timezone information from
677
+
678
+ Returns
679
+ -------
680
+ pd.DataFrame
681
+ The dataframe with timezone information removed
682
+ """
683
+
684
+ date_cols = []
685
+ index_is_date = False
686
+
687
+ # Find columns and index containing date data
688
+ if (
689
+ df.index.dtype.kind == "M"
690
+ and hasattr(df.index.dtype, "tz")
691
+ and df.index.dtype.tz is not None
692
+ ):
693
+ index_is_date = True
694
+
695
+ for col, dtype in df.dtypes.items():
696
+ if dtype.kind == "M" and hasattr(df.index.dtype, "tz") and dtype.tz is not None:
697
+ date_cols.append(col)
698
+
699
+ # Remove the timezone information
700
+ for col in date_cols:
701
+ df[col] = df[col].dt.date
702
+
703
+ if index_is_date:
704
+ index_name = df.index.name
705
+ df.index = df.index.date
706
+ df.index.name = index_name
707
+
708
+ return df
709
+
710
+
711
+ def compose_export_path(func_name: str, dir_path: str) -> Path:
712
+ """Compose export path for data from the terminal.
713
+
714
+ Creates a path to a folder and a filename based on conditions.
715
+
716
+ Parameters
717
+ ----------
718
+ func_name : str
719
+ Name of the command that invokes this function
720
+ dir_path : str
721
+ Path of directory from where this function is called
722
+
723
+ Returns
724
+ -------
725
+ Path
726
+ Path variable containing the path of the exported file
727
+ """
728
+ now = datetime.now()
729
+ # Resolving all symlinks and also normalizing path.
730
+ resolve_path = Path(dir_path).resolve()
731
+ # Getting the directory names from the path. Instead of using split/replace (Windows doesn't like that)
732
+ # check if this is done in a main context to avoid saving with openbb_cli
733
+ if resolve_path.parts[-2] == "openbb_cli":
734
+ path_cmd = f"{resolve_path.parts[-1]}"
735
+ else:
736
+ path_cmd = f"{resolve_path.parts[-2]}_{resolve_path.parts[-1]}"
737
+
738
+ default_filename = f"{now.strftime('%Y%m%d_%H%M%S')}_{path_cmd}_{func_name}"
739
+
740
+ full_path = Path(Session().user.preferences.export_directory) / default_filename
741
+
742
+ return full_path
743
+
744
+
745
+ def ask_file_overwrite(file_path: Path) -> Tuple[bool, bool]:
746
+ """Provide a prompt for overwriting existing files.
747
+
748
+ Returns two values, the first is a boolean indicating if the file exists and the
749
+ second is a boolean indicating if the user wants to overwrite the file.
750
+ """
751
+ if Session().settings.FILE_OVERWRITE:
752
+ return False, True
753
+ if Session().settings.TEST_MODE:
754
+ return False, True
755
+ if file_path.exists():
756
+ overwrite = input("\nFile already exists. Overwrite? [y/n]: ").lower()
757
+ if overwrite == "y":
758
+ file_path.unlink(missing_ok=True)
759
+ # File exists and user wants to overwrite
760
+ return True, True
761
+ # File exists and user does not want to overwrite
762
+ return True, False
763
+ # File does not exist
764
+ return False, True
765
+
766
+
767
+ # This is a false positive on pylint and being tracked in pylint #3060
768
+ # pylint: disable=abstract-class-instantiated
769
+ def save_to_excel(df, saved_path, sheet_name, start_row=0, index=True, header=True):
770
+ """Save a Pandas DataFrame to an Excel file.
771
+
772
+ Args:
773
+ df: A Pandas DataFrame.
774
+ saved_path: The path to the Excel file to save to.
775
+ sheet_name: The name of the sheet to save the DataFrame to.
776
+ start_row: The row number to start writing the DataFrame at.
777
+ index: Whether to write the DataFrame index to the Excel file.
778
+ header: Whether to write the DataFrame header to the Excel file.
779
+ """
780
+ overwrite_options = {
781
+ "o": "replace",
782
+ "a": "overlay",
783
+ "n": "new",
784
+ }
785
+
786
+ if not saved_path.exists():
787
+ with pd.ExcelWriter(saved_path, engine="openpyxl") as writer:
788
+ df.to_excel(writer, sheet_name=sheet_name, index=index, header=header)
789
+
790
+ else:
791
+ with pd.ExcelFile(saved_path) as reader:
792
+ overwrite_option = "n"
793
+ if sheet_name in reader.sheet_names:
794
+ overwrite_option = input(
795
+ "\nSheet already exists. Overwrite/Append/New? [o/a/n]: "
796
+ ).lower()
797
+ start_row = 0
798
+ if overwrite_option == "a":
799
+ existing_df = pd.read_excel(saved_path, sheet_name=sheet_name)
800
+ start_row = existing_df.shape[0] + 1
801
+
802
+ with pd.ExcelWriter(
803
+ saved_path,
804
+ mode="a",
805
+ if_sheet_exists=overwrite_options[overwrite_option],
806
+ engine="openpyxl",
807
+ ) as writer:
808
+ df.to_excel(
809
+ writer,
810
+ sheet_name=sheet_name,
811
+ startrow=start_row,
812
+ index=index,
813
+ header=False if overwrite_option == "a" else header,
814
+ )
815
+
816
+
817
+ # This is a false positive on pylint and being tracked in pylint #3060
818
+ # pylint: disable=abstract-class-instantiated
819
+ def export_data(
820
+ export_type: str,
821
+ dir_path: str,
822
+ func_name: str,
823
+ df: pd.DataFrame = pd.DataFrame(),
824
+ sheet_name: Optional[str] = None,
825
+ figure: Optional["OpenBBFigure"] = None,
826
+ margin: bool = True,
827
+ ) -> None:
828
+ """Export data to a file.
829
+
830
+ Parameters
831
+ ----------
832
+ export_type : str
833
+ Type of export between: csv,json,xlsx,xls
834
+ dir_path : str
835
+ Path of directory from where this function is called
836
+ func_name : str
837
+ Name of the command that invokes this function
838
+ df : pd.Dataframe
839
+ Dataframe of data to save
840
+ sheet_name : str
841
+ If provided. The name of the sheet to save in excel file
842
+ figure : Optional[OpenBBFigure]
843
+ Figure object to save as image file
844
+ margin : bool
845
+ Automatically adjust subplot parameters to give specified padding.
846
+ """
847
+
848
+ if export_type:
849
+ saved_path = compose_export_path(func_name, dir_path).resolve()
850
+ saved_path.parent.mkdir(parents=True, exist_ok=True)
851
+ for exp_type in export_type.split(","):
852
+ # In this scenario the path was provided, e.g. --export pt.csv, pt.jpg
853
+ if "." in exp_type:
854
+ saved_path = saved_path.with_name(exp_type)
855
+ # In this scenario we use the default filename
856
+ else:
857
+ if ".OpenBB_openbb_cli" in saved_path.name:
858
+ saved_path = saved_path.with_name(
859
+ saved_path.name.replace(".OpenBB_openbb_cli", "OpenBBCLI")
860
+ )
861
+ saved_path = saved_path.with_suffix(f".{exp_type}")
862
+
863
+ exists, overwrite = False, False
864
+ is_xlsx = exp_type.endswith("xlsx")
865
+ if sheet_name is None and is_xlsx or not is_xlsx:
866
+ exists, overwrite = ask_file_overwrite(saved_path)
867
+
868
+ if exists and not overwrite:
869
+ existing = len(list(saved_path.parent.glob(saved_path.stem + "*")))
870
+ saved_path = saved_path.with_stem(f"{saved_path.stem}_{existing + 1}")
871
+
872
+ df = df.replace(
873
+ {
874
+ r"\[yellow\]": "",
875
+ r"\[/yellow\]": "",
876
+ r"\[green\]": "",
877
+ r"\[/green\]": "",
878
+ r"\[red\]": "",
879
+ r"\[/red\]": "",
880
+ r"\[magenta\]": "",
881
+ r"\[/magenta\]": "",
882
+ },
883
+ regex=True,
884
+ )
885
+
886
+ if exp_type.endswith("csv"):
887
+ df.to_csv(saved_path)
888
+ elif exp_type.endswith("json"):
889
+ df.reset_index(drop=True, inplace=True)
890
+ df.to_json(saved_path)
891
+ elif exp_type.endswith("xlsx"):
892
+ # since xlsx does not support datetimes with timezones we need to remove it
893
+ df = remove_timezone_from_dataframe(df)
894
+
895
+ if sheet_name is None: # noqa: SIM223
896
+ df.to_excel(
897
+ saved_path,
898
+ index=True,
899
+ header=True,
900
+ )
901
+ else:
902
+ save_to_excel(df, saved_path, sheet_name)
903
+
904
+ elif saved_path.suffix in [".jpg", ".pdf", ".png", ".svg"]:
905
+ if figure is None:
906
+ Session().console.print("No plot to export.")
907
+ continue
908
+ figure.show(export_image=saved_path, margin=margin)
909
+ else:
910
+ Session().console.print("Wrong export file specified.")
911
+ continue
912
+
913
+ Session().console.print(f"Saved file: {saved_path}")
914
+
915
+ if figure is not None:
916
+ figure._exported = True # pylint: disable=protected-access
917
+
918
+
919
+ def system_clear():
920
+ """Clear screen."""
921
+ os.system("cls||clear") # nosec # noqa: S605,S607
922
+
923
+
924
+ # Write an abstract helper to make requests from a url with potential headers and params
925
+ def request(
926
+ url: str, method: str = "get", timeout: int = 0, **kwargs
927
+ ) -> requests.Response:
928
+ """Make requests from a url with potential headers and params.
929
+
930
+ Parameters
931
+ ----------
932
+ url : str
933
+ Url to make the request to
934
+ method : str
935
+ HTTP method to use. Choose from:
936
+ delete, get, head, patch, post, put, by default "get"
937
+ timeout : int
938
+ How many seconds to wait for the server to send data
939
+
940
+ Returns
941
+ -------
942
+ requests.Response
943
+ Request response object
944
+
945
+ Raises
946
+ ------
947
+ ValueError
948
+ If invalid method is passed
949
+ """
950
+ method = method.lower()
951
+ if method not in ["delete", "get", "head", "patch", "post", "put"]:
952
+ raise ValueError(f"Invalid method: {method}")
953
+ # We want to add a user agent to the request, so check if there are any headers
954
+ # If there are headers, check if there is a user agent, if not add one.
955
+ # Some requests seem to work only with a specific user agent, so we want to be able to override it.
956
+ headers = kwargs.pop("headers", {})
957
+ timeout = timeout or Session().user.preferences.request_timeout
958
+
959
+ if "User-Agent" not in headers:
960
+ headers["User-Agent"] = get_user_agent()
961
+ func = getattr(requests, method)
962
+ return func(
963
+ url,
964
+ headers=headers,
965
+ timeout=timeout,
966
+ **kwargs,
967
+ )