CatPtain commited on
Commit
b93364a
·
verified ·
1 Parent(s): 013b837

Upload 70 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. cli/README.md +67 -0
  3. cli/integration/test_commands.py +29 -0
  4. cli/integration/test_integration_base_controller.py +89 -0
  5. cli/integration/test_integration_base_platform_controller.py +81 -0
  6. cli/integration/test_integration_cli_controller.py +26 -0
  7. cli/integration/test_integration_hub_service.py +62 -0
  8. cli/integration/test_integration_obbject_registry.py +54 -0
  9. cli/openbb_cli/__init__.py +1 -0
  10. cli/openbb_cli/argparse_translator/__init__.py +0 -0
  11. cli/openbb_cli/argparse_translator/argparse_argument.py +63 -0
  12. cli/openbb_cli/argparse_translator/argparse_class_processor.py +147 -0
  13. cli/openbb_cli/argparse_translator/argparse_translator.py +490 -0
  14. cli/openbb_cli/argparse_translator/obbject_registry.py +129 -0
  15. cli/openbb_cli/argparse_translator/reference_processor.py +142 -0
  16. cli/openbb_cli/argparse_translator/utils.py +76 -0
  17. cli/openbb_cli/assets/routines/routine_example.openbb +21 -0
  18. cli/openbb_cli/assets/styles/default/Consolas.ttf +3 -0
  19. cli/openbb_cli/assets/styles/default/dark.mpfstyle.json +47 -0
  20. cli/openbb_cli/assets/styles/default/dark.mplrc.json +7 -0
  21. cli/openbb_cli/assets/styles/default/dark.mplstyle +96 -0
  22. cli/openbb_cli/assets/styles/default/dark.pltstyle.json +132 -0
  23. cli/openbb_cli/assets/styles/default/dark.richstyle.json +9 -0
  24. cli/openbb_cli/assets/styles/default/light.mpfstyle.json +47 -0
  25. cli/openbb_cli/assets/styles/default/light.mplrc.json +7 -0
  26. cli/openbb_cli/assets/styles/default/light.mplstyle +95 -0
  27. cli/openbb_cli/assets/styles/default/light.pltstyle.json +871 -0
  28. cli/openbb_cli/assets/styles/default/light.richstyle.json +9 -0
  29. cli/openbb_cli/assets/styles/default/tables.pltstyle.json +102 -0
  30. cli/openbb_cli/assets/styles/user/openbb.richstyle.json +9 -0
  31. cli/openbb_cli/cli.py +31 -0
  32. cli/openbb_cli/config/__init__.py +1 -0
  33. cli/openbb_cli/config/completer.py +427 -0
  34. cli/openbb_cli/config/console.py +93 -0
  35. cli/openbb_cli/config/constants.py +80 -0
  36. cli/openbb_cli/config/menu_text.py +165 -0
  37. cli/openbb_cli/config/setup.py +11 -0
  38. cli/openbb_cli/config/style.py +108 -0
  39. cli/openbb_cli/controllers/base_controller.py +1032 -0
  40. cli/openbb_cli/controllers/base_platform_controller.py +392 -0
  41. cli/openbb_cli/controllers/choices.py +344 -0
  42. cli/openbb_cli/controllers/cli_controller.py +944 -0
  43. cli/openbb_cli/controllers/hub_service.py +107 -0
  44. cli/openbb_cli/controllers/platform_controller_factory.py +58 -0
  45. cli/openbb_cli/controllers/script_parser.py +488 -0
  46. cli/openbb_cli/controllers/settings_controller.py +142 -0
  47. cli/openbb_cli/controllers/utils.py +1032 -0
  48. cli/openbb_cli/models/settings.py +167 -0
  49. cli/openbb_cli/session.py +108 -0
  50. cli/openbb_cli/utils/utils.py +34 -0
.gitattributes CHANGED
@@ -35,3 +35,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  build/conda/installer/assets/dmg_volume.icns filter=lfs diff=lfs merge=lfs -text
37
  build/conda/installer/assets/Installer_vertical2.bmp filter=lfs diff=lfs merge=lfs -text
 
 
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  build/conda/installer/assets/dmg_volume.icns filter=lfs diff=lfs merge=lfs -text
37
  build/conda/installer/assets/Installer_vertical2.bmp filter=lfs diff=lfs merge=lfs -text
38
+ cli/openbb_cli/assets/styles/default/Consolas.ttf filter=lfs diff=lfs merge=lfs -text
cli/README.md ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenBB Platform CLI
2
+
3
+ [![Downloads](https://static.pepy.tech/badge/openbb)](https://pepy.tech/project/openbb)
4
+ [![LatestRelease](https://badge.fury.io/py/openbb.svg)](https://github.com/OpenBB-finance/OpenBB)
5
+
6
+ | OpenBB is committed to build the future of investment research by focusing on an open source infrastructure accessible to everyone, everywhere. |
7
+ | :---------------------------------------------------------------------------------------------------------------------------------------------: |
8
+ | ![OpenBBLogo](https://user-images.githubusercontent.com/25267873/218899768-1f0964b8-326c-4f35-af6f-ea0946ac970b.png) |
9
+ | Check our website at [openbb.co](www.openbb.co) |
10
+
11
+ ## Overview
12
+
13
+ The OpenBB Platform CLI is a command line interface that wraps [OpenBB Platform](https://docs.openbb.co/platform).
14
+
15
+ It offers a convenient way to interact with the OpenBB Platform and its extensions, as well as automated data collection via OpenBB Routine Scripts.
16
+
17
+ Find the most complete documentation, examples, and usage guides for the OpenBB Platform CLI [here](https://docs.openbb.co/cli).
18
+
19
+ ## Installation
20
+
21
+ The command below provides access to all the available OpenBB extensions behind the OpenBB Platform, find the complete list [here](https://my.openbb.co/app/platform/extensions).
22
+
23
+ ```bash
24
+ pip install openbb-cli
25
+ ```
26
+
27
+ > Note: Find the most complete installation hints and tips [here](https://docs.openbb.co/cli/installation).
28
+
29
+ After the installation is complete, you can deploy the OpenBB Platform CLI by running the following command:
30
+
31
+ ```bash
32
+ openbb
33
+ ```
34
+
35
+ Which should result in the following output:
36
+
37
+ ![image](https://github.com/OpenBB-finance/OpenBB/assets/48914296/f606bb6e-fa00-4fc8-bad2-8269bb4fc38e)
38
+
39
+ ## API keys
40
+
41
+ To fully leverage the OpenBB Platform you need to get some API keys to connect with data providers. Here are the 3 options on where to set them:
42
+
43
+ 1. OpenBB Hub
44
+ 2. Local file
45
+
46
+ ### 1. OpenBB Hub
47
+
48
+ Set your keys at [OpenBB Hub](https://my.openbb.co/app/platform/credentials) and get your personal access token from <https://my.openbb.co/app/platform/pat> to connect with your account.
49
+
50
+ > Once you log in, on the Platform CLI (through the `/account` menu, all your credentials will be in sync with the OpenBB Hub.)
51
+
52
+ ### 2. Local file
53
+
54
+ You can specify the keys directly in the `~/.openbb_platform/user_settings.json` file.
55
+
56
+ Populate this file with the following template and replace the values with your keys:
57
+
58
+ ```json
59
+ {
60
+ "credentials": {
61
+ "fmp_api_key": "REPLACE_ME",
62
+ "polygon_api_key": "REPLACE_ME",
63
+ "benzinga_api_key": "REPLACE_ME",
64
+ "fred_api_key": "REPLACE_ME"
65
+ }
66
+ }
67
+ ```
cli/integration/test_commands.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+
3
+ import pytest
4
+ from openbb_cli.cli import main
5
+
6
+
7
+ @pytest.mark.parametrize(
8
+ "input_values",
9
+ [
10
+ "/equity/price/historical --symbol aapl --provider fmp",
11
+ "/equity/price/historical --symbol msft --provider yfinance",
12
+ "/equity/price/historical --symbol goog --provider polygon",
13
+ "/crypto/price/historical --symbol btc --provider fmp",
14
+ "/currency/price/historical --symbol eur --provider fmp",
15
+ "/derivatives/futures/historical --symbol cl --provider fmp",
16
+ "/etf/price/historical --symbol spy --provider fmp",
17
+ "/economy",
18
+ ],
19
+ )
20
+ @pytest.mark.integration
21
+ def test_launch_with_cli_input(monkeypatch, input_values):
22
+ """Test launching the CLI and providing input via stdin with multiple parameters."""
23
+ stdin = io.StringIO(input_values)
24
+ monkeypatch.setattr("sys.stdin", stdin)
25
+
26
+ try:
27
+ main()
28
+ except Exception as e:
29
+ pytest.fail(f"Main function raised an exception: {e}")
cli/integration/test_integration_base_controller.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integration tests for the base_controller module."""
2
+
3
+ from unittest.mock import Mock, patch
4
+
5
+ import pytest
6
+ from openbb_cli.controllers.base_controller import BaseController
7
+ from openbb_cli.session import Session
8
+
9
+ # pylint: disable=unused-variable, redefined-outer-name
10
+
11
+
12
+ class TestController(BaseController):
13
+ """Test controller for the BaseController."""
14
+
15
+ PATH = "/test/"
16
+
17
+ def print_help(self):
18
+ """Print help message."""
19
+
20
+
21
+ @pytest.fixture
22
+ def base_controller():
23
+ """Set up the environment for each test function."""
24
+ session = Session() # noqa: F841
25
+ controller = TestController()
26
+ return controller
27
+
28
+
29
+ @pytest.mark.integration
30
+ def test_check_path_valid(base_controller):
31
+ """Test that check_path does not raise an error for a valid path."""
32
+ base_controller.PATH = "/equity/"
33
+ try:
34
+ base_controller.check_path()
35
+ except ValueError:
36
+ pytest.fail("check_path raised ValueError unexpectedly!")
37
+
38
+
39
+ @pytest.mark.integration
40
+ def test_check_path_invalid(base_controller):
41
+ """Test that check_path raises an error for an invalid path."""
42
+ with pytest.raises(ValueError):
43
+ base_controller.PATH = "invalid_path" # Missing leading '/'
44
+ base_controller.check_path()
45
+
46
+ with pytest.raises(ValueError):
47
+ base_controller.PATH = "/invalid_path" # Missing trailing '/'
48
+ base_controller.check_path()
49
+
50
+
51
+ @pytest.mark.integration
52
+ def test_parse_input(base_controller):
53
+ """Test the parse_input method."""
54
+ input_str = "/equity/price/help"
55
+ expected_output = ["", "equity", "price", "help"]
56
+ assert (
57
+ base_controller.parse_input(input_str) == expected_output
58
+ ), "Input parsing failed"
59
+
60
+
61
+ @pytest.mark.integration
62
+ def test_switch_command_execution(base_controller):
63
+ """Test the switch method."""
64
+ base_controller.queue = []
65
+ base_controller.switch("/home/../reset/")
66
+ assert base_controller.queue == [
67
+ "home",
68
+ "..",
69
+ "reset",
70
+ ], "Switch did not update the queue correctly"
71
+
72
+
73
+ @patch("openbb_cli.controllers.base_controller.BaseController.call_help")
74
+ @pytest.mark.integration
75
+ def test_command_routing(mock_call_help, base_controller):
76
+ """Test the command routing."""
77
+ base_controller.switch("help")
78
+ mock_call_help.assert_called_once()
79
+
80
+
81
+ @pytest.mark.integration
82
+ def test_custom_reset(base_controller):
83
+ """Test the custom reset method."""
84
+ base_controller.custom_reset = Mock(return_value=["custom", "reset"])
85
+ base_controller.call_reset(None)
86
+ expected_queue = ["quit", "reset", "custom", "reset"]
87
+ assert (
88
+ base_controller.queue == expected_queue
89
+ ), f"Expected queue to be {expected_queue}, but was {base_controller.queue}"
cli/integration/test_integration_base_platform_controller.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test the base platform controller."""
2
+
3
+ from unittest.mock import MagicMock, Mock, patch
4
+
5
+ import pytest
6
+ from openbb_cli.controllers.base_platform_controller import (
7
+ PlatformController,
8
+ Session,
9
+ )
10
+
11
+ # pylint: disable=protected-access, unused-variable, redefined-outer-name
12
+
13
+
14
+ @pytest.fixture
15
+ def platform_controller():
16
+ """Return a platform controller."""
17
+ session = Session() # noqa: F841
18
+ translators = {"test_command": MagicMock(), "test_menu": MagicMock()} # noqa: F841
19
+ translators["test_command"]._parser = Mock(
20
+ _actions=[Mock(dest="data", choices=[], type=str, nargs=None)]
21
+ )
22
+ translators["test_command"].execute_func = Mock(return_value=Mock())
23
+ translators["test_menu"]._parser = Mock(
24
+ _actions=[Mock(dest="data", choices=[], type=str, nargs=None)]
25
+ )
26
+ translators["test_menu"].execute_func = Mock(return_value=Mock())
27
+
28
+ controller = PlatformController(
29
+ name="test", parent_path=["platform"], translators=translators
30
+ )
31
+ return controller
32
+
33
+
34
+ @pytest.mark.integration
35
+ def test_platform_controller_initialization(platform_controller):
36
+ """Test the initialization of the platform controller."""
37
+ expected_path = "/platform/test/"
38
+ assert (
39
+ expected_path == platform_controller.PATH
40
+ ), "Controller path was not set correctly"
41
+
42
+
43
+ @pytest.mark.integration
44
+ def test_command_generation(platform_controller):
45
+ """Test the generation of commands."""
46
+ command_name = "test_command"
47
+ mock_execute_func = Mock(return_value=(Mock(), None))
48
+ platform_controller.translators[command_name].execute_func = mock_execute_func
49
+
50
+ platform_controller._generate_command_call(
51
+ name=command_name, translator=platform_controller.translators[command_name]
52
+ )
53
+ command_method_name = f"call_{command_name}"
54
+ assert hasattr(
55
+ platform_controller, command_method_name
56
+ ), "Command method was not created"
57
+
58
+
59
+ @patch(
60
+ "openbb_cli.controllers.base_platform_controller.PlatformController._link_obbject_to_data_processing_commands"
61
+ )
62
+ @patch(
63
+ "openbb_cli.controllers.base_platform_controller.PlatformController._generate_commands"
64
+ )
65
+ @patch(
66
+ "openbb_cli.controllers.base_platform_controller.PlatformController._generate_sub_controllers"
67
+ )
68
+ @pytest.mark.integration
69
+ def test_platform_controller_calls(
70
+ mock_sub_controllers, mock_commands, mock_link_commands
71
+ ):
72
+ """Test the calls of the platform controller."""
73
+ translators = {"test_command": Mock()}
74
+ translators["test_command"].parser = Mock()
75
+ translators["test_command"].execute_func = Mock()
76
+ _ = PlatformController(
77
+ name="test", parent_path=["platform"], translators=translators
78
+ )
79
+ mock_sub_controllers.assert_called_once()
80
+ mock_commands.assert_called_once()
81
+ mock_link_commands.assert_called_once()
cli/integration/test_integration_cli_controller.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test the CLI controller integration."""
2
+
3
+ from openbb_cli.controllers.cli_controller import (
4
+ CLIController,
5
+ )
6
+
7
+
8
+ def test_parse_input_valid_commands():
9
+ """Test parse_input method."""
10
+ controller = CLIController()
11
+ input_string = "exe --file test.openbb"
12
+ expected_output = [
13
+ "exe --file test.openbb"
14
+ ] # Adjust based on actual expected behavior
15
+ assert controller.parse_input(input_string) == expected_output
16
+
17
+
18
+ def test_parse_input_invalid_commands():
19
+ """Test parse_input method."""
20
+ controller = CLIController()
21
+ input_string = "nonexistentcommand args"
22
+ expected_output = ["nonexistentcommand args"]
23
+ actual_output = controller.parse_input(input_string)
24
+ assert (
25
+ actual_output == expected_output
26
+ ), f"Expected {expected_output}, got {actual_output}"
cli/integration/test_integration_hub_service.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integration tests for the hub_service module."""
2
+
3
+ from unittest.mock import create_autospec, patch
4
+
5
+ import pytest
6
+ import requests
7
+ from openbb_cli.controllers.hub_service import upload_routine
8
+ from openbb_core.app.model.hub.hub_session import HubSession
9
+
10
+ # pylint: disable=unused-argument, redefined-outer-name, unused-variable
11
+
12
+
13
+ @pytest.fixture
14
+ def auth_header():
15
+ """Return a fake auth header."""
16
+ return "Bearer fake_token"
17
+
18
+
19
+ @pytest.fixture
20
+ def hub_session_mock():
21
+ """Return a mock HubSession."""
22
+ mock = create_autospec(HubSession, instance=True)
23
+ mock.username = "TestUser"
24
+ return mock
25
+
26
+
27
+ # Fixture for routine data
28
+ @pytest.fixture
29
+ def routine_data():
30
+ """Return a dictionary with routine data."""
31
+ return {
32
+ "name": "Test Routine",
33
+ "description": "A test routine",
34
+ "routine": "print('Hello World')",
35
+ "override": False,
36
+ "tags": "test",
37
+ "public": True,
38
+ }
39
+
40
+
41
+ @pytest.mark.integration
42
+ def test_upload_routine_timeout(auth_header, routine_data):
43
+ """Test upload_routine with a timeout exception."""
44
+ with patch(
45
+ "requests.post", side_effect=requests.exceptions.Timeout
46
+ ) as mocked_post: # noqa: F841
47
+
48
+ response = upload_routine(auth_header, **routine_data)
49
+
50
+ assert response is None
51
+
52
+
53
+ @pytest.mark.integration
54
+ def test_upload_routine_connection_error(auth_header, routine_data):
55
+ """Test upload_routine with a connection error."""
56
+ with patch(
57
+ "requests.post", side_effect=requests.exceptions.ConnectionError
58
+ ) as mocked_post: # noqa: F841
59
+
60
+ response = upload_routine(auth_header, **routine_data)
61
+
62
+ assert response is None
cli/integration/test_integration_obbject_registry.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test the obbject registry."""
2
+
3
+ from openbb_cli.argparse_translator.obbject_registry import Registry
4
+ from openbb_core.app.model.obbject import OBBject
5
+
6
+ # pylint: disable=unused-variable
7
+ # ruff: noqa: disable=F841
8
+
9
+
10
+ def test_registry_operations():
11
+ """Test the registry operations."""
12
+ registry = Registry()
13
+ obbject1 = OBBject(
14
+ id="1", results=True, extra={"register_key": "key1", "command": "cmd1"}
15
+ )
16
+ obbject2 = OBBject(
17
+ id="2", results=True, extra={"register_key": "key2", "command": "cmd2"}
18
+ )
19
+ obbject3 = OBBject( # noqa: F841
20
+ id="3", results=True, extra={"register_key": "key3", "command": "cmd3"}
21
+ )
22
+
23
+ # Add obbjects to the registry
24
+ assert registry.register(obbject1) is True
25
+ assert registry.register(obbject2) is True
26
+ # Attempt to add the same object again
27
+ assert registry.register(obbject1) is False
28
+ # Ensure the registry size is correct
29
+ assert len(registry.obbjects) == 2
30
+
31
+ # Get by index
32
+ assert registry.get(0) == obbject2
33
+ assert registry.get(1) == obbject1
34
+ # Get by key
35
+ assert registry.get("key1") == obbject1
36
+ assert registry.get("key2") == obbject2
37
+ # Invalid index/key
38
+ assert registry.get(2) is None
39
+ assert registry.get("invalid_key") is None
40
+
41
+ # Remove an object
42
+ registry.remove(0)
43
+ assert len(registry.obbjects) == 1
44
+ assert registry.get("key2") is None
45
+
46
+ # Validate the 'all' property
47
+ all_obbjects = registry.all
48
+ assert "command" in all_obbjects[0]
49
+ assert all_obbjects[0]["command"] == "cmd1"
50
+
51
+ # Clean up by removing all objects
52
+ registry.remove()
53
+ assert len(registry.obbjects) == 0
54
+ assert registry.get("key1") is None
cli/openbb_cli/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Package init"""
cli/openbb_cli/argparse_translator/__init__.py ADDED
File without changes
cli/openbb_cli/argparse_translator/argparse_argument.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic models for argparse arguments and argument groups."""
2
+
3
+ from typing import (
4
+ Any,
5
+ List,
6
+ Literal,
7
+ Optional,
8
+ Tuple,
9
+ )
10
+
11
+ from pydantic import BaseModel, model_validator
12
+
13
+ SEP = "__"
14
+
15
+
16
+ class ArgparseArgumentModel(BaseModel):
17
+ """Pydantic model for an argparse argument."""
18
+
19
+ name: str
20
+ type: Any
21
+ dest: str
22
+ default: Any
23
+ required: bool
24
+ action: Literal["store_true", "store"]
25
+ help: Optional[str]
26
+ nargs: Optional[Literal["+"]]
27
+ choices: Optional[Tuple]
28
+
29
+ @model_validator(mode="after") # type: ignore
30
+ @classmethod
31
+ def validate_action(cls, values: "ArgparseArgumentModel"):
32
+ """Validate the action based on the type."""
33
+ if values.type is bool and values.action != "store_true":
34
+ raise ValueError('If type is bool, action must be "store_true"')
35
+ return values
36
+
37
+ @model_validator(mode="after") # type: ignore
38
+ @classmethod
39
+ def remove_props_on_store_true(cls, values: "ArgparseArgumentModel"):
40
+ """Remove type, nargs, and choices if action is store_true."""
41
+ if values.action == "store_true":
42
+ values.type = None
43
+ values.nargs = None
44
+ values.choices = None
45
+ return values
46
+
47
+ # override
48
+ def model_dump(self, **kwargs):
49
+ """Override the model_dump method to remove empty choices."""
50
+ res = super().model_dump(**kwargs)
51
+
52
+ # Check if choices is present and if it's an empty tuple remove it
53
+ if "choices" in res and not res["choices"]:
54
+ del res["choices"]
55
+
56
+ return res
57
+
58
+
59
+ class ArgparseArgumentGroupModel(BaseModel):
60
+ """Pydantic model for a custom argument group."""
61
+
62
+ name: str
63
+ arguments: List[ArgparseArgumentModel]
cli/openbb_cli/argparse_translator/argparse_class_processor.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Module for the ArgparseClassProcessor class."""
2
+
3
+ import inspect
4
+ from typing import Any, Dict, Optional, Type
5
+
6
+ # TODO: this needs to be done differently
7
+ from openbb_core.app.static.container import Container
8
+
9
+ from openbb_cli.argparse_translator.argparse_translator import ArgparseTranslator
10
+ from openbb_cli.argparse_translator.reference_processor import (
11
+ ReferenceToArgumentsProcessor,
12
+ )
13
+
14
+
15
+ class ArgparseClassProcessor:
16
+ """Process a target class to create ArgparseTranslators for its methods."""
17
+
18
+ # reference variable used to create custom groups for the ArgpaseTranslators
19
+ _reference: Dict[str, Any] = {}
20
+
21
+ def __init__(
22
+ self,
23
+ target_class: Type,
24
+ add_help: bool = False,
25
+ reference: Optional[Dict[str, Any]] = None,
26
+ ):
27
+ """
28
+ Initialize the ArgparseClassProcessor.
29
+
30
+ Parameters
31
+ ----------
32
+ target_class : Type
33
+ The target class whose methods will be processed.
34
+ add_help : Optional[bool]
35
+ Whether to add help to the ArgparseTranslators.
36
+ """
37
+ self._target_class: Type = target_class
38
+ self._add_help: bool = add_help
39
+ self._translators: Dict[str, ArgparseTranslator] = {}
40
+ self._paths: Dict[str, str] = {}
41
+
42
+ ArgparseClassProcessor._reference = reference or {}
43
+
44
+ self._translators = self._process_class(
45
+ target=self._target_class, add_help=self._add_help
46
+ )
47
+ self._paths[self._get_class_name(self._target_class)] = "path"
48
+ self._build_paths(target=self._target_class)
49
+
50
+ @property
51
+ def translators(self) -> Dict[str, ArgparseTranslator]:
52
+ """
53
+ Get the ArgparseTranslators associated with the target class.
54
+
55
+ Returns
56
+ -------
57
+ Dict[str, ArgparseTranslator]
58
+ The ArgparseTranslators associated with the target class.
59
+ """
60
+ return self._translators
61
+
62
+ @property
63
+ def paths(self) -> Dict[str, str]:
64
+ """
65
+ Get the paths associated with the target class.
66
+
67
+ Returns
68
+ -------
69
+ Dict[str, str]
70
+ The paths associated with the target class.
71
+ """
72
+ return self._paths
73
+
74
+ @classmethod
75
+ def _custom_groups_from_reference(cls, class_name: str, function_name: str) -> Dict:
76
+ route = f"/{class_name.replace('_', '/')}/{function_name}"
77
+ reference = {route: cls._reference[route]} if route in cls._reference else {}
78
+ if not reference:
79
+ return {}
80
+ rp = ReferenceToArgumentsProcessor(reference)
81
+ return rp.custom_groups.get(route, {}) # type: ignore
82
+
83
+ @classmethod
84
+ def _process_class(
85
+ cls,
86
+ target: type,
87
+ add_help: bool = False,
88
+ ) -> Dict[str, ArgparseTranslator]:
89
+ methods = {}
90
+
91
+ for name, member in inspect.getmembers(target):
92
+ if name.startswith("__") or name.startswith("_"):
93
+ continue
94
+ if inspect.ismethod(member):
95
+ class_name = cls._get_class_name(target)
96
+ methods[f"{class_name}_{name}"] = ArgparseTranslator(
97
+ func=member,
98
+ add_help=add_help,
99
+ custom_argument_groups=cls._custom_groups_from_reference( # type: ignore
100
+ class_name=class_name, function_name=name
101
+ ),
102
+ )
103
+ elif isinstance(member, Container):
104
+ methods = {
105
+ **methods,
106
+ **cls._process_class(
107
+ target=getattr(target, name), add_help=add_help
108
+ ),
109
+ }
110
+
111
+ return methods
112
+
113
+ @staticmethod
114
+ def _get_class_name(target: type) -> str:
115
+ return (
116
+ str(type(target))
117
+ .rsplit(".", maxsplit=1)[-1]
118
+ .replace("'>", "")
119
+ .replace("ROUTER_", "")
120
+ .lower()
121
+ )
122
+
123
+ def get_translator(self, command: str) -> ArgparseTranslator:
124
+ """
125
+ Retrieve the ArgparseTranslator object associated with a specific menu and command.
126
+
127
+ Parameters
128
+ ----------
129
+ command : str
130
+ The command associated with the ArgparseTranslator.
131
+
132
+ Returns
133
+ -------
134
+ ArgparseTranslator
135
+ The ArgparseTranslator associated with the specified menu and command.
136
+ """
137
+ return self._translators[command]
138
+
139
+ def _build_paths(self, target: type, depth: int = 1):
140
+ for name, member in inspect.getmembers(target):
141
+ if name.startswith("__") or name.startswith("_"):
142
+ continue
143
+ if inspect.ismethod(member):
144
+ pass
145
+ elif isinstance(member, Container):
146
+ self._build_paths(target=getattr(target, name), depth=depth + 1)
147
+ self._paths[f"{name}"] = "sub" * depth + "path"
cli/openbb_cli/argparse_translator/argparse_translator.py ADDED
@@ -0,0 +1,490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Module for translating a function into an argparse program."""
2
+
3
+ import argparse
4
+ import inspect
5
+ import re
6
+ from copy import deepcopy
7
+ from typing import (
8
+ Any,
9
+ Callable,
10
+ Dict,
11
+ List,
12
+ Literal,
13
+ Optional,
14
+ Tuple,
15
+ Type,
16
+ Union,
17
+ get_args,
18
+ get_origin,
19
+ get_type_hints,
20
+ )
21
+
22
+ from openbb_core.app.model.field import OpenBBField
23
+ from pydantic import BaseModel
24
+ from typing_extensions import Annotated
25
+
26
+ from openbb_cli.argparse_translator.argparse_argument import (
27
+ ArgparseArgumentGroupModel,
28
+ ArgparseArgumentModel,
29
+ )
30
+ from openbb_cli.argparse_translator.utils import (
31
+ get_argument_choices,
32
+ get_argument_optional_choices,
33
+ in_group,
34
+ remove_argument,
35
+ set_optional_choices,
36
+ )
37
+
38
+ # pylint: disable=protected-access
39
+
40
+ SEP = "__"
41
+
42
+
43
+ class ArgparseTranslator:
44
+ """Class to translate a function into an argparse program."""
45
+
46
+ def __init__(
47
+ self,
48
+ func: Callable,
49
+ custom_argument_groups: Optional[List[ArgparseArgumentGroupModel]] = None,
50
+ add_help: Optional[bool] = True,
51
+ ):
52
+ """
53
+ Initialize the ArgparseTranslator.
54
+
55
+ Args:
56
+ func (Callable): The function to translate into an argparse program.
57
+ add_help (Optional[bool], optional): Whether to add the help argument. Defaults to False.
58
+ """
59
+ self.func = func
60
+ self.signature = inspect.signature(func)
61
+ self.type_hints = get_type_hints(func)
62
+ self.provider_parameters: Dict[str, List[str]] = {}
63
+
64
+ self._parser = argparse.ArgumentParser(
65
+ prog=func.__name__,
66
+ description=self._build_description(func.__doc__), # type: ignore
67
+ formatter_class=argparse.RawTextHelpFormatter,
68
+ add_help=add_help if add_help else False,
69
+ )
70
+ self._required = self._parser.add_argument_group("required arguments")
71
+
72
+ if any(param in self.type_hints for param in self.signature.parameters):
73
+ self._generate_argparse_arguments(self.signature.parameters)
74
+
75
+ if custom_argument_groups:
76
+ for group in custom_argument_groups:
77
+ self.provider_parameters[group.name] = []
78
+ argparse_group = self._parser.add_argument_group(group.name)
79
+ for argument in group.arguments:
80
+ self._handle_argument_in_groups(argument, argparse_group)
81
+
82
+ def _handle_argument_in_groups(self, argument, group):
83
+ """Handle the argument and add it to the parser."""
84
+
85
+ def _update_providers(
86
+ input_string: str, new_provider: List[Optional[str]]
87
+ ) -> str:
88
+ pattern = r"\(provider:\s*(.*?)\)"
89
+ providers = re.findall(pattern, input_string)
90
+ providers.extend(new_provider)
91
+ # remove pattern from help and add with new providers
92
+ input_string = re.sub(pattern, "", input_string).strip()
93
+ return f"{input_string} (provider: {', '.join(providers)})"
94
+
95
+ # check if the argument is already in use, if not, add it
96
+ if f"--{argument.name}" not in self._parser._option_string_actions:
97
+ kwargs = argument.model_dump(exclude={"name"}, exclude_none=True)
98
+ group.add_argument(f"--{argument.name}", **kwargs)
99
+ if group.title in self.provider_parameters:
100
+ self.provider_parameters[group.title].append(argument.name)
101
+
102
+ else:
103
+ kwargs = argument.model_dump(exclude={"name"}, exclude_none=True)
104
+ model_choices = kwargs.get("choices", ()) or ()
105
+ # extend choices
106
+ existing_choices = get_argument_choices(self._parser, argument.name)
107
+ choices = tuple(set(existing_choices + model_choices))
108
+ optional_choices = bool(existing_choices and not model_choices)
109
+
110
+ # check if the argument is in the required arguments
111
+ if in_group(self._parser, argument.name, group_title="required arguments"):
112
+ for action in self._required._group_actions:
113
+ if action.dest == argument.name and choices:
114
+ # update choices
115
+ action.choices = choices
116
+ set_optional_choices(action, optional_choices)
117
+ return
118
+
119
+ # check if the argument is in the optional arguments
120
+ if in_group(self._parser, argument.name, group_title="optional arguments"):
121
+ for action in self._parser._actions:
122
+ if action.dest == argument.name:
123
+ # update choices
124
+ if choices:
125
+ action.choices = choices
126
+ set_optional_choices(action, optional_choices)
127
+ if argument.name not in self.signature.parameters:
128
+ # update help
129
+ action.help = _update_providers(
130
+ action.help or "", [group.title]
131
+ )
132
+ return
133
+
134
+ # we need to check if the optional choices were set in other group
135
+ # before we remove the argument from the group, otherwise we will lose info
136
+ if not optional_choices:
137
+ optional_choices = get_argument_optional_choices(
138
+ self._parser, argument.name
139
+ )
140
+
141
+ # if the argument is in use, remove it from all groups
142
+ # and return the groups that had the argument
143
+ groups_w_arg = remove_argument(self._parser, argument.name)
144
+ groups_w_arg.append(group.title) # add current group
145
+
146
+ # add it to the optional arguments group instead
147
+ if choices:
148
+ kwargs["choices"] = choices # update choices
149
+ # add provider info to the help
150
+ kwargs["help"] = _update_providers(argument.help or "", groups_w_arg)
151
+ action = self._parser.add_argument(f"--{argument.name}", **kwargs)
152
+ set_optional_choices(action, optional_choices)
153
+
154
+ @property
155
+ def parser(self) -> argparse.ArgumentParser:
156
+ """Get the argparse parser."""
157
+ return deepcopy(self._parser)
158
+
159
+ @staticmethod
160
+ def _build_description(func_doc: str) -> str:
161
+ """Build the description of the argparse program from the function docstring."""
162
+ patterns = ["openbb\n ======", "Parameters\n ----------"]
163
+
164
+ if func_doc:
165
+ for pattern in patterns:
166
+ if pattern in func_doc:
167
+ func_doc = func_doc[: func_doc.index(pattern)].strip()
168
+ break
169
+
170
+ return func_doc
171
+
172
+ @staticmethod
173
+ def _param_is_default(param: inspect.Parameter) -> bool:
174
+ """Return True if the parameter has a default value."""
175
+ return param.default != inspect.Parameter.empty
176
+
177
+ def _get_action_type(self, param: inspect.Parameter) -> str:
178
+ """Return the argparse action type for the given parameter."""
179
+ param_type = self.type_hints[param.name]
180
+ type_origin = get_origin(param_type)
181
+ if param_type is bool or (
182
+ type_origin is Union and bool in get_args(param_type)
183
+ ):
184
+ return "store_true"
185
+ return "store"
186
+
187
+ def _get_type_and_choices( # noqa: PLR0912 # pylint: disable=too-many-return-statements, too-many-branches, too-many-statements
188
+ self, param: inspect.Parameter
189
+ ) -> Tuple[Type[Any], Tuple[Any, ...]]:
190
+ """Return the type and choices for the given parameter."""
191
+ param_type = self.type_hints[param.name]
192
+ type_origin = get_origin(param_type)
193
+
194
+ choices: tuple[Any, ...] = ()
195
+
196
+ if type_origin is Literal:
197
+ choices = get_args(param_type)
198
+ # Special handling for boolean literals
199
+ if all(isinstance(choice, bool) for choice in choices):
200
+ return bool, ()
201
+ param_type = type(choices[0]) # type: ignore
202
+
203
+ if type_origin is list:
204
+ param_type = get_args(param_type)[0]
205
+
206
+ if get_origin(param_type) is Literal:
207
+ choices = get_args(param_type)
208
+ # Special handling for boolean literals in lists
209
+ if all(isinstance(choice, bool) for choice in choices):
210
+ return bool, ()
211
+ param_type = type(choices[0]) # type: ignore
212
+
213
+ if type_origin is Union:
214
+ union_args = get_args(param_type)
215
+ # Check if Union contains bool type
216
+ if bool in union_args and (str in union_args or len(union_args) == 2):
217
+ return bool, ()
218
+
219
+ # Check if Union contains Literal types and extract all choices
220
+ literal_choices: list = []
221
+ for arg in union_args:
222
+ if get_origin(arg) is Literal:
223
+ literal_choices.extend(get_args(arg))
224
+
225
+ if literal_choices:
226
+ # Check if all choices are boolean
227
+ if all(isinstance(choice, bool) for choice in literal_choices):
228
+ return bool, ()
229
+ # We have Literal types in the Union, use their choices
230
+ choices = tuple(literal_choices)
231
+ param_type = type(choices[0]) # type: ignore
232
+ elif str in union_args:
233
+ param_type = str
234
+
235
+ # check if it's an Optional, which would be a Union with NoneType
236
+ if type(None) in get_args(param_type):
237
+ # remove NoneType from the args
238
+ args = [arg for arg in get_args(param_type) if arg is not None]
239
+ # if there is only one arg left, use it
240
+ if len(args) == 1:
241
+ param_type = args[0]
242
+
243
+ if get_origin(param_type) is Literal:
244
+ choices = get_args(param_type)
245
+ # Special handling for boolean literals
246
+ if all(isinstance(choice, bool) for choice in choices):
247
+ return bool, ()
248
+ param_type = type(choices[0]) # type: ignore
249
+ elif len(args) > 1:
250
+ # Handle Union with multiple types (not just Optional)
251
+ # Try to extract Literal types again from the filtered args
252
+ literal_choices = []
253
+ for arg in args:
254
+ if get_origin(arg) is Literal:
255
+ literal_choices.extend(get_args(arg))
256
+
257
+ if literal_choices:
258
+ # Check if all choices are boolean
259
+ if all(isinstance(choice, bool) for choice in literal_choices):
260
+ return bool, ()
261
+ choices = tuple(set(literal_choices))
262
+ param_type = type(choices[0]) # type: ignore
263
+
264
+ # if there are custom choices, override
265
+ custom_choices = self._get_argument_custom_choices(param)
266
+ if custom_choices and param_type is not bool:
267
+ choices = tuple(custom_choices)
268
+
269
+ return param_type, choices
270
+
271
+ @staticmethod
272
+ def _split_annotation(
273
+ base_annotation: Type[Any], custom_annotation_type: Type
274
+ ) -> Tuple[Type[Any], List[Any]]:
275
+ """Find the base annotation and the custom annotations, namely the OpenBBField."""
276
+ if get_origin(base_annotation) is not Annotated:
277
+ return base_annotation, []
278
+ base_annotation, *maybe_custom_annotations = get_args(base_annotation)
279
+ return base_annotation, [
280
+ annotation
281
+ for annotation in maybe_custom_annotations
282
+ if isinstance(annotation, custom_annotation_type)
283
+ ]
284
+
285
+ @classmethod
286
+ def _get_argument_custom_help(cls, param: inspect.Parameter) -> Optional[str]:
287
+ """Return the help annotation for the given parameter."""
288
+ base_annotation = param.annotation
289
+ _, custom_annotations = cls._split_annotation(base_annotation, OpenBBField)
290
+ help_annotation = (
291
+ custom_annotations[0].description if custom_annotations else None
292
+ )
293
+ return help_annotation
294
+
295
+ @classmethod
296
+ def _get_argument_custom_choices(cls, param: inspect.Parameter) -> Optional[str]:
297
+ """Return the help annotation for the given parameter."""
298
+ base_annotation = param.annotation
299
+ _, custom_annotations = cls._split_annotation(base_annotation, OpenBBField)
300
+ choices_annotation = (
301
+ custom_annotations[0].choices if custom_annotations else None
302
+ )
303
+ return choices_annotation
304
+
305
+ def _get_nargs(self, param: inspect.Parameter) -> Optional[str]:
306
+ """Return the nargs annotation for the given parameter."""
307
+ param_type = self.type_hints[param.name]
308
+ origin = get_origin(param_type)
309
+
310
+ if origin is list:
311
+ return "+"
312
+
313
+ if origin is Union and any(
314
+ get_origin(arg) is list for arg in get_args(param_type)
315
+ ):
316
+ return "+"
317
+
318
+ return None
319
+
320
+ def _generate_argparse_arguments(self, parameters) -> None:
321
+ """Generate the argparse arguments from the function parameters."""
322
+ for param in parameters.values():
323
+ if param.name == "kwargs":
324
+ continue
325
+
326
+ param_type, choices = self._get_type_and_choices(param)
327
+
328
+ # if the param is a custom type, we need to flatten it
329
+ if inspect.isclass(param_type) and issubclass(param_type, BaseModel):
330
+ # update type hints with the custom type fields
331
+ type_hints = get_type_hints(param_type)
332
+ # prefix the type hints keys with the param name
333
+ type_hints = {
334
+ f"{param.name}{SEP}{key}": value
335
+ for key, value in type_hints.items()
336
+ }
337
+ self.type_hints.update(type_hints)
338
+ # create a signature from the custom type
339
+ sig = inspect.signature(param_type)
340
+
341
+ # add help to the annotation
342
+ annotated_parameters: List[inspect.Parameter] = []
343
+ for child_param in sig.parameters.values():
344
+ new_child_param = child_param.replace(
345
+ name=f"{param.name}{SEP}{child_param.name}",
346
+ annotation=Annotated[
347
+ child_param.annotation,
348
+ OpenBBField(
349
+ description=param_type.model_json_schema()[
350
+ "properties"
351
+ ][child_param.name].get("description", None)
352
+ ),
353
+ ],
354
+ kind=inspect.Parameter.KEYWORD_ONLY,
355
+ )
356
+ annotated_parameters.append(new_child_param)
357
+
358
+ # replacing with the annotated parameters
359
+ new_signature = inspect.Signature(
360
+ parameters=annotated_parameters,
361
+ return_annotation=sig.return_annotation,
362
+ )
363
+ self._generate_argparse_arguments(new_signature.parameters)
364
+
365
+ # the custom type itself should not be added as an argument
366
+ continue
367
+
368
+ required = not self._param_is_default(param)
369
+
370
+ # Get the appropriate action based on the parameter type
371
+ action = self._get_action_type(param)
372
+
373
+ # For boolean parameters with action="store_true", we should not use any choices
374
+ if param_type is bool:
375
+ choices = ()
376
+ action = "store_true"
377
+
378
+ argument = ArgparseArgumentModel(
379
+ name=param.name,
380
+ type=param_type,
381
+ dest=param.name,
382
+ default=param.default,
383
+ required=required,
384
+ action=action,
385
+ help=self._get_argument_custom_help(param),
386
+ nargs=self._get_nargs(param),
387
+ choices=choices,
388
+ )
389
+ kwargs = argument.model_dump(exclude={"name"}, exclude_none=True)
390
+
391
+ if required:
392
+ self._required.add_argument(
393
+ f"--{argument.name}",
394
+ **kwargs,
395
+ )
396
+ else:
397
+ self._parser.add_argument(
398
+ f"--{argument.name}",
399
+ **kwargs,
400
+ )
401
+
402
+ @staticmethod
403
+ def _unflatten_args(args: dict) -> Dict[str, Any]:
404
+ """Unflatten the args that were flattened by the custom types."""
405
+ result: Dict[str, Any] = {}
406
+ for key, value in args.items():
407
+ if SEP in key:
408
+ parts = key.split(SEP)
409
+ nested_dict = result
410
+ for part in parts[:-1]:
411
+ if part not in nested_dict:
412
+ nested_dict[part] = {}
413
+ nested_dict = nested_dict[part]
414
+ nested_dict[parts[-1]] = value
415
+ else:
416
+ result[key] = value
417
+ return result
418
+
419
+ def _update_with_custom_types(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
420
+ """Update the kwargs with the custom types."""
421
+ # for each argument in the signature that is a custom type, we need to
422
+ # update the kwargs with the custom type kwargs
423
+ for param in self.signature.parameters.values():
424
+ if param.name == "kwargs":
425
+ continue
426
+ param_type, _ = self._get_type_and_choices(param)
427
+ if inspect.isclass(param_type) and issubclass(param_type, BaseModel):
428
+ custom_type_kwargs = kwargs[param.name]
429
+ kwargs[param.name] = param_type(**custom_type_kwargs)
430
+
431
+ return kwargs
432
+
433
+ def execute_func(
434
+ self,
435
+ parsed_args: Optional[argparse.Namespace] = None,
436
+ ) -> Any:
437
+ """
438
+ Execute the original function with the parsed arguments.
439
+
440
+ Args:
441
+ parsed_args (Optional[argparse.Namespace], optional): The parsed arguments. Defaults to None.
442
+
443
+ Returns:
444
+ Any: The return value of the original function.
445
+
446
+ """
447
+ kwargs = self._unflatten_args(vars(parsed_args))
448
+ kwargs = self._update_with_custom_types(kwargs)
449
+ provider = kwargs.get("provider")
450
+ provider_args: List = []
451
+ if provider and provider in self.provider_parameters:
452
+ provider_args = self.provider_parameters[provider]
453
+ else:
454
+ for args in self.provider_parameters.values():
455
+ provider_args.extend(args)
456
+
457
+ # remove kwargs not matching the signature, provider parameters, or are empty.
458
+ kwargs = {
459
+ key: value
460
+ for key, value in kwargs.items()
461
+ if (
462
+ (key in self.signature.parameters or key in provider_args)
463
+ and (value or value is False)
464
+ )
465
+ }
466
+ return self.func(**kwargs)
467
+
468
+ def parse_args_and_execute(self) -> Any:
469
+ """
470
+ Parse the arguments and executes the original function.
471
+
472
+ Returns:
473
+ Any: The return value of the original function.
474
+ """
475
+ parsed_args = self._parser.parse_args()
476
+
477
+ return self.execute_func(parsed_args)
478
+
479
+ def translate(self) -> Callable:
480
+ """
481
+ Wrap the original function with an argparse program.
482
+
483
+ Returns:
484
+ Callable: The original function wrapped with an argparse program.
485
+ """
486
+
487
+ def wrapper_func():
488
+ return self.parse_args_and_execute()
489
+
490
+ return wrapper_func
cli/openbb_cli/argparse_translator/obbject_registry.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Registry for OBBjects."""
2
+
3
+ import json
4
+ from typing import Dict, List, Optional, Union
5
+
6
+ from openbb_core.app.model.obbject import OBBject
7
+
8
+
9
+ class Registry:
10
+ """Registry for OBBjects."""
11
+
12
+ def __init__(self):
13
+ """Initialize the registry."""
14
+ self._obbjects: List[OBBject] = []
15
+
16
+ @staticmethod
17
+ def _contains_obbject(uuid: str, obbjects: List[OBBject]) -> bool:
18
+ """Check if obbject with uuid is in the registry."""
19
+ return any(obbject.id == uuid for obbject in obbjects)
20
+
21
+ def register(self, obbject: OBBject) -> bool:
22
+ """Designed to add an OBBject instance to the registry."""
23
+ if (
24
+ isinstance(obbject, OBBject)
25
+ and not self._contains_obbject(obbject.id, self._obbjects)
26
+ and obbject.results
27
+ ):
28
+ self._obbjects.append(obbject)
29
+ return True
30
+ return False
31
+
32
+ def get(self, arg: Union[int, str]) -> Optional[OBBject]:
33
+ """Return the obbject with index or key."""
34
+ if isinstance(arg, int):
35
+ return self._get_by_index(arg)
36
+ if isinstance(arg, str):
37
+ return self._get_by_key(arg)
38
+
39
+ raise ValueError("Couldn't get the `OBBject` with the provided argument.")
40
+
41
+ def _get_by_key(self, key: str) -> Optional[OBBject]:
42
+ """Return the obbject with key."""
43
+ for obbject in self._obbjects:
44
+ if obbject.extra.get("register_key", "") == key:
45
+ return obbject
46
+ return None
47
+
48
+ def _get_by_index(self, idx: int) -> Optional[OBBject]:
49
+ """Return the obbject at index idx."""
50
+ # the list should work as a stack
51
+ # i.e., the last element needs to be accessed by idx=0 and so on
52
+ reversed_list = list(reversed(self._obbjects))
53
+
54
+ # check if the index is out of bounds
55
+ if idx >= len(reversed_list):
56
+ return None
57
+
58
+ return reversed_list[idx]
59
+
60
+ def remove(self, idx: int = -1):
61
+ """Remove the obbject at index idx, default is the last element."""
62
+ # the list should work as a stack
63
+ # i.e., the last element needs to be accessed by idx=0 and so on
64
+ reversed_list = list(reversed(self._obbjects))
65
+ del reversed_list[idx]
66
+ self._obbjects = list(reversed(reversed_list))
67
+
68
+ @property
69
+ def all(self) -> Dict[int, Dict]:
70
+ """Return all obbjects in the registry."""
71
+
72
+ def _handle_standard_params(obbject: OBBject) -> str:
73
+ """Handle standard params for obbjects."""
74
+ standard_params_json = ""
75
+ std_params = getattr(
76
+ obbject, "_standard_params", {}
77
+ ) # pylint: disable=protected-access
78
+ if std_params:
79
+ standard_params = {
80
+ k: str(v)[:30] for k, v in std_params.items() if v and k != "data"
81
+ }
82
+ standard_params_json = json.dumps(standard_params)
83
+
84
+ return standard_params_json
85
+
86
+ def _handle_data_repr(obbject: OBBject) -> str:
87
+ """Handle data representation for obbjects."""
88
+ data_repr = ""
89
+ if hasattr(obbject, "results") and obbject.results:
90
+ data_schema = (
91
+ obbject.results[0].model_json_schema()
92
+ if obbject.results
93
+ and isinstance(obbject.results, list)
94
+ and hasattr(obbject.results[0], "model_json_schema")
95
+ else ""
96
+ )
97
+ if data_schema and "title" in data_schema:
98
+ data_repr = f"{data_schema['title']}" # type: ignore
99
+ if data_schema and "description" in data_schema:
100
+ data_repr += f" - {data_schema['description'].split('.')[0]}" # type: ignore
101
+
102
+ return data_repr
103
+
104
+ obbjects = {}
105
+ for i, obbject in enumerate(list(reversed(self._obbjects))):
106
+ obbjects[i] = {
107
+ "route": obbject._route, # pylint: disable=protected-access
108
+ "provider": obbject.provider,
109
+ "standard params": _handle_standard_params(obbject),
110
+ "data": _handle_data_repr(obbject),
111
+ "command": obbject.extra.get("command", ""),
112
+ "key": obbject.extra.get("register_key", ""),
113
+ }
114
+
115
+ return obbjects
116
+
117
+ @property
118
+ def obbjects(self) -> List[OBBject]:
119
+ """Return all obbjects in the registry."""
120
+ return self._obbjects
121
+
122
+ @property
123
+ def obbject_keys(self) -> List[str]:
124
+ """Return all obbject keys in the registry."""
125
+ return [
126
+ obbject.extra["register_key"]
127
+ for obbject in self._obbjects
128
+ if "register_key" in obbject.extra
129
+ ]
cli/openbb_cli/argparse_translator/reference_processor.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Module for the ReferenceToArgumentsProcessor class."""
2
+
3
+ # `ForwardRef`needs to be imported because the usage of `eval()`,
4
+ # which creates a ForwardRef
5
+ # which would raise a not defined error if it's not imported here.
6
+ # pylint: disable=unused-import
7
+ from typing import (
8
+ Any,
9
+ Dict,
10
+ ForwardRef, # noqa: F401
11
+ List,
12
+ Literal,
13
+ Optional,
14
+ Tuple,
15
+ Union,
16
+ get_args,
17
+ get_origin,
18
+ )
19
+
20
+ from openbb_cli.argparse_translator.argparse_argument import (
21
+ ArgparseArgumentGroupModel,
22
+ ArgparseArgumentModel,
23
+ )
24
+
25
+
26
+ class ReferenceToArgumentsProcessor:
27
+ """Class to process the reference and build custom argument groups."""
28
+
29
+ def __init__(self, reference: Dict[str, Dict]):
30
+ """Initialize the ReferenceToArgumentsProcessor."""
31
+ self._reference = reference
32
+ self._custom_groups: Dict[str, List[ArgparseArgumentGroupModel]] = {}
33
+
34
+ self._build_custom_groups()
35
+
36
+ @property
37
+ def custom_groups(self) -> Dict[str, List[ArgparseArgumentGroupModel]]:
38
+ """Get the custom groups."""
39
+ return self._custom_groups
40
+
41
+ @staticmethod
42
+ def _make_type_parsable(type_: str) -> type:
43
+ """Make the type parsable by removing the annotations."""
44
+ if "Union" in type_ and "str" in type_:
45
+ return str
46
+ if "Union" in type_ and "int" in type_:
47
+ return int
48
+ if type_ in ["date", "datetime.time", "time"]:
49
+ return str
50
+
51
+ if any(x in type_ for x in ["gt=", "ge=", "lt=", "le="]):
52
+ if "Annotated" in type_:
53
+ type_ = type_.replace("Annotated[", "").replace("]", "")
54
+ type_ = type_.split(",")[0]
55
+
56
+ return eval(type_) # noqa: S307, E501 pylint: disable=eval-used
57
+
58
+ def _parse_type(self, type_: str) -> type:
59
+ """Parse the type from the string representation."""
60
+ type_ = self._make_type_parsable(type_) # type: ignore
61
+
62
+ if get_origin(type_) is Literal:
63
+ type_ = type(get_args(type_)[0]) # type: ignore
64
+
65
+ return type_ # type: ignore
66
+
67
+ def _get_nargs(self, type_: type) -> Optional[Union[int, str]]:
68
+ """Get the nargs for the given type."""
69
+ if get_origin(type_) is list:
70
+ return "+"
71
+ return None
72
+
73
+ def _get_choices(self, type_: str, custom_choices: Any) -> Tuple:
74
+ """Get the choices for the given type."""
75
+ type_ = self._make_type_parsable(type_) # type: ignore
76
+ type_origin = get_origin(type_)
77
+
78
+ choices: tuple[Any, ...] = ()
79
+
80
+ if type_origin is Literal:
81
+ choices = get_args(type_)
82
+
83
+ if type_origin is list:
84
+ type_ = get_args(type_)[0]
85
+
86
+ if get_origin(type_) is Literal:
87
+ choices = get_args(type_)
88
+
89
+ if type_origin is Union and type(None) in get_args(type_):
90
+ # remove NoneType from the args
91
+ args = [arg for arg in get_args(type_) if arg != type(None)]
92
+ # if there is only one arg left, use it
93
+ if len(args) > 1:
94
+ raise ValueError("Union with NoneType should have only one type left")
95
+ type_ = args[0]
96
+
97
+ if get_origin(type_) is Literal:
98
+ choices = get_args(type_)
99
+
100
+ if custom_choices:
101
+ return tuple(custom_choices)
102
+
103
+ return choices
104
+
105
+ def _build_custom_groups(self):
106
+ """Build the custom groups from the reference."""
107
+ for route, v in self._reference.items():
108
+ for provider, args in v["parameters"].items():
109
+ if provider == "standard":
110
+ continue
111
+
112
+ custom_arguments = []
113
+ for arg in args:
114
+ if arg.get("standard"):
115
+ continue
116
+
117
+ type_ = self._parse_type(arg["type"])
118
+
119
+ custom_arguments.append(
120
+ ArgparseArgumentModel(
121
+ name=arg["name"],
122
+ type=type_,
123
+ dest=arg["name"],
124
+ default=arg["default"],
125
+ required=not (arg["optional"]),
126
+ action="store" if type_ != bool else "store_true",
127
+ help=arg["description"],
128
+ nargs=self._get_nargs(type_), # type: ignore
129
+ choices=self._get_choices(
130
+ arg["type"], custom_choices=arg["choices"]
131
+ ),
132
+ )
133
+ )
134
+
135
+ group = ArgparseArgumentGroupModel(
136
+ name=provider, arguments=custom_arguments
137
+ )
138
+
139
+ if route not in self._custom_groups:
140
+ self._custom_groups[route] = []
141
+
142
+ self._custom_groups[route].append(group)
cli/openbb_cli/argparse_translator/utils.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Utilities for argparse_translator module."""
2
+
3
+ from argparse import Action, ArgumentParser
4
+ from typing import List, Optional, Tuple
5
+
6
+
7
+ def in_group(parser: ArgumentParser, argument_name: str, group_title: str) -> bool:
8
+ """Check if an argument is in a group of an ArgumentParser."""
9
+ for action_group in parser._action_groups: # pylint: disable=protected-access
10
+ if action_group.title == group_title:
11
+ for (
12
+ action
13
+ ) in action_group._group_actions: # pylint: disable=protected-access
14
+ opts = action.option_strings
15
+ if (opts and opts[0] == argument_name) or action.dest == argument_name:
16
+ return True
17
+ return False
18
+
19
+
20
+ def remove_argument(parser: ArgumentParser, argument_name: str) -> List[Optional[str]]:
21
+ """Remove an argument from an ArgumentParser."""
22
+ groups_w_arg = []
23
+
24
+ # remove the argument from the parser
25
+ for action in parser._actions: # pylint: disable=protected-access
26
+ opts = action.option_strings
27
+ if (opts and opts[0] == argument_name) or action.dest == argument_name:
28
+ parser._remove_action(action) # pylint: disable=protected-access
29
+ break
30
+
31
+ # remove from all groups
32
+ for action_group in parser._action_groups: # pylint: disable=protected-access
33
+ for action in action_group._group_actions: # pylint: disable=protected-access
34
+ opts = action.option_strings
35
+ if (opts and opts[0] == argument_name) or action.dest == argument_name:
36
+ action_group._group_actions.remove( # pylint: disable=protected-access
37
+ action
38
+ )
39
+ groups_w_arg.append(action_group.title)
40
+
41
+ # remove from _action_groups dict
42
+ parser._option_string_actions.pop( # pylint: disable=protected-access
43
+ f"--{argument_name}", None
44
+ )
45
+
46
+ return groups_w_arg
47
+
48
+
49
+ def get_argument_choices(parser: ArgumentParser, argument_name: str) -> Tuple:
50
+ """Get the choices of an argument from an ArgumentParser."""
51
+ for action in parser._actions: # pylint: disable=protected-access
52
+ opts = action.option_strings
53
+ if (opts and opts[0] == argument_name) or action.dest == argument_name:
54
+ return tuple(action.choices or ())
55
+ return ()
56
+
57
+
58
+ def get_argument_optional_choices(parser: ArgumentParser, argument_name: str) -> bool:
59
+ """Get the optional_choices attribute of an argument from an ArgumentParser."""
60
+ for action in parser._actions: # pylint: disable=protected-access
61
+ opts = action.option_strings
62
+ if (
63
+ (opts and opts[0] == argument_name)
64
+ or action.dest == argument_name
65
+ and hasattr(action, "optional_choices")
66
+ ):
67
+ return (
68
+ action.optional_choices # type: ignore[attr-defined] # this is a custom attribute
69
+ )
70
+ return False
71
+
72
+
73
+ def set_optional_choices(action: Action, optional_choices: bool):
74
+ """Set the optional_choices attribute of an action."""
75
+ if not hasattr(action, "optional_choices") and optional_choices:
76
+ setattr(action, "optional_choices", optional_choices)
cli/openbb_cli/assets/routines/routine_example.openbb ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Go into the equity context
2
+ equity
3
+
4
+ # Get the company's profile
5
+ profile --symbol aapl
6
+
7
+ # Get company's statements
8
+ fundamental
9
+ balance --symbol aapl
10
+ cash --symbol aapl
11
+ transcript --symbol aapl --year 2023
12
+
13
+ # Load company's historical data
14
+ ../price
15
+ historical --symbol aapl
16
+
17
+ # Get its performance
18
+ performance --symbol aapl
19
+
20
+ # Export the candle as an image and the historical data into a csv
21
+ historical --symbol aapl --chart --export csv,jpg
cli/openbb_cli/assets/styles/default/Consolas.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:423504695a3de1f80c618e3e6ead215a6b891be06c179bf048bb5a80d5d0eda3
3
+ size 358460
cli/openbb_cli/assets/styles/default/dark.mpfstyle.json ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "style_name": "dark",
3
+ "base_mpf_style": null,
4
+ "marketcolors": {
5
+ "candle": {
6
+ "up": "#00ACFF",
7
+ "down": "#e4003a"
8
+ },
9
+ "edge": {
10
+ "up": "#00ACFF",
11
+ "down": "#e4003a"
12
+ },
13
+ "wick": {
14
+ "up": "#00ACFF",
15
+ "down": "#e4003a"
16
+ },
17
+ "ohlc": {
18
+ "up": "#00ACFF",
19
+ "down": "#e4003a"
20
+ },
21
+ "volume": {
22
+ "up": "#00ACFF",
23
+ "down": "#e4003a"
24
+ },
25
+ "vcedge": {
26
+ "up": "#219E4F",
27
+ "down": "#9E1711"
28
+ },
29
+ "vcdopcod": true,
30
+ "alpha": 1
31
+ },
32
+ "mavcolors": [
33
+ "#EB3DBC",
34
+ "#31EBEA",
35
+ "#EB8C54",
36
+ "#EB5549"
37
+ ],
38
+ "y_on_right": true,
39
+ "gridcolor": "#A3A0A2",
40
+ "gridstyle": ":",
41
+ "facecolor": "black",
42
+ "edgecolor": null,
43
+ "figcolor": null,
44
+ "gridaxis": null,
45
+ "rc": null,
46
+ "legacy_rc": null
47
+ }
cli/openbb_cli/assets/styles/default/dark.mplrc.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "xticks_rotation": 10,
3
+ "tight_layout_padding": 2,
4
+ "pie_wedgeprops": {"linewidth": 0.5, "edgecolor": "#FFFFFF"},
5
+ "pie_startangle": 90,
6
+ "volume_bar_width": 0.5
7
+ }
cli/openbb_cli/assets/styles/default/dark.mplstyle ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The cool hackerman style
2
+
3
+ # LINES
4
+ # http://matplotlib.org/api/artist_api.html#module-matplotlib.lines
5
+ lines.linewidth: 1.5
6
+ lines.color: F5EFF3
7
+ lines.linestyle: -
8
+ lines.marker: None
9
+ lines.markeredgewidth: 0.5
10
+ lines.markersize: 4
11
+ lines.dash_joinstyle: miter
12
+ lines.dash_capstyle: butt
13
+ lines.solid_joinstyle: miter
14
+ lines.solid_capstyle: projecting
15
+ lines.antialiased: True
16
+
17
+ patch.edgecolor: F5EFF3
18
+ patch.facecolor: 00aaff
19
+ patch.linewidth: 1
20
+
21
+ # TEXT
22
+ # http://matplotlib.org/api/font_manager_api.html
23
+ font.family: sans-serif
24
+ font.sans-serif: Consolas, Deja Vu Sans, Arial, Helvetica
25
+ font.style: normal
26
+ font.variant: normal
27
+ font.weight: medium
28
+ font.stretch: normal
29
+ font.size: 16
30
+
31
+ text.color: F5EFF3
32
+
33
+ axes.spines.left: true
34
+ axes.spines.bottom: true
35
+ axes.spines.top: true
36
+ axes.spines.right: true
37
+ axes.linewidth: 0.4
38
+
39
+ axes.labelsize: small
40
+ axes.titlelocation: left
41
+
42
+ axes.facecolor: black
43
+ axes.edgecolor: F5EFF3
44
+ axes.labelcolor: F5EFF3
45
+ axes.grid: true
46
+ axes.grid.which: major
47
+
48
+ axes.prop_cycle: cycler('color', ['ffed00', 'ef7d00', 'e4003a', 'c13246', '822661', '48277c', '005ca9', '00aaff', '9b30d9', 'af005f', '5f00af', 'af87ff'])
49
+
50
+ xtick.color: F5EFF3
51
+ ytick.color: F5EFF3
52
+
53
+ xtick.major.size: 2
54
+ xtick.minor.size: 1
55
+ xtick.labelsize: small
56
+ xtick.alignment: center
57
+ xtick.major.width: 0.2
58
+
59
+ ytick.major.size: 2
60
+ ytick.minor.size: 1
61
+ ytick.labelsize: small
62
+
63
+ ytick.left: False
64
+ ytick.labelleft: False
65
+ ytick.right: True
66
+ ytick.labelright: True
67
+ ytick.major.width: 0.2
68
+
69
+ grid.color: F5EFF3
70
+ grid.linewidth: 0.4
71
+ grid.linestyle: :
72
+
73
+ legend.framealpha: 0.6
74
+ legend.frameon: true
75
+ legend.facecolor: black
76
+ legend.edgecolor: black
77
+ legend.scatterpoints: 3
78
+ legend.fontsize: small
79
+ legend.loc: upper left
80
+
81
+ figure.facecolor: black
82
+ figure.edgecolor: black
83
+ figure.subplot.hspace: 0.2
84
+
85
+ savefig.facecolor: black
86
+ savefig.edgecolor: black
87
+
88
+ ### Boxplots
89
+ boxplot.boxprops.color: F5EFF3
90
+ boxplot.capprops.color: F5EFF3
91
+ boxplot.flierprops.color: F5EFF3
92
+ boxplot.flierprops.markeredgecolor: F5EFF3
93
+ boxplot.whiskerprops.color: F5EFF3
94
+ boxplot.medianprops.color: F5EFF3
95
+ boxplot.meanprops.markeredgecolor: F5EFF3
96
+ boxplot.meanprops.color: F5EFF3
cli/openbb_cli/assets/styles/default/dark.pltstyle.json ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "line": {
3
+ "up_color": "#00ACFF",
4
+ "down_color": "#e4003a",
5
+ "color": "#ffed00",
6
+ "width": 1.5
7
+ },
8
+ "data": {
9
+ "candlestick": [
10
+ {
11
+ "decreasing": {
12
+ "fillcolor": "#e4003a",
13
+ "line": {
14
+ "color": "#e4003a"
15
+ }
16
+ },
17
+ "increasing": {
18
+ "fillcolor": "#00ACFF",
19
+ "line": {
20
+ "color": "#00ACFF"
21
+ }
22
+ },
23
+ "type": "candlestick"
24
+ }
25
+ ]
26
+ },
27
+ "layout": {
28
+ "annotationdefaults": {
29
+ "showarrow": false
30
+ },
31
+ "autotypenumbers": "strict",
32
+ "colorway": [
33
+ "#ffed00",
34
+ "#ef7d00",
35
+ "#e4003a",
36
+ "#c13246",
37
+ "#822661",
38
+ "#48277c",
39
+ "#005ca9",
40
+ "#00aaff",
41
+ "#9b30d9",
42
+ "#af005f",
43
+ "#5f00af",
44
+ "#af87ff"
45
+ ],
46
+ "dragmode": "pan",
47
+ "font": {
48
+ "family": "Fira Code",
49
+ "size": 18
50
+ },
51
+ "hoverlabel": {
52
+ "align": "left"
53
+ },
54
+ "mapbox": {
55
+ "style": "dark"
56
+ },
57
+ "hovermode": "x",
58
+ "legend": {
59
+ "bgcolor": "rgba(0, 0, 0, 0.5)",
60
+ "x": 0.01,
61
+ "xanchor": "left",
62
+ "y": 0.99,
63
+ "yanchor": "top",
64
+ "font": {
65
+ "size": 15
66
+ }
67
+ },
68
+ "legend2": {
69
+ "bgcolor": "rgba(0, 0, 0, 0.5)",
70
+ "font": {
71
+ "size": 15
72
+ }
73
+ },
74
+ "legend3": {
75
+ "bgcolor": "rgba(0, 0, 0, 0.5)",
76
+ "font": {
77
+ "size": 15
78
+ }
79
+ },
80
+ "legend4": {
81
+ "bgcolor": "rgba(0, 0, 0, 0.5)",
82
+ "font": {
83
+ "size": 15
84
+ }
85
+ },
86
+ "legend5": {
87
+ "bgcolor": "rgba(0, 0, 0, 0.5)",
88
+ "font": {
89
+ "size": 15
90
+ }
91
+ },
92
+ "paper_bgcolor": "#000000",
93
+ "plot_bgcolor": "#000000",
94
+ "xaxis": {
95
+ "automargin": true,
96
+ "autorange": true,
97
+ "rangeslider": {
98
+ "visible": false
99
+ },
100
+ "showgrid": true,
101
+ "showline": true,
102
+ "tickfont": {
103
+ "size": 14
104
+ },
105
+ "zeroline": false,
106
+ "tick0": 1,
107
+ "title": {
108
+ "standoff": 20
109
+ },
110
+ "linecolor": "#F5EFF3",
111
+ "mirror": true,
112
+ "ticks": "outside"
113
+ },
114
+ "yaxis": {
115
+ "anchor": "x",
116
+ "automargin": true,
117
+ "fixedrange": false,
118
+ "zeroline": false,
119
+ "showgrid": true,
120
+ "showline": true,
121
+ "side": "right",
122
+ "tick0": 0.5,
123
+ "title": {
124
+ "standoff": 20
125
+ },
126
+ "gridcolor": "#283442",
127
+ "linecolor": "#F5EFF3",
128
+ "mirror": true,
129
+ "ticks": "outside"
130
+ }
131
+ }
132
+ }
cli/openbb_cli/assets/styles/default/dark.richstyle.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "info": "rgb(224,131,48)",
3
+ "cmds": "rgb(102,203,228)",
4
+ "param": "rgb(247,206,70)",
5
+ "menu": "rgb(50,115,185)",
6
+ "src": "rgb(216,90,64)",
7
+ "unvl": "grey30",
8
+ "help": "#FAC900"
9
+ }
cli/openbb_cli/assets/styles/default/light.mpfstyle.json ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "style_name": "light",
3
+ "base_mpf_style": null,
4
+ "marketcolors": {
5
+ "candle": {
6
+ "up": "#00ACFF",
7
+ "down": "#e4003a"
8
+ },
9
+ "edge": {
10
+ "up": "#00ACFF",
11
+ "down": "#e4003a"
12
+ },
13
+ "wick": {
14
+ "up": "#00ACFF",
15
+ "down": "#e4003a"
16
+ },
17
+ "ohlc": {
18
+ "up": "#00ACFF",
19
+ "down": "#e4003a"
20
+ },
21
+ "volume": {
22
+ "up": "#00ACFF",
23
+ "down": "#e4003a"
24
+ },
25
+ "vcedge": {
26
+ "up": "#219E4F",
27
+ "down": "#9E1711"
28
+ },
29
+ "vcdopcod": true,
30
+ "alpha": 1
31
+ },
32
+ "mavcolors": [
33
+ "#EB3DBC",
34
+ "#31EBEA",
35
+ "#EB8C54",
36
+ "#EB5549"
37
+ ],
38
+ "y_on_right": true,
39
+ "gridcolor": "grey",
40
+ "gridstyle": ":",
41
+ "facecolor": "white",
42
+ "edgecolor": null,
43
+ "figcolor": null,
44
+ "gridaxis": null,
45
+ "rc": null,
46
+ "legacy_rc": null
47
+ }
cli/openbb_cli/assets/styles/default/light.mplrc.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "xticks_rotation": 10,
3
+ "tight_layout_padding": 2,
4
+ "pie_wedgeprops": {"linewidth": 0.5, "edgecolor": "#000000"},
5
+ "pie_startangle": 90,
6
+ "volume_bar_width": 0.5
7
+ }
cli/openbb_cli/assets/styles/default/light.mplstyle ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The cool hackerman style
2
+
3
+ # LINES
4
+ # http://matplotlib.org/api/artist_api.html#module-matplotlib.lines
5
+ lines.linewidth: 1.1
6
+ lines.color: 0F0F0F
7
+ lines.linestyle: -
8
+ lines.marker: None
9
+ lines.markeredgewidth: 0.5
10
+ lines.markersize: 4
11
+ lines.dash_joinstyle: miter
12
+ lines.dash_capstyle: butt
13
+ lines.solid_joinstyle: miter
14
+ lines.solid_capstyle: projecting
15
+ lines.antialiased: True
16
+
17
+ patch.edgecolor: 000000
18
+ patch.facecolor: 822661
19
+ patch.linewidth: 1
20
+
21
+ # TEXT
22
+ # http://matplotlib.org/api/font_manager_api.html
23
+ font.family: sans-serif
24
+ font.sans-serif: Consolas, Deja Vu Sans, Arial, Helvetica
25
+ font.style: normal
26
+ font.variant: normal
27
+ font.weight: medium
28
+ font.stretch: normal
29
+ font.size: 13
30
+
31
+ text.color: 0F0F0F
32
+
33
+ axes.spines.left: true
34
+ axes.spines.bottom: true
35
+ axes.spines.top: true
36
+ axes.spines.right: true
37
+ axes.linewidth: 0.2
38
+
39
+ axes.labelsize: small
40
+ axes.titlelocation: left
41
+
42
+ axes.facecolor: white
43
+ axes.edgecolor: 0F0F0F
44
+ axes.labelcolor: 0F0F0F
45
+ axes.grid: true
46
+ axes.grid.which: major
47
+
48
+ axes.prop_cycle: cycler('color', ['254495', 'c13246', '48277c', 'e4003a', 'ef7d00', '822661', 'ffed00', '00aaff', '9b30d9', 'af005f', '5f00af', 'af87ff'])
49
+
50
+ xtick.color: 0F0F0F
51
+ ytick.color: 0F0F0F
52
+
53
+ xtick.major.size: 2
54
+ xtick.minor.size: 1
55
+ xtick.labelsize: small
56
+
57
+ ytick.major.size: 2
58
+ ytick.minor.size: 1
59
+ ytick.labelsize: small
60
+ xtick.alignment: center
61
+
62
+ ytick.left: False
63
+ ytick.labelleft: False
64
+ ytick.right: True
65
+ ytick.labelright: True
66
+
67
+
68
+ grid.color: 0F0F0F
69
+ grid.linewidth: 0.4
70
+ grid.linestyle: :
71
+
72
+ legend.framealpha: 0.6
73
+ legend.frameon: true
74
+ legend.facecolor: white
75
+ legend.edgecolor: white
76
+ legend.scatterpoints: 3
77
+ legend.fontsize: small
78
+ legend.loc: best
79
+
80
+ figure.facecolor: white
81
+ figure.edgecolor: white
82
+ figure.subplot.hspace: 0.2
83
+
84
+ savefig.facecolor: white
85
+ savefig.edgecolor: white
86
+
87
+ ### Boxplots
88
+ boxplot.boxprops.color: 0F0F0F
89
+ boxplot.capprops.color: 0F0F0F
90
+ boxplot.flierprops.color: 0F0F0F
91
+ boxplot.flierprops.markeredgecolor: 0F0F0F
92
+ boxplot.whiskerprops.color: 0F0F0F
93
+ boxplot.medianprops.color: 0F0F0F
94
+ boxplot.meanprops.markeredgecolor: 0F0F0F
95
+ boxplot.meanprops.color: 0F0F0F
cli/openbb_cli/assets/styles/default/light.pltstyle.json ADDED
@@ -0,0 +1,871 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "line": {
3
+ "up_color": "#009600",
4
+ "down_color": "#c80000",
5
+ "color": "#0d0887",
6
+ "width": 1.5,
7
+ "down_color_transparent": "rgba(200, 0, 0, 0.4)",
8
+ "up_color_transparent": "rgba(0, 150, 0, 0.4)"
9
+ },
10
+ "data": {
11
+ "barpolar": [
12
+ {
13
+ "marker": {
14
+ "line": {
15
+ "color": "white",
16
+ "width": 0.5
17
+ },
18
+ "pattern": {
19
+ "fillmode": "overlay",
20
+ "size": 10,
21
+ "solidity": 0.2
22
+ }
23
+ },
24
+ "type": "barpolar"
25
+ }
26
+ ],
27
+ "bar": [
28
+ {
29
+ "error_x": {
30
+ "color": "#2a3f5f"
31
+ },
32
+ "error_y": {
33
+ "color": "#2a3f5f"
34
+ },
35
+ "marker": {
36
+ "line": {
37
+ "color": "white",
38
+ "width": 0.5
39
+ },
40
+ "pattern": {
41
+ "fillmode": "overlay",
42
+ "size": 10,
43
+ "solidity": 0.2
44
+ }
45
+ },
46
+ "type": "bar"
47
+ }
48
+ ],
49
+ "carpet": [
50
+ {
51
+ "aaxis": {
52
+ "endlinecolor": "#2a3f5f",
53
+ "gridcolor": "#C8D4E3",
54
+ "linecolor": "#C8D4E3",
55
+ "minorgridcolor": "#C8D4E3",
56
+ "startlinecolor": "#2a3f5f"
57
+ },
58
+ "baxis": {
59
+ "endlinecolor": "#2a3f5f",
60
+ "gridcolor": "#C8D4E3",
61
+ "linecolor": "#C8D4E3",
62
+ "minorgridcolor": "#C8D4E3",
63
+ "startlinecolor": "#2a3f5f"
64
+ },
65
+ "type": "carpet"
66
+ }
67
+ ],
68
+ "choropleth": [
69
+ {
70
+ "colorbar": {
71
+ "outlinewidth": 0,
72
+ "ticks": ""
73
+ },
74
+ "type": "choropleth"
75
+ }
76
+ ],
77
+ "contourcarpet": [
78
+ {
79
+ "colorbar": {
80
+ "outlinewidth": 0,
81
+ "ticks": ""
82
+ },
83
+ "type": "contourcarpet"
84
+ }
85
+ ],
86
+ "contour": [
87
+ {
88
+ "colorbar": {
89
+ "outlinewidth": 0,
90
+ "ticks": ""
91
+ },
92
+ "colorscale": [
93
+ [
94
+ 0.0,
95
+ "#0d0887"
96
+ ],
97
+ [
98
+ 0.1111111111111111,
99
+ "#46039f"
100
+ ],
101
+ [
102
+ 0.2222222222222222,
103
+ "#7201a8"
104
+ ],
105
+ [
106
+ 0.3333333333333333,
107
+ "#9c179e"
108
+ ],
109
+ [
110
+ 0.4444444444444444,
111
+ "#bd3786"
112
+ ],
113
+ [
114
+ 0.5555555555555556,
115
+ "#d8576b"
116
+ ],
117
+ [
118
+ 0.6666666666666666,
119
+ "#ed7953"
120
+ ],
121
+ [
122
+ 0.7777777777777778,
123
+ "#fb9f3a"
124
+ ],
125
+ [
126
+ 0.8888888888888888,
127
+ "#fdca26"
128
+ ],
129
+ [
130
+ 1.0,
131
+ "#f0f921"
132
+ ]
133
+ ],
134
+ "type": "contour"
135
+ }
136
+ ],
137
+ "heatmapgl": [
138
+ {
139
+ "colorbar": {
140
+ "outlinewidth": 0,
141
+ "ticks": ""
142
+ },
143
+ "colorscale": [
144
+ [
145
+ 0.0,
146
+ "#0d0887"
147
+ ],
148
+ [
149
+ 0.1111111111111111,
150
+ "#46039f"
151
+ ],
152
+ [
153
+ 0.2222222222222222,
154
+ "#7201a8"
155
+ ],
156
+ [
157
+ 0.3333333333333333,
158
+ "#9c179e"
159
+ ],
160
+ [
161
+ 0.4444444444444444,
162
+ "#bd3786"
163
+ ],
164
+ [
165
+ 0.5555555555555556,
166
+ "#d8576b"
167
+ ],
168
+ [
169
+ 0.6666666666666666,
170
+ "#ed7953"
171
+ ],
172
+ [
173
+ 0.7777777777777778,
174
+ "#fb9f3a"
175
+ ],
176
+ [
177
+ 0.8888888888888888,
178
+ "#fdca26"
179
+ ],
180
+ [
181
+ 1.0,
182
+ "#f0f921"
183
+ ]
184
+ ],
185
+ "type": "heatmapgl"
186
+ }
187
+ ],
188
+ "heatmap": [
189
+ {
190
+ "colorbar": {
191
+ "outlinewidth": 0,
192
+ "ticks": ""
193
+ },
194
+ "colorscale": [
195
+ [
196
+ 0.0,
197
+ "#0d0887"
198
+ ],
199
+ [
200
+ 0.1111111111111111,
201
+ "#46039f"
202
+ ],
203
+ [
204
+ 0.2222222222222222,
205
+ "#7201a8"
206
+ ],
207
+ [
208
+ 0.3333333333333333,
209
+ "#9c179e"
210
+ ],
211
+ [
212
+ 0.4444444444444444,
213
+ "#bd3786"
214
+ ],
215
+ [
216
+ 0.5555555555555556,
217
+ "#d8576b"
218
+ ],
219
+ [
220
+ 0.6666666666666666,
221
+ "#ed7953"
222
+ ],
223
+ [
224
+ 0.7777777777777778,
225
+ "#fb9f3a"
226
+ ],
227
+ [
228
+ 0.8888888888888888,
229
+ "#fdca26"
230
+ ],
231
+ [
232
+ 1.0,
233
+ "#f0f921"
234
+ ]
235
+ ],
236
+ "type": "heatmap"
237
+ }
238
+ ],
239
+ "histogram2dcontour": [
240
+ {
241
+ "colorbar": {
242
+ "outlinewidth": 0,
243
+ "ticks": ""
244
+ },
245
+ "colorscale": [
246
+ [
247
+ 0.0,
248
+ "#0d0887"
249
+ ],
250
+ [
251
+ 0.1111111111111111,
252
+ "#46039f"
253
+ ],
254
+ [
255
+ 0.2222222222222222,
256
+ "#7201a8"
257
+ ],
258
+ [
259
+ 0.3333333333333333,
260
+ "#9c179e"
261
+ ],
262
+ [
263
+ 0.4444444444444444,
264
+ "#bd3786"
265
+ ],
266
+ [
267
+ 0.5555555555555556,
268
+ "#d8576b"
269
+ ],
270
+ [
271
+ 0.6666666666666666,
272
+ "#ed7953"
273
+ ],
274
+ [
275
+ 0.7777777777777778,
276
+ "#fb9f3a"
277
+ ],
278
+ [
279
+ 0.8888888888888888,
280
+ "#fdca26"
281
+ ],
282
+ [
283
+ 1.0,
284
+ "#f0f921"
285
+ ]
286
+ ],
287
+ "type": "histogram2dcontour"
288
+ }
289
+ ],
290
+ "histogram2d": [
291
+ {
292
+ "colorbar": {
293
+ "outlinewidth": 0,
294
+ "ticks": ""
295
+ },
296
+ "colorscale": [
297
+ [
298
+ 0.0,
299
+ "#0d0887"
300
+ ],
301
+ [
302
+ 0.1111111111111111,
303
+ "#46039f"
304
+ ],
305
+ [
306
+ 0.2222222222222222,
307
+ "#7201a8"
308
+ ],
309
+ [
310
+ 0.3333333333333333,
311
+ "#9c179e"
312
+ ],
313
+ [
314
+ 0.4444444444444444,
315
+ "#bd3786"
316
+ ],
317
+ [
318
+ 0.5555555555555556,
319
+ "#d8576b"
320
+ ],
321
+ [
322
+ 0.6666666666666666,
323
+ "#ed7953"
324
+ ],
325
+ [
326
+ 0.7777777777777778,
327
+ "#fb9f3a"
328
+ ],
329
+ [
330
+ 0.8888888888888888,
331
+ "#fdca26"
332
+ ],
333
+ [
334
+ 1.0,
335
+ "#f0f921"
336
+ ]
337
+ ],
338
+ "type": "histogram2d"
339
+ }
340
+ ],
341
+ "histogram": [
342
+ {
343
+ "marker": {
344
+ "pattern": {
345
+ "fillmode": "overlay",
346
+ "size": 10,
347
+ "solidity": 0.2
348
+ }
349
+ },
350
+ "type": "histogram"
351
+ }
352
+ ],
353
+ "mesh3d": [
354
+ {
355
+ "colorbar": {
356
+ "outlinewidth": 0,
357
+ "ticks": ""
358
+ },
359
+ "type": "mesh3d"
360
+ }
361
+ ],
362
+ "parcoords": [
363
+ {
364
+ "line": {
365
+ "colorbar": {
366
+ "outlinewidth": 0,
367
+ "ticks": ""
368
+ }
369
+ },
370
+ "type": "parcoords"
371
+ }
372
+ ],
373
+ "pie": [
374
+ {
375
+ "automargin": true,
376
+ "type": "pie"
377
+ }
378
+ ],
379
+ "scatter3d": [
380
+ {
381
+ "line": {
382
+ "colorbar": {
383
+ "outlinewidth": 0,
384
+ "ticks": ""
385
+ }
386
+ },
387
+ "marker": {
388
+ "colorbar": {
389
+ "outlinewidth": 0,
390
+ "ticks": ""
391
+ }
392
+ },
393
+ "type": "scatter3d"
394
+ }
395
+ ],
396
+ "scattercarpet": [
397
+ {
398
+ "marker": {
399
+ "colorbar": {
400
+ "outlinewidth": 0,
401
+ "ticks": ""
402
+ }
403
+ },
404
+ "type": "scattercarpet"
405
+ }
406
+ ],
407
+ "scattergeo": [
408
+ {
409
+ "marker": {
410
+ "colorbar": {
411
+ "outlinewidth": 0,
412
+ "ticks": ""
413
+ }
414
+ },
415
+ "type": "scattergeo"
416
+ }
417
+ ],
418
+ "scattergl": [
419
+ {
420
+ "marker": {
421
+ "colorbar": {
422
+ "outlinewidth": 0,
423
+ "ticks": ""
424
+ }
425
+ },
426
+ "type": "scattergl"
427
+ }
428
+ ],
429
+ "scattermapbox": [
430
+ {
431
+ "marker": {
432
+ "colorbar": {
433
+ "outlinewidth": 0,
434
+ "ticks": ""
435
+ }
436
+ },
437
+ "type": "scattermapbox"
438
+ }
439
+ ],
440
+ "scatterpolargl": [
441
+ {
442
+ "marker": {
443
+ "colorbar": {
444
+ "outlinewidth": 0,
445
+ "ticks": ""
446
+ }
447
+ },
448
+ "type": "scatterpolargl"
449
+ }
450
+ ],
451
+ "scatterpolar": [
452
+ {
453
+ "marker": {
454
+ "colorbar": {
455
+ "outlinewidth": 0,
456
+ "ticks": ""
457
+ }
458
+ },
459
+ "type": "scatterpolar"
460
+ }
461
+ ],
462
+ "scatter": [
463
+ {
464
+ "fillpattern": {
465
+ "fillmode": "overlay",
466
+ "size": 10,
467
+ "solidity": 0.2
468
+ },
469
+ "type": "scatter"
470
+ }
471
+ ],
472
+ "scatterternary": [
473
+ {
474
+ "marker": {
475
+ "colorbar": {
476
+ "outlinewidth": 0,
477
+ "ticks": ""
478
+ }
479
+ },
480
+ "type": "scatterternary"
481
+ }
482
+ ],
483
+ "surface": [
484
+ {
485
+ "colorbar": {
486
+ "outlinewidth": 0,
487
+ "ticks": ""
488
+ },
489
+ "colorscale": [
490
+ [
491
+ 0.0,
492
+ "#0d0887"
493
+ ],
494
+ [
495
+ 0.1111111111111111,
496
+ "#46039f"
497
+ ],
498
+ [
499
+ 0.2222222222222222,
500
+ "#7201a8"
501
+ ],
502
+ [
503
+ 0.3333333333333333,
504
+ "#9c179e"
505
+ ],
506
+ [
507
+ 0.4444444444444444,
508
+ "#bd3786"
509
+ ],
510
+ [
511
+ 0.5555555555555556,
512
+ "#d8576b"
513
+ ],
514
+ [
515
+ 0.6666666666666666,
516
+ "#ed7953"
517
+ ],
518
+ [
519
+ 0.7777777777777778,
520
+ "#fb9f3a"
521
+ ],
522
+ [
523
+ 0.8888888888888888,
524
+ "#fdca26"
525
+ ],
526
+ [
527
+ 1.0,
528
+ "#f0f921"
529
+ ]
530
+ ],
531
+ "type": "surface"
532
+ }
533
+ ],
534
+ "table": [
535
+ {
536
+ "cells": {
537
+ "fill": {
538
+ "color": "#EBF0F8"
539
+ },
540
+ "line": {
541
+ "color": "white"
542
+ }
543
+ },
544
+ "header": {
545
+ "fill": {
546
+ "color": "#C8D4E3"
547
+ },
548
+ "line": {
549
+ "color": "white"
550
+ }
551
+ },
552
+ "type": "table"
553
+ }
554
+ ],
555
+ "candlestick": [
556
+ {
557
+ "decreasing": {
558
+ "fillcolor": "#c80000",
559
+ "line": {
560
+ "color": "#990000"
561
+ }
562
+ },
563
+ "increasing": {
564
+ "fillcolor": "#009600",
565
+ "line": {
566
+ "color": "#007500"
567
+ }
568
+ },
569
+ "type": "candlestick"
570
+ }
571
+ ]
572
+ },
573
+ "layout": {
574
+ "annotationdefaults": {
575
+ "arrowcolor": "#2a3f5f",
576
+ "arrowhead": 0,
577
+ "arrowwidth": 1,
578
+ "showarrow": false
579
+ },
580
+ "autotypenumbers": "strict",
581
+ "coloraxis": {
582
+ "colorbar": {
583
+ "outlinewidth": 0,
584
+ "ticks": ""
585
+ }
586
+ },
587
+ "colorscale": {
588
+ "diverging": [
589
+ [
590
+ 0,
591
+ "#8e0152"
592
+ ],
593
+ [
594
+ 0.1,
595
+ "#c51b7d"
596
+ ],
597
+ [
598
+ 0.2,
599
+ "#de77ae"
600
+ ],
601
+ [
602
+ 0.3,
603
+ "#f1b6da"
604
+ ],
605
+ [
606
+ 0.4,
607
+ "#fde0ef"
608
+ ],
609
+ [
610
+ 0.5,
611
+ "#f7f7f7"
612
+ ],
613
+ [
614
+ 0.6,
615
+ "#e6f5d0"
616
+ ],
617
+ [
618
+ 0.7,
619
+ "#b8e186"
620
+ ],
621
+ [
622
+ 0.8,
623
+ "#7fbc41"
624
+ ],
625
+ [
626
+ 0.9,
627
+ "#4d9221"
628
+ ],
629
+ [
630
+ 1,
631
+ "#276419"
632
+ ]
633
+ ],
634
+ "sequential": [
635
+ [
636
+ 0.0,
637
+ "#0d0887"
638
+ ],
639
+ [
640
+ 0.1111111111111111,
641
+ "#46039f"
642
+ ],
643
+ [
644
+ 0.2222222222222222,
645
+ "#7201a8"
646
+ ],
647
+ [
648
+ 0.3333333333333333,
649
+ "#9c179e"
650
+ ],
651
+ [
652
+ 0.4444444444444444,
653
+ "#bd3786"
654
+ ],
655
+ [
656
+ 0.5555555555555556,
657
+ "#d8576b"
658
+ ],
659
+ [
660
+ 0.6666666666666666,
661
+ "#ed7953"
662
+ ],
663
+ [
664
+ 0.7777777777777778,
665
+ "#fb9f3a"
666
+ ],
667
+ [
668
+ 0.8888888888888888,
669
+ "#fdca26"
670
+ ],
671
+ [
672
+ 1.0,
673
+ "#f0f921"
674
+ ]
675
+ ],
676
+ "sequentialminus": [
677
+ [
678
+ 0.0,
679
+ "#0d0887"
680
+ ],
681
+ [
682
+ 0.1111111111111111,
683
+ "#46039f"
684
+ ],
685
+ [
686
+ 0.2222222222222222,
687
+ "#7201a8"
688
+ ],
689
+ [
690
+ 0.3333333333333333,
691
+ "#9c179e"
692
+ ],
693
+ [
694
+ 0.4444444444444444,
695
+ "#bd3786"
696
+ ],
697
+ [
698
+ 0.5555555555555556,
699
+ "#d8576b"
700
+ ],
701
+ [
702
+ 0.6666666666666666,
703
+ "#ed7953"
704
+ ],
705
+ [
706
+ 0.7777777777777778,
707
+ "#fb9f3a"
708
+ ],
709
+ [
710
+ 0.8888888888888888,
711
+ "#fdca26"
712
+ ],
713
+ [
714
+ 1.0,
715
+ "#f0f921"
716
+ ]
717
+ ]
718
+ },
719
+ "colorway": [
720
+ "#254495",
721
+ "#c13246",
722
+ "#48277c",
723
+ "#e4003a",
724
+ "#ef7d00",
725
+ "#822661",
726
+ "#ffed00",
727
+ "#00aaff",
728
+ "#9b30d9",
729
+ "#af005f",
730
+ "#5f00af",
731
+ "#af87ff"
732
+ ],
733
+ "font": {
734
+ "color": "#2a3f5f"
735
+ },
736
+ "geo": {
737
+ "bgcolor": "white",
738
+ "lakecolor": "white",
739
+ "landcolor": "white",
740
+ "showlakes": true,
741
+ "showland": true,
742
+ "subunitcolor": "#C8D4E3"
743
+ },
744
+ "hoverlabel": {
745
+ "align": "left"
746
+ },
747
+ "hovermode": "x",
748
+ "mapbox": {
749
+ "style": "light"
750
+ },
751
+ "paper_bgcolor": "white",
752
+ "plot_bgcolor": "white",
753
+ "polar": {
754
+ "angularaxis": {
755
+ "gridcolor": "#EBF0F8",
756
+ "linecolor": "#EBF0F8",
757
+ "ticks": ""
758
+ },
759
+ "bgcolor": "white",
760
+ "radialaxis": {
761
+ "gridcolor": "#EBF0F8",
762
+ "linecolor": "#EBF0F8",
763
+ "ticks": ""
764
+ }
765
+ },
766
+ "scene": {
767
+ "xaxis": {
768
+ "backgroundcolor": "white",
769
+ "gridcolor": "#DFE8F3",
770
+ "gridwidth": 2,
771
+ "linecolor": "#EBF0F8",
772
+ "showbackground": true,
773
+ "ticks": "",
774
+ "zerolinecolor": "#EBF0F8"
775
+ },
776
+ "yaxis": {
777
+ "backgroundcolor": "white",
778
+ "gridcolor": "#DFE8F3",
779
+ "gridwidth": 2,
780
+ "linecolor": "#EBF0F8",
781
+ "showbackground": true,
782
+ "ticks": "",
783
+ "zerolinecolor": "#EBF0F8"
784
+ },
785
+ "zaxis": {
786
+ "backgroundcolor": "white",
787
+ "gridcolor": "#DFE8F3",
788
+ "gridwidth": 2,
789
+ "linecolor": "#EBF0F8",
790
+ "showbackground": true,
791
+ "ticks": "",
792
+ "zerolinecolor": "#EBF0F8"
793
+ }
794
+ },
795
+ "shapedefaults": {
796
+ "line": {
797
+ "color": "#2a3f5f"
798
+ }
799
+ },
800
+ "ternary": {
801
+ "aaxis": {
802
+ "gridcolor": "#DFE8F3",
803
+ "linecolor": "#A2B1C6",
804
+ "ticks": ""
805
+ },
806
+ "baxis": {
807
+ "gridcolor": "#DFE8F3",
808
+ "linecolor": "#A2B1C6",
809
+ "ticks": ""
810
+ },
811
+ "bgcolor": "white",
812
+ "caxis": {
813
+ "gridcolor": "#DFE8F3",
814
+ "linecolor": "#A2B1C6",
815
+ "ticks": ""
816
+ }
817
+ },
818
+ "title": {
819
+ "x": 0.05
820
+ },
821
+ "xaxis": {
822
+ "automargin": true,
823
+ "ticks": "",
824
+ "zerolinewidth": 2,
825
+ "rangeslider": {
826
+ "visible": false
827
+ },
828
+ "showgrid": true,
829
+ "showline": true,
830
+ "tickfont": {
831
+ "size": 15
832
+ },
833
+ "mirror": true,
834
+ "zeroline": false
835
+ },
836
+ "yaxis": {
837
+ "automargin": true,
838
+ "ticks": "",
839
+ "tickfont": {
840
+ "size": 15
841
+ },
842
+ "zerolinewidth": 2,
843
+ "fixedrange": false,
844
+ "showgrid": true,
845
+ "showline": true,
846
+ "side": "right",
847
+ "mirror": true,
848
+ "zeroline": false
849
+ },
850
+ "dragmode": "pan",
851
+ "legend": {
852
+ "bgcolor": "rgba(0, 0, 0, 0)",
853
+ "x": 1.1,
854
+ "xanchor": "left",
855
+ "y": 0.99,
856
+ "yanchor": "top"
857
+ },
858
+ "legend2": {
859
+ "bgcolor": "rgba(0, 0, 0, 0)"
860
+ },
861
+ "legend3": {
862
+ "bgcolor": "rgba(0, 0, 0, 0)"
863
+ },
864
+ "legend4": {
865
+ "bgcolor": "rgba(0, 0, 0, 0)"
866
+ },
867
+ "legend5": {
868
+ "bgcolor": "rgba(0, 0, 0, 0)"
869
+ }
870
+ }
871
+ }
cli/openbb_cli/assets/styles/default/light.richstyle.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "info": "rgb(224,131,48)",
3
+ "cmds": "rgb(70,156,222)",
4
+ "param": "rgb(247,206,70)",
5
+ "menu": "rgb(50,115,185)",
6
+ "src": "rgb(216,90,64)",
7
+ "unvl": "grey30",
8
+ "help": "#FAC900"
9
+ }
cli/openbb_cli/assets/styles/default/tables.pltstyle.json ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "data": {
3
+ "candlestick": [
4
+ {
5
+ "decreasing": {
6
+ "fillcolor": "#e4003a",
7
+ "line": {
8
+ "color": "#e4003a"
9
+ }
10
+ },
11
+ "increasing": {
12
+ "fillcolor": "#00ACFF",
13
+ "line": {
14
+ "color": "#00ACFF"
15
+ }
16
+ },
17
+ "type": "candlestick"
18
+ }
19
+ ]
20
+ },
21
+ "layout": {
22
+ "annotationdefaults": {
23
+ "showarrow": false
24
+ },
25
+ "autotypenumbers": "strict",
26
+ "colorway": [
27
+ "#ffed00",
28
+ "#ef7d00",
29
+ "#e4003a",
30
+ "#c13246",
31
+ "#822661",
32
+ "#48277c",
33
+ "#005ca9",
34
+ "#00aaff",
35
+ "#9b30d9",
36
+ "#af005f",
37
+ "#5f00af",
38
+ "#af87ff"
39
+ ],
40
+ "dragmode": "pan",
41
+ "font": {
42
+ "family": "Fira Code",
43
+ "size": 18
44
+ },
45
+ "hoverlabel": {
46
+ "align": "left"
47
+ },
48
+ "mapbox": {
49
+ "style": "light"
50
+ },
51
+ "hovermode": "x",
52
+ "legend": {
53
+ "bgcolor": "rgba(0, 0, 0, 0)",
54
+ "x": 0.01,
55
+ "xanchor": "left",
56
+ "y": 0.99,
57
+ "yanchor": "top",
58
+ "font": {
59
+ "size": 15
60
+ }
61
+ },
62
+ "paper_bgcolor": "white",
63
+ "plot_bgcolor": "white",
64
+ "xaxis": {
65
+ "automargin": true,
66
+ "autorange": true,
67
+ "rangeslider": {
68
+ "visible": false
69
+ },
70
+ "showgrid": true,
71
+ "showline": true,
72
+ "tickfont": {
73
+ "size": 14
74
+ },
75
+ "zeroline": false,
76
+ "tick0": 1,
77
+ "title": {
78
+ "standoff": 20
79
+ },
80
+ "linecolor": "#F5EFF3",
81
+ "mirror": true,
82
+ "ticks": "outside"
83
+ },
84
+ "yaxis": {
85
+ "anchor": "x",
86
+ "automargin": true,
87
+ "fixedrange": false,
88
+ "zeroline": false,
89
+ "showgrid": true,
90
+ "showline": true,
91
+ "side": "right",
92
+ "tick0": 0.5,
93
+ "title": {
94
+ "standoff": 20
95
+ },
96
+ "gridcolor": "#283442",
97
+ "linecolor": "#F5EFF3",
98
+ "mirror": true,
99
+ "ticks": "outside"
100
+ }
101
+ }
102
+ }
cli/openbb_cli/assets/styles/user/openbb.richstyle.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "info": "rgb(224,131,48)",
3
+ "cmds": "#2A7C6E",
4
+ "param": "rgb(247,206,70)",
5
+ "menu": "#427A2E",
6
+ "src": "rgb(216,90,64)",
7
+ "unvl": "grey30",
8
+ "help": "#FAC900"
9
+ }
cli/openbb_cli/cli.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenBB Platform CLI entry point."""
2
+
3
+ import sys
4
+
5
+ from openbb_cli.utils.utils import change_logging_sub_app, reset_logging_sub_app
6
+
7
+
8
+ def main():
9
+ """Use the main entry point for the OpenBB Platform CLI."""
10
+ print("Loading...\n") # noqa: T201
11
+
12
+ # pylint: disable=import-outside-toplevel
13
+ from openbb_cli.config.setup import bootstrap
14
+ from openbb_cli.controllers.cli_controller import launch
15
+
16
+ bootstrap()
17
+
18
+ dev = "--dev" in sys.argv[1:]
19
+ debug = "--debug" in sys.argv[1:]
20
+
21
+ launch(dev, debug)
22
+
23
+
24
+ if __name__ == "__main__":
25
+ initial_logging_sub_app = change_logging_sub_app()
26
+ try:
27
+ main()
28
+ except Exception:
29
+ pass
30
+ finally:
31
+ reset_logging_sub_app(initial_logging_sub_app)
cli/openbb_cli/config/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Core config init."""
cli/openbb_cli/config/completer.py ADDED
@@ -0,0 +1,427 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Nested completer for completion of OpenBB hierarchical data structures."""
2
+
3
+ from typing import (
4
+ Any,
5
+ Callable,
6
+ Dict,
7
+ Iterable,
8
+ List,
9
+ Mapping,
10
+ Optional,
11
+ Pattern,
12
+ Set,
13
+ Union,
14
+ )
15
+
16
+ from prompt_toolkit.completion import CompleteEvent, Completer, Completion
17
+ from prompt_toolkit.document import Document
18
+ from prompt_toolkit.formatted_text import AnyFormattedText
19
+ from prompt_toolkit.history import FileHistory
20
+
21
+ NestedDict = Mapping[str, Union[Any, Set[str], None, Completer]]
22
+
23
+ # pylint: disable=too-many-arguments,global-statement,too-many-branches,global-variable-not-assigned
24
+
25
+
26
+ class WordCompleter(Completer):
27
+ """Simple autocompletion on a list of words.
28
+
29
+ :param words: List of words or callable that returns a list of words.
30
+ :param ignore_case: If True, case-insensitive completion.
31
+ :param meta_dict: Optional dict mapping words to their meta-text. (This
32
+ should map strings to strings or formatted text.)
33
+ :param WORD: When True, use WORD characters.
34
+ :param sentence: When True, don't complete by comparing the word before the
35
+ cursor, but by comparing all the text before the cursor. In this case,
36
+ the list of words is just a list of strings, where each string can
37
+ contain spaces. (Can not be used together with the WORD option.)
38
+ :param match_middle: When True, match not only the start, but also in the
39
+ middle of the word.
40
+ :param pattern: Optional compiled regex for finding the word before
41
+ the cursor to complete. When given, use this regex pattern instead of
42
+ default one (see document._FIND_WORD_RE)
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ words: Union[List[str], Callable[[], List[str]]],
48
+ ignore_case: bool = False,
49
+ display_dict: Optional[Mapping[str, AnyFormattedText]] = None,
50
+ meta_dict: Optional[Mapping[str, AnyFormattedText]] = None,
51
+ WORD: bool = True,
52
+ sentence: bool = False,
53
+ match_middle: bool = False,
54
+ pattern: Optional[Pattern[str]] = None,
55
+ ) -> None:
56
+ """Initialize the WordCompleter."""
57
+ assert not (WORD and sentence) # noqa: S101
58
+
59
+ self.words = words
60
+ self.ignore_case = ignore_case
61
+ self.display_dict = display_dict or {}
62
+ self.meta_dict = meta_dict or {}
63
+ self.WORD = WORD
64
+ self.sentence = sentence
65
+ self.match_middle = match_middle
66
+ self.pattern = pattern
67
+
68
+ def get_completions(
69
+ self,
70
+ document: Document,
71
+ _complete_event: CompleteEvent,
72
+ ) -> Iterable[Completion]:
73
+ """Get completions."""
74
+ # Get list of words.
75
+ words = self.words
76
+ if callable(words):
77
+ words = words()
78
+
79
+ # Get word/text before cursor.
80
+ if self.sentence:
81
+ word_before_cursor = document.text_before_cursor
82
+ else:
83
+ word_before_cursor = document.get_word_before_cursor(
84
+ WORD=self.WORD, pattern=self.pattern
85
+ )
86
+ if (
87
+ "--" in document.text_before_cursor
88
+ and document.text_before_cursor.rfind(" --")
89
+ >= document.text_before_cursor.rfind(" -")
90
+ ):
91
+ word_before_cursor = f'--{document.text_before_cursor.split("--")[-1]}'
92
+ elif f"--{word_before_cursor}" == document.text_before_cursor:
93
+ word_before_cursor = document.text_before_cursor
94
+
95
+ if self.ignore_case:
96
+ word_before_cursor = word_before_cursor.lower()
97
+
98
+ def word_matches(word: str) -> bool:
99
+ """Set True when the word before the cursor matches."""
100
+ if self.ignore_case:
101
+ word = word.lower()
102
+
103
+ if self.match_middle:
104
+ return word_before_cursor in word
105
+ return word.startswith(word_before_cursor)
106
+
107
+ for a in words:
108
+ if word_matches(a):
109
+ display = self.display_dict.get(a, a)
110
+ display_meta = self.meta_dict.get(a, "")
111
+ yield Completion(
112
+ text=a,
113
+ start_position=-len(word_before_cursor),
114
+ display=display,
115
+ display_meta=display_meta,
116
+ )
117
+
118
+
119
+ class NestedCompleter(Completer):
120
+ """Completer which wraps around several other completers, and calls any the
121
+ one that corresponds with the first word of the input.
122
+
123
+ By combining multiple `NestedCompleter` instances, we can achieve multiple
124
+ hierarchical levels of autocompletion. This is useful when `WordCompleter`
125
+ is not sufficient.
126
+
127
+ If you need multiple levels, check out the `from_nested_dict` classmethod.
128
+ """
129
+
130
+ complementary: List = list()
131
+
132
+ def __init__(
133
+ self, options: Dict[str, Optional[Completer]], ignore_case: bool = True
134
+ ) -> None:
135
+ """Initialize the NestedCompleter."""
136
+ self.flags_processed: List = list()
137
+ self.original_options = options
138
+ self.options = options
139
+ self.ignore_case = ignore_case
140
+ self.complementary = list()
141
+
142
+ def __repr__(self) -> str:
143
+ """Return string representation of NestedCompleter."""
144
+ return f"NestedCompleter({self.options!r}, ignore_case={self.ignore_case!r})"
145
+
146
+ @classmethod
147
+ def from_nested_dict(cls, data: dict) -> "NestedCompleter":
148
+ """Create a `NestedCompleter`.
149
+
150
+ It starts from a nested dictionary data structure, like this:
151
+
152
+ .. code::
153
+
154
+ data = {
155
+ 'show': {
156
+ 'version': None,
157
+ 'interfaces': None,
158
+ 'clock': None,
159
+ 'ip': {'interface': {'brief'}}
160
+ },
161
+ 'exit': None
162
+ 'enable': None
163
+ }
164
+
165
+ The value should be `None` if there is no further completion at some
166
+ point. If all values in the dictionary are None, it is also possible to
167
+ use a set instead.
168
+
169
+ Values in this data structure can be a completers as well.
170
+ """
171
+ options: Dict[str, Any] = {}
172
+ for key, value in data.items():
173
+ if isinstance(value, Completer):
174
+ options[key] = value
175
+ elif isinstance(value, dict):
176
+ options[key] = cls.from_nested_dict(value)
177
+ elif isinstance(value, set):
178
+ options[key] = cls.from_nested_dict({item: None for item in value})
179
+ elif isinstance(key, str) and isinstance(value, str):
180
+ options[key] = options[value]
181
+ else:
182
+ assert value is None # noqa: S101
183
+ options[key] = None
184
+
185
+ for items in cls.complementary:
186
+ if items[0] in options:
187
+ options[items[1]] = options[items[0]]
188
+ elif items[1] in options:
189
+ options[items[0]] = options[items[1]]
190
+
191
+ return cls(options)
192
+
193
+ def get_completions( # noqa: PLR0912
194
+ self, document: Document, complete_event: CompleteEvent
195
+ ) -> Iterable[Completion]:
196
+ """Get completions."""
197
+ # Split document.
198
+ cmd = ""
199
+ text = document.text_before_cursor.lstrip()
200
+ if " " in text:
201
+ cmd = text.split(" ")[0]
202
+ if "-" in text:
203
+ if text.rfind("--") == -1 or text.rfind("-") - 1 > text.rfind("--"):
204
+ unprocessed_text = "-" + text.split("-")[-1]
205
+ else:
206
+ unprocessed_text = "--" + text.split("--")[-1]
207
+ else:
208
+ unprocessed_text = text
209
+ stripped_len = len(document.text_before_cursor) - len(text)
210
+
211
+ # Check if there are multiple flags for the same command
212
+ if self.complementary:
213
+ for same_flags in self.complementary:
214
+ if (
215
+ same_flags[0] in self.flags_processed
216
+ and same_flags[1] not in self.flags_processed
217
+ ) or (
218
+ same_flags[1] in self.flags_processed
219
+ and same_flags[0] not in self.flags_processed
220
+ ):
221
+ if same_flags[0] in self.flags_processed:
222
+ self.flags_processed.append(same_flags[1])
223
+ elif same_flags[1] in self.flags_processed:
224
+ self.flags_processed.append(same_flags[0])
225
+
226
+ if cmd:
227
+ self.options = {
228
+ k: self.original_options.get(cmd).options[k] # type: ignore
229
+ for k in self.original_options.get(cmd).options # type: ignore
230
+ if k not in self.flags_processed
231
+ }
232
+ else:
233
+ self.options = {
234
+ k: self.original_options[k]
235
+ for k in self.original_options
236
+ if k not in self.flags_processed
237
+ }
238
+
239
+ # If there is a space, check for the first term, and use a subcompleter.
240
+ if " " in unprocessed_text:
241
+ first_term = unprocessed_text.split()[0]
242
+
243
+ # user is updating one of the values
244
+ if unprocessed_text[-1] != " ":
245
+ self.flags_processed = [
246
+ flag for flag in self.flags_processed if flag != first_term
247
+ ]
248
+
249
+ if self.complementary:
250
+ for same_flags in self.complementary:
251
+ if (
252
+ same_flags[0] in self.flags_processed
253
+ and same_flags[1] not in self.flags_processed
254
+ ) or (
255
+ same_flags[1] in self.flags_processed
256
+ and same_flags[0] not in self.flags_processed
257
+ ):
258
+ if same_flags[0] in self.flags_processed:
259
+ self.flags_processed.remove(same_flags[0])
260
+ elif same_flags[1] in self.flags_processed:
261
+ self.flags_processed.remove(same_flags[1])
262
+
263
+ if cmd and self.original_options.get(cmd):
264
+ self.options = self.original_options
265
+ else:
266
+ self.options = {
267
+ k: self.original_options[k]
268
+ for k in self.original_options
269
+ if k not in self.flags_processed
270
+ }
271
+
272
+ if "-" not in text:
273
+ completer = self.options.get(first_term)
274
+ elif cmd in self.options and self.options.get(cmd):
275
+ completer = self.options.get(cmd).options.get(first_term) # type: ignore
276
+ else:
277
+ completer = self.options.get(first_term)
278
+
279
+ # If we have a sub completer, use this for the completions.
280
+ if completer is not None:
281
+ remaining_text = unprocessed_text[len(first_term) :].lstrip()
282
+ move_cursor = len(text) - len(remaining_text) + stripped_len
283
+
284
+ new_document = Document(
285
+ remaining_text,
286
+ cursor_position=document.cursor_position - move_cursor,
287
+ )
288
+
289
+ # Provides auto-completion but if user doesn't take it still keep going
290
+ if " " in new_document.text:
291
+ if (
292
+ new_document.text in [f"{opt} " for opt in self.options]
293
+ or unprocessed_text[-1] == " "
294
+ ):
295
+ self.flags_processed.append(first_term)
296
+ if cmd:
297
+ self.options = {
298
+ k: self.original_options.get(cmd).options[k] # type: ignore
299
+ for k in self.original_options.get(cmd).options # type: ignore
300
+ if k not in self.flags_processed
301
+ }
302
+ else:
303
+ self.options = {
304
+ k: self.original_options[k]
305
+ for k in self.original_options
306
+ if k not in self.flags_processed
307
+ }
308
+
309
+ # In case the users inputs a single boolean flag
310
+ elif not completer.options: # type: ignore
311
+ self.flags_processed.append(first_term)
312
+
313
+ if self.complementary:
314
+ for same_flags in self.complementary:
315
+ if (
316
+ same_flags[0] in self.flags_processed
317
+ and same_flags[1] not in self.flags_processed
318
+ ) or (
319
+ same_flags[1] in self.flags_processed
320
+ and same_flags[0] not in self.flags_processed
321
+ ):
322
+ if same_flags[0] in self.flags_processed:
323
+ self.flags_processed.append(same_flags[1])
324
+ elif same_flags[1] in self.flags_processed:
325
+ self.flags_processed.append(same_flags[0])
326
+
327
+ if cmd:
328
+ self.options = {
329
+ k: self.original_options.get(cmd).options[k] # type: ignore
330
+ for k in self.original_options.get(cmd).options # type: ignore
331
+ if k not in self.flags_processed
332
+ }
333
+ else:
334
+ self.options = {
335
+ k: self.original_options[k]
336
+ for k in self.original_options
337
+ if k not in self.flags_processed
338
+ }
339
+
340
+ else:
341
+ # This is a NestedCompleter
342
+ yield from completer.get_completions(new_document, complete_event)
343
+
344
+ # No space in the input: behave exactly like `WordCompleter`.
345
+ else:
346
+ # check if the prompt has been updated in the meantime
347
+ if " " in text or "-" in text:
348
+ actual_flags_processed = [
349
+ flag for flag in self.flags_processed if flag in text
350
+ ]
351
+
352
+ if self.complementary:
353
+ for same_flags in self.complementary:
354
+ if (
355
+ same_flags[0] in actual_flags_processed
356
+ and same_flags[1] not in actual_flags_processed
357
+ ) or (
358
+ same_flags[1] in actual_flags_processed
359
+ and same_flags[0] not in actual_flags_processed
360
+ ):
361
+ if same_flags[0] in actual_flags_processed:
362
+ actual_flags_processed.append(same_flags[1])
363
+ elif same_flags[1] in actual_flags_processed:
364
+ actual_flags_processed.append(same_flags[0])
365
+
366
+ if len(actual_flags_processed) < len(self.flags_processed):
367
+ self.flags_processed = actual_flags_processed
368
+ if cmd:
369
+ self.options = {
370
+ k: self.original_options.get(cmd).options[k] # type: ignore
371
+ for k in self.original_options.get(cmd).options # type: ignore
372
+ if k not in self.flags_processed
373
+ }
374
+ else:
375
+ self.options = {
376
+ k: self.original_options[k]
377
+ for k in self.original_options
378
+ if k not in self.flags_processed
379
+ }
380
+
381
+ command = self.options.get(cmd)
382
+ options = command.options if command else {} # type: ignore
383
+ command_options = [f"{cmd} {opt}" for opt in options]
384
+ text_list = [text in val for val in command_options]
385
+ if cmd and cmd in self.options and text_list:
386
+ completer = WordCompleter(
387
+ list(self.options.get(cmd).options.keys()), # type: ignore
388
+ ignore_case=self.ignore_case,
389
+ )
390
+ elif bool([val for val in self.options if text in val]):
391
+ completer = WordCompleter(
392
+ list(self.options.keys()), ignore_case=self.ignore_case
393
+ )
394
+ else:
395
+ # The user has delete part of the first command and we need to reset options
396
+ if bool([val for val in self.original_options if text in val]):
397
+ self.options = self.original_options
398
+ self.flags_processed = list()
399
+ completer = WordCompleter(
400
+ list(self.options.keys()), ignore_case=self.ignore_case
401
+ )
402
+
403
+ # This is a WordCompleter
404
+ yield from completer.get_completions(document, complete_event)
405
+
406
+
407
+ class CustomFileHistory(FileHistory):
408
+ """Filtered file history."""
409
+
410
+ def sanitize_input(self, string: str) -> str:
411
+ """Sanitize sensitive information from the input string by parsing arguments."""
412
+ keywords = ["--password", "--email", "--pat"]
413
+ string_list = string.split(" ")
414
+
415
+ for kw in keywords:
416
+ if kw in string_list:
417
+ index = string_list.index(kw)
418
+ if len(string_list) > index + 1:
419
+ string_list[index + 1] = "********"
420
+
421
+ result = " ".join(string_list)
422
+ return result
423
+
424
+ def store_string(self, string: str) -> None:
425
+ """Store string in history."""
426
+ string = self.sanitize_input(string)
427
+ super().store_string(string)
cli/openbb_cli/config/console.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple
2
+
3
+ from rich import panel
4
+ from rich.console import (
5
+ Console as RichConsole,
6
+ Theme,
7
+ )
8
+ from rich.text import Text
9
+
10
+ from openbb_cli.config.menu_text import RICH_TAGS
11
+
12
+ if TYPE_CHECKING:
13
+ from openbb_cli.models.settings import Settings
14
+
15
+
16
+ class Console:
17
+ """Create a rich console to wrap the console print with a Panel."""
18
+
19
+ def __init__(
20
+ self,
21
+ settings: "Settings",
22
+ style: Optional[Dict[str, Any]] = None,
23
+ ):
24
+ """Initialize the ConsoleAndPanel class."""
25
+ self._console = RichConsole(
26
+ theme=Theme(style),
27
+ highlight=False,
28
+ soft_wrap=True,
29
+ )
30
+ self._settings = settings
31
+ self.menu_text = ""
32
+ self.menu_path = ""
33
+
34
+ @staticmethod
35
+ def _filter_rich_tags(text):
36
+ """Filter out rich tags from text."""
37
+ for val in RICH_TAGS:
38
+ text = text.replace(val, "")
39
+
40
+ return text
41
+
42
+ @staticmethod
43
+ def _blend_text(
44
+ message: str, color1: Tuple[int, int, int], color2: Tuple[int, int, int]
45
+ ) -> Text:
46
+ """Blend text from one color to another."""
47
+ text = Text(message)
48
+ r1, g1, b1 = color1
49
+ r2, g2, b2 = color2
50
+ dr = r2 - r1
51
+ dg = g2 - g1
52
+ db = b2 - b1
53
+ size = len(text) + 5
54
+ for index in range(size):
55
+ blend = index / size
56
+ color = f"#{int(r1 + dr * blend):02X}{int(g1 + dg * blend):02X}{int(b1 + db * blend):02X}"
57
+ text.stylize(color, index, index + 1)
58
+ return text
59
+
60
+ def print(self, *args, **kwargs):
61
+ """Print the text to the console."""
62
+ if kwargs and "text" in list(kwargs) and "menu" in list(kwargs):
63
+ if not self._settings.TEST_MODE:
64
+ if self._settings.ENABLE_RICH_PANEL:
65
+ if self._settings.SHOW_VERSION:
66
+ version = self._settings.VERSION
67
+ version = f"[param]OpenBB Platform CLI v{version}[/param] (https://openbb.co)"
68
+ else:
69
+ version = (
70
+ "[param]OpenBB Platform CLI[/param] (https://openbb.co)"
71
+ )
72
+ self._console.print(
73
+ panel.Panel(
74
+ "\n" + kwargs["text"],
75
+ title=kwargs["menu"],
76
+ subtitle_align="right",
77
+ subtitle=version,
78
+ )
79
+ )
80
+
81
+ else:
82
+ self._console.print(kwargs["text"])
83
+ else:
84
+ print(self._filter_rich_tags(kwargs["text"])) # noqa: T201
85
+ elif not self._settings.TEST_MODE:
86
+ self._console.print(*args, **kwargs)
87
+ else:
88
+ print(*args, **kwargs) # noqa: T201
89
+
90
+ def input(self, *args, **kwargs):
91
+ """Get input from the user."""
92
+ self.print(*args, **kwargs, end="")
93
+ return input()
cli/openbb_cli/config/constants.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Constants module."""
2
+
3
+ from pathlib import Path
4
+
5
+ # Paths
6
+ HOME_DIRECTORY = Path.home()
7
+ REPOSITORY_DIRECTORY = Path(__file__).parent.parent.parent.parent
8
+ SRC_DIRECTORY = Path(__file__).parent.parent
9
+ SETTINGS_DIRECTORY = HOME_DIRECTORY / ".openbb_platform"
10
+ ASSETS_DIRECTORY = SRC_DIRECTORY / "assets"
11
+ STYLES_DIRECTORY = ASSETS_DIRECTORY / "styles"
12
+ ENV_FILE_SETTINGS = SETTINGS_DIRECTORY / ".cli.env"
13
+ HIST_FILE_PROMPT = SETTINGS_DIRECTORY / ".cli.his"
14
+
15
+
16
+ DEFAULT_ROUTINES_URL = "https://openbb-cms.directus.app/items/Routines"
17
+ TIMEOUT = 30
18
+ CONNECTION_ERROR_MSG = "[red]Connection error.[/red]"
19
+ CONNECTION_TIMEOUT_MSG = "[red]Connection timeout.[/red]"
20
+ SCRIPT_TAGS = [
21
+ "stocks",
22
+ "crypto",
23
+ "etf",
24
+ "economy",
25
+ "forex",
26
+ "fixed income",
27
+ "alternative",
28
+ "funds",
29
+ "bonds",
30
+ "macro",
31
+ "mutual funds",
32
+ "equities",
33
+ "options",
34
+ "dark pool",
35
+ "shorts",
36
+ "insider",
37
+ "behavioral analysis",
38
+ "fundamental analysis",
39
+ "technical analysis",
40
+ "quantitative analysis",
41
+ "forecasting",
42
+ "government",
43
+ "comparison",
44
+ "nft",
45
+ "on chain",
46
+ "off chain",
47
+ "screener",
48
+ "report",
49
+ "overview",
50
+ "rates",
51
+ "econometrics",
52
+ "portfolio",
53
+ "real estate",
54
+ ]
55
+ AVAILABLE_FLAIRS = {
56
+ ":openbb": "(🦋)",
57
+ ":bug": "(🐛)",
58
+ ":rocket": "(🚀)",
59
+ ":diamond": "(💎)",
60
+ ":stars": "(✨)",
61
+ ":baseball": "(⚾)",
62
+ ":boat": "(⛵)",
63
+ ":phone": "(☎)",
64
+ ":mercury": "(☿)",
65
+ ":hidden": "",
66
+ ":sun": "(☼)",
67
+ ":moon": "(🌕)",
68
+ ":nuke": "(☢)",
69
+ ":hazard": "(☣)",
70
+ ":tunder": "(☈)",
71
+ ":king": "(♔)",
72
+ ":queen": "(♕)",
73
+ ":knight": "(♘)",
74
+ ":recycle": "(♻)",
75
+ ":scales": "(⚖)",
76
+ ":ball": "(⚽)",
77
+ ":golf": "(⛳)",
78
+ ":peace": "(☮)",
79
+ ":yy": "(☯)",
80
+ }
cli/openbb_cli/config/menu_text.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rich Module."""
2
+
3
+ __docformat__ = "numpy"
4
+
5
+ from typing import Dict, List
6
+
7
+ from openbb import obb
8
+
9
+ # https://rich.readthedocs.io/en/stable/appendix/colors.html#appendix-colors
10
+ # https://rich.readthedocs.io/en/latest/highlighting.html#custom-highlighters
11
+
12
+
13
+ RICH_TAGS = [
14
+ "[menu]",
15
+ "[/menu]",
16
+ "[cmds]",
17
+ "[/cmds]",
18
+ "[info]",
19
+ "[/info]",
20
+ "[param]",
21
+ "[/param]",
22
+ "[src]",
23
+ "[/src]",
24
+ "[help]",
25
+ "[/help]",
26
+ ]
27
+
28
+
29
+ class MenuText:
30
+ """Create menu text with rich colors to be displayed by CLI."""
31
+
32
+ CMD_NAME_LENGTH = 23
33
+ CMD_DESCRIPTION_LENGTH = 65
34
+ CMD_PROVIDERS_LENGTH = 23
35
+ SECTION_SPACING = 4
36
+
37
+ def __init__(self, path: str = ""):
38
+ """Initialize menu help."""
39
+ self.menu_text = ""
40
+ self.menu_path = path
41
+ self.warnings: List[Dict[str, str]] = []
42
+
43
+ @staticmethod
44
+ def _get_providers(command_path: str) -> List:
45
+ """Return the preferred provider for the given command.
46
+
47
+ Parameters
48
+ ----------
49
+ command_path: str
50
+ The command to find the provider for. E.g. "/equity/price/historical
51
+
52
+ Returns
53
+ -------
54
+ List
55
+ The list of providers for the given command.
56
+ """
57
+ command_reference = obb.reference.get("paths", {}).get(command_path, {}) # type: ignore
58
+ if command_reference:
59
+ providers = list(command_reference["parameters"].keys())
60
+ return [provider for provider in providers if provider != "standard"]
61
+ return []
62
+
63
+ def _format_cmd_name(self, name: str) -> str:
64
+ """Truncate command name length if it is too long."""
65
+ if len(name) > self.CMD_NAME_LENGTH:
66
+ new_name = name[: self.CMD_NAME_LENGTH]
67
+
68
+ if "_" in name:
69
+ name_split = name.split("_")
70
+
71
+ new_name = (
72
+ "_".join(name_split[:2]) if len(name_split) > 2 else name_split[0]
73
+ )
74
+
75
+ if len(new_name) > self.CMD_NAME_LENGTH:
76
+ new_name = new_name[: self.CMD_NAME_LENGTH]
77
+
78
+ if new_name != name:
79
+ self.warnings.append(
80
+ {
81
+ "warning": "Command name too long",
82
+ "actual command": f"`{name}`",
83
+ "displayed command": f"`{new_name}`",
84
+ }
85
+ )
86
+ name = new_name
87
+
88
+ return name
89
+
90
+ def _format_cmd_description(
91
+ self, name: str, description: str, trim: bool = True
92
+ ) -> str:
93
+ """Truncate command description length if it is too long."""
94
+ if not description or description == f"{self.menu_path}{name}":
95
+ description = ""
96
+ return (
97
+ description[: self.CMD_DESCRIPTION_LENGTH - 3] + "..."
98
+ if len(description) > self.CMD_DESCRIPTION_LENGTH and trim
99
+ else description
100
+ )
101
+
102
+ def add_raw(self, text: str, left_spacing: bool = False):
103
+ """Append raw text (without translation)."""
104
+ if left_spacing:
105
+ self.menu_text += f"{self.SECTION_SPACING * ' '}{text}\n"
106
+ else:
107
+ self.menu_text += text
108
+
109
+ def add_info(self, text: str):
110
+ """Append information text (after translation)."""
111
+ self.menu_text += f"[info]{text}:[/info]\n"
112
+
113
+ def add_cmd(self, name: str, description: str = "", disable: bool = False):
114
+ """Append command text (after translation)."""
115
+ formatted_name = self._format_cmd_name(name)
116
+ name_padding = (self.CMD_NAME_LENGTH - len(formatted_name)) * " "
117
+ providers = self._get_providers(f"{self.menu_path}{name}")
118
+ formatted_description = self._format_cmd_description(
119
+ formatted_name,
120
+ description,
121
+ bool(providers),
122
+ )
123
+ description_padding = (
124
+ self.CMD_DESCRIPTION_LENGTH - len(formatted_description)
125
+ ) * " "
126
+ spacing = self.SECTION_SPACING * " "
127
+ description_padding = (
128
+ self.CMD_DESCRIPTION_LENGTH - len(formatted_description)
129
+ ) * " "
130
+ cmd = f"{spacing}{formatted_name + name_padding}{spacing}{formatted_description+description_padding}"
131
+ cmd = f"[unvl]{cmd}[/unvl]" if disable else f"[cmds]{cmd}[/cmds]"
132
+
133
+ if providers:
134
+ cmd += rf"{spacing}[src]\[{', '.join(providers)}][/src]"
135
+
136
+ self.menu_text += cmd + "\n"
137
+
138
+ def add_menu(
139
+ self,
140
+ name: str,
141
+ description: str = "",
142
+ disable: bool = False,
143
+ ):
144
+ """Append menu text (after translation)."""
145
+ spacing = (self.CMD_NAME_LENGTH - len(name) + self.SECTION_SPACING) * " "
146
+
147
+ if not description or description == f"{self.menu_path}{name}":
148
+ description = ""
149
+
150
+ if len(description) > self.CMD_DESCRIPTION_LENGTH:
151
+ description = description[: self.CMD_DESCRIPTION_LENGTH - 3] + "..."
152
+
153
+ menu = f"{name}{spacing}{description}"
154
+ tag = "unvl" if disable else "menu"
155
+ self.menu_text += f"[{tag}]> {menu}[/{tag}]\n"
156
+
157
+ def add_setting(self, name: str, status: bool = True, description: str = ""):
158
+ """Append menu text (after translation)."""
159
+ spacing = (self.CMD_NAME_LENGTH - len(name) + self.SECTION_SPACING) * " "
160
+ indentation = self.SECTION_SPACING * " "
161
+ color = "green" if status else "red"
162
+
163
+ self.menu_text += (
164
+ f"[{color}]{indentation}{name}{spacing}{description}[/{color}]\n"
165
+ )
cli/openbb_cli/config/setup.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration for the CLI."""
2
+
3
+ from pathlib import Path
4
+
5
+ from openbb_cli.config.constants import ENV_FILE_SETTINGS, SETTINGS_DIRECTORY
6
+
7
+
8
+ def bootstrap():
9
+ """Setup pre-launch configurations for the CLI."""
10
+ SETTINGS_DIRECTORY.mkdir(parents=True, exist_ok=True)
11
+ Path(ENV_FILE_SETTINGS).touch(exist_ok=True)
cli/openbb_cli/config/style.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chart and style helpers for Plotly."""
2
+
3
+ # pylint: disable=C0302,R0902,W3301
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from rich.console import Console
9
+
10
+ from openbb_cli.config.constants import STYLES_DIRECTORY
11
+
12
+ console = Console()
13
+
14
+
15
+ class Style:
16
+ """The class that helps with handling of style configurations.
17
+
18
+ It serves styles for 2 libraries. For `Plotly` this class serves absolute paths
19
+ to the .pltstyle files. For `Plotly` and `Rich` this class serves custom
20
+ styles as python dictionaries.
21
+ """
22
+
23
+ STYLES_REPO = STYLES_DIRECTORY
24
+
25
+ console_styles_available: Dict[str, Path] = {}
26
+ console_style: Dict[str, Any] = {}
27
+
28
+ line_color: str = ""
29
+ up_color: str = ""
30
+ down_color: str = ""
31
+ up_colorway: List[str] = []
32
+ down_colorway: List[str] = []
33
+ up_color_transparent: str = ""
34
+ down_color_transparent: str = ""
35
+
36
+ line_width: float = 1.5
37
+
38
+ def __init__(
39
+ self,
40
+ style: Optional[str] = "",
41
+ directory: Optional[Path] = None,
42
+ ):
43
+ """Initialize the class."""
44
+ self._load(directory)
45
+ self.apply(style, directory)
46
+
47
+ def apply(
48
+ self, style: Optional[str] = None, directory: Optional[Path] = None
49
+ ) -> None:
50
+ """Apply the style to the console."""
51
+ if style:
52
+ if style in self.console_styles_available:
53
+ json_path: Optional[Path] = self.console_styles_available[style]
54
+ else:
55
+ self._load(directory)
56
+ if style in self.console_styles_available:
57
+ json_path = self.console_styles_available[style]
58
+ else:
59
+ console.print(f"\nInvalid console style '{style}', using default.")
60
+ json_path = self.console_styles_available.get("dark", None)
61
+
62
+ if json_path:
63
+ self.console_style = self._from_json(json_path)
64
+ else:
65
+ console.print("Error loading default.")
66
+
67
+ def _from_directory(self, folder: Optional[Path]) -> None:
68
+ """Load custom styles from folder.
69
+
70
+ Parses the styles/default and styles/user folders and loads style files.
71
+ To be recognized files need to follow a naming convention:
72
+ *.pltstyle - plotly stylesheets
73
+ *.richstyle.json - rich stylesheets
74
+
75
+ Parameters
76
+ ----------
77
+ folder : str
78
+ Path to the folder containing the stylesheets
79
+ """
80
+ if not folder or not folder.exists():
81
+ return
82
+
83
+ for attr, ext in zip(
84
+ ["console_styles_available"],
85
+ [".richstyle.json"],
86
+ ):
87
+ for file in folder.rglob(f"*{ext}"):
88
+ getattr(self, attr)[file.name.replace(ext, "")] = file
89
+
90
+ def _load(self, directory: Optional[Path] = None) -> None:
91
+ """Load custom styles from default and user folders."""
92
+ self._from_directory(self.STYLES_REPO)
93
+ self._from_directory(directory)
94
+
95
+ def _from_json(self, file: Path) -> Dict[str, Any]:
96
+ """Load style from json file."""
97
+ with open(file) as f:
98
+ json_style: dict = json.load(f)
99
+ for key, value in json_style.items():
100
+ json_style[key] = value.replace(
101
+ " ", ""
102
+ ) # remove whitespaces so Rich can parse it
103
+ return json_style
104
+
105
+ @property
106
+ def available_styles(self) -> List[str]:
107
+ """Return available styles."""
108
+ return list(self.console_styles_available.keys())
cli/openbb_cli/controllers/base_controller.py ADDED
@@ -0,0 +1,1032 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Base controller for the CLI."""
2
+
3
+ import argparse
4
+ import difflib
5
+ import os
6
+ import re
7
+ import shlex
8
+ from abc import ABCMeta, abstractmethod
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List, Literal, Optional, Tuple, Union
12
+
13
+ import pandas as pd
14
+ from openbb_cli.config.completer import NestedCompleter
15
+ from openbb_cli.config.constants import SCRIPT_TAGS
16
+ from openbb_cli.controllers.choices import build_controller_choice_map
17
+ from openbb_cli.controllers.hub_service import upload_routine
18
+ from openbb_cli.controllers.utils import (
19
+ check_file_type_saved,
20
+ check_positive,
21
+ get_flair_and_username,
22
+ handle_obbject_display,
23
+ parse_and_split_input,
24
+ parse_unknown_args_to_dict,
25
+ print_guest_block_msg,
26
+ print_rich_table,
27
+ remove_file,
28
+ system_clear,
29
+ validate_register_key,
30
+ )
31
+ from openbb_cli.session import Session
32
+ from prompt_toolkit.formatted_text import HTML
33
+ from prompt_toolkit.styles import Style
34
+
35
+ # pylint: disable=C0301,C0302,R0902,global-statement,too-many-boolean-expressions
36
+ # pylint: disable=R0912
37
+
38
+ controllers: Dict[str, Any] = {}
39
+ session = Session()
40
+
41
+
42
+ # TODO: We should try to avoid these global variables
43
+ RECORD_SESSION = False
44
+ RECORD_SESSION_LOCAL_ONLY = False
45
+ SESSION_RECORDED = list()
46
+ SESSION_RECORDED_NAME = ""
47
+ SESSION_RECORDED_DESCRIPTION = ""
48
+ SESSION_RECORDED_TAGS = ""
49
+ SESSION_RECORDED_PUBLIC = False
50
+
51
+
52
+ class BaseController(metaclass=ABCMeta):
53
+ """Base class for a cli controller."""
54
+
55
+ CHOICES_COMMON = [
56
+ "cls",
57
+ "home",
58
+ "h",
59
+ "?",
60
+ "help",
61
+ "q",
62
+ "quit",
63
+ "..",
64
+ "e",
65
+ "exit",
66
+ "r",
67
+ "reset",
68
+ "stop",
69
+ "whoami",
70
+ "results",
71
+ ]
72
+
73
+ CHOICES_COMMANDS: List[str] = []
74
+ CHOICES_MENUS: List[str] = []
75
+ NEWS_CHOICES: dict = {}
76
+ COMMAND_SEPARATOR = "/"
77
+ KEYS_MENU = "keys" + COMMAND_SEPARATOR
78
+ PATH: str = ""
79
+ FILE_PATH: str = ""
80
+ CHOICES_GENERATION = False
81
+
82
+ @property
83
+ def choices_default(self):
84
+ """Return the default choices."""
85
+ choices = (
86
+ build_controller_choice_map(controller=self)
87
+ if self.CHOICES_GENERATION
88
+ else {}
89
+ )
90
+
91
+ return choices
92
+
93
+ def __init__(self, queue: Optional[List[str]] = None) -> None:
94
+ """Create the base class for any controller in the codebase.
95
+
96
+ Used to simplify the creation of menus.
97
+
98
+ queue: List[str]
99
+ The current queue of jobs to process separated by "/"
100
+ E.g. /stocks/load gme/dps/sidtc/../exit
101
+ """
102
+ self.check_path()
103
+ self.path = [x for x in self.PATH.split("/") if x != ""]
104
+ self.queue = (
105
+ self.parse_input(an_input="/".join(queue))
106
+ if (queue and self.PATH != "/")
107
+ else list()
108
+ )
109
+
110
+ controller_choices = self.CHOICES_COMMANDS + self.CHOICES_MENUS
111
+ if controller_choices:
112
+ self.controller_choices = controller_choices + self.CHOICES_COMMON
113
+ else:
114
+ self.controller_choices = self.CHOICES_COMMON
115
+
116
+ self.completer: Union[None, NestedCompleter] = None
117
+
118
+ self.parser = argparse.ArgumentParser(
119
+ add_help=False,
120
+ prog=self.path[-1] if self.PATH != "/" else "cli",
121
+ )
122
+ self.parser.exit_on_error = False # type: ignore
123
+ self.parser.add_argument("cmd", choices=self.controller_choices)
124
+
125
+ def update_completer(self, choices) -> None:
126
+ """Update the completer with new choices."""
127
+ if session.prompt_session and session.settings.USE_PROMPT_TOOLKIT:
128
+ self.completer = NestedCompleter.from_nested_dict(choices)
129
+
130
+ def check_path(self) -> None:
131
+ """Check if command path is valid."""
132
+ path = self.PATH
133
+ if path[0] != "/":
134
+ raise ValueError("Path must begin with a '/' character.")
135
+ if path[-1] != "/":
136
+ raise ValueError("Path must end with a '/' character.")
137
+ if not re.match("^[a-z/]*$", path):
138
+ raise ValueError(
139
+ "Path must only contain lowercase letters and '/' characters."
140
+ )
141
+
142
+ def load_class(self, class_ins, *args, **kwargs):
143
+ """Check for an existing instance of the controller before creating a new one."""
144
+ self.save_class()
145
+ arguments = len(args) + len(kwargs)
146
+
147
+ if class_ins.PATH in controllers and arguments == 1:
148
+ old_class = controllers[class_ins.PATH]
149
+ old_class.queue = self.queue
150
+ return old_class.menu()
151
+ return class_ins(*args, **kwargs).menu()
152
+
153
+ def save_class(self) -> None:
154
+ """Save the current instance of the class to be loaded later."""
155
+ controllers[self.PATH] = self
156
+
157
+ def custom_reset(self) -> List[str]:
158
+ """Implement custom reset.
159
+
160
+ This will be replaced by any children with custom_reset functions.
161
+ """
162
+ return []
163
+
164
+ @abstractmethod
165
+ def print_help(self) -> None:
166
+ """Print help placeholder."""
167
+ raise NotImplementedError("Must override print_help.")
168
+
169
+ def parse_input(self, an_input: str) -> list:
170
+ """Parse controller input.
171
+
172
+ Splits the command chain from user input into a list of individual commands
173
+ while respecting the forward slash in the command arguments.
174
+
175
+ In the default scenario only unix-like paths are handles by the parser.
176
+ Override this function in the controller classes that inherit from this one to
177
+ resolve edge cases specific to command arguments on those controllers.
178
+
179
+ When handling edge cases add additional regular expressions to the list.
180
+
181
+ Parameters
182
+ ----------
183
+ an_input : str
184
+ User input string
185
+
186
+ Returns
187
+ ----------
188
+ list
189
+ Command queue as list
190
+ """
191
+ custom_filters: list = []
192
+ commands = parse_and_split_input(
193
+ an_input=an_input, custom_filters=custom_filters
194
+ )
195
+ return commands
196
+
197
+ def switch(self, an_input: str) -> List[str]:
198
+ """Process and dispatch input.
199
+
200
+ Returns
201
+ ----------
202
+ List[str]
203
+ list of commands in the queue to execute
204
+ """
205
+ actions = self.parse_input(an_input)
206
+
207
+ if an_input and an_input != "reset":
208
+ session.console.print()
209
+
210
+ # Empty command
211
+ if len(actions) == 0:
212
+ pass
213
+
214
+ # Navigation slash is being used first split commands
215
+ elif len(actions) > 1:
216
+ # Absolute path is specified
217
+ if not actions[0]:
218
+ actions[0] = "home"
219
+
220
+ # Add all instructions to the queue
221
+ for cmd in actions[::-1]:
222
+ if cmd:
223
+ self.queue.insert(0, cmd)
224
+
225
+ # Single command fed, process
226
+ else:
227
+ try:
228
+ (known_args, other_args) = self.parser.parse_known_args(
229
+ shlex.split(an_input)
230
+ )
231
+ except Exception as exc:
232
+ raise SystemExit from exc
233
+
234
+ if RECORD_SESSION:
235
+ SESSION_RECORDED.append(an_input)
236
+
237
+ # Redirect commands to their correct functions
238
+ if known_args.cmd:
239
+ if known_args.cmd in ("..", "q"):
240
+ known_args.cmd = "quit"
241
+ elif known_args.cmd in ("e"):
242
+ known_args.cmd = "exit"
243
+ elif known_args.cmd in ("?", "h"):
244
+ known_args.cmd = "help"
245
+ elif known_args.cmd == "r":
246
+ known_args.cmd = "reset"
247
+
248
+ getattr(
249
+ self,
250
+ "call_" + known_args.cmd,
251
+ lambda _: "Command not recognized!",
252
+ )(other_args)
253
+
254
+ if (
255
+ an_input
256
+ and an_input != "reset"
257
+ and (
258
+ not self.queue or (self.queue and self.queue[0] not in ("quit", "help"))
259
+ )
260
+ ):
261
+ session.console.print()
262
+
263
+ return self.queue
264
+
265
+ def call_cls(self, _) -> None:
266
+ """Process cls command."""
267
+ system_clear()
268
+
269
+ def call_home(self, _) -> None:
270
+ """Process home command."""
271
+ self.save_class()
272
+ if self.PATH.count("/") == 1 and session.settings.ENABLE_EXIT_AUTO_HELP:
273
+ self.print_help()
274
+ for _ in range(self.PATH.count("/") - 1):
275
+ self.queue.insert(0, "quit")
276
+
277
+ def call_help(self, _) -> None:
278
+ """Process help command."""
279
+ self.print_help()
280
+
281
+ def call_quit(self, _) -> None:
282
+ """Process quit menu command."""
283
+ self.save_class()
284
+ self.queue.insert(0, "quit")
285
+
286
+ def call_exit(self, _) -> None:
287
+ # Not sure how to handle controller loading here
288
+ """Process exit cli command."""
289
+ self.save_class()
290
+ for _ in range(self.PATH.count("/")):
291
+ self.queue.insert(0, "quit")
292
+
293
+ if not session.is_local():
294
+ remove_file(
295
+ Path(session.user.preferences.export_directory, "routines", "hub")
296
+ )
297
+
298
+ def call_reset(self, _) -> None:
299
+ """Process reset command.
300
+
301
+ If you would like to have customization in the reset process define a method
302
+ `custom_reset` in the child class.
303
+ """
304
+ self.save_class()
305
+ if self.PATH != "/":
306
+ if self.custom_reset():
307
+ self.queue = self.custom_reset() + self.queue
308
+ else:
309
+ for val in self.path[::-1]:
310
+ self.queue.insert(0, val)
311
+ self.queue.insert(0, "reset")
312
+ for _ in range(len(self.path)):
313
+ self.queue.insert(0, "quit")
314
+
315
+ def call_record(self, other_args) -> None:
316
+ """Process record command."""
317
+ parser = argparse.ArgumentParser(
318
+ add_help=False,
319
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
320
+ prog="record",
321
+ description="Start recording session into .openbb routine file",
322
+ )
323
+ parser.add_argument(
324
+ "-n",
325
+ "--name",
326
+ action="store",
327
+ dest="name",
328
+ type=str,
329
+ default="",
330
+ help="Routine title name to be saved - only use characters, digits and whitespaces.",
331
+ nargs="+",
332
+ )
333
+ parser.add_argument(
334
+ "-d",
335
+ "--description",
336
+ type=str,
337
+ dest="description",
338
+ help="The description of the routine",
339
+ default=f"Routine recorded at {datetime.now().strftime('%H:%M')} from the OpenBB Platform CLI",
340
+ nargs="+",
341
+ )
342
+ parser.add_argument(
343
+ "--tag1",
344
+ type=str,
345
+ dest="tag1",
346
+ help=f"The tag associated with the routine. Select from: {', '.join(SCRIPT_TAGS)}",
347
+ default="",
348
+ nargs="+",
349
+ )
350
+ parser.add_argument(
351
+ "--tag2",
352
+ type=str,
353
+ dest="tag2",
354
+ help=f"The tag associated with the routine. Select from: {', '.join(SCRIPT_TAGS)}",
355
+ default="",
356
+ nargs="+",
357
+ )
358
+ parser.add_argument(
359
+ "--tag3",
360
+ type=str,
361
+ dest="tag3",
362
+ help=f"The tag associated with the routine. Select from: {', '.join(SCRIPT_TAGS)}",
363
+ default="",
364
+ nargs="+",
365
+ )
366
+ parser.add_argument(
367
+ "-p",
368
+ "--public",
369
+ dest="public",
370
+ action="store_true",
371
+ help="Whether the routine should be public or not",
372
+ default=False,
373
+ )
374
+
375
+ if other_args and "-" not in other_args[0][0]:
376
+ other_args.insert(0, "-n")
377
+
378
+ ns_parser, _ = self.parse_simple_args(parser, other_args)
379
+
380
+ if ns_parser:
381
+ if not ns_parser.name:
382
+ session.console.print(
383
+ "[red]Set a routine title by using the '-n' flag. E.g. 'record -n Morning routine'[/red]"
384
+ )
385
+ return
386
+
387
+ tag1 = (
388
+ " ".join(ns_parser.tag1)
389
+ if isinstance(ns_parser.tag1, list)
390
+ else ns_parser.tag1
391
+ )
392
+ if tag1 and tag1 not in SCRIPT_TAGS:
393
+ session.console.print(
394
+ f"[red]The parameter 'tag1' needs to be one of the following {', '.join(SCRIPT_TAGS)}[/red]"
395
+ )
396
+ return
397
+
398
+ tag2 = (
399
+ " ".join(ns_parser.tag2)
400
+ if isinstance(ns_parser.tag2, list)
401
+ else ns_parser.tag2
402
+ )
403
+ if tag2 and tag2 not in SCRIPT_TAGS:
404
+ session.console.print(
405
+ f"[red]The parameter 'tag2' needs to be one of the following {', '.join(SCRIPT_TAGS)}[/red]"
406
+ )
407
+ return
408
+
409
+ tag3 = (
410
+ " ".join(ns_parser.tag3)
411
+ if isinstance(ns_parser.tag3, list)
412
+ else ns_parser.tag3
413
+ )
414
+ if tag3 and tag3 not in SCRIPT_TAGS:
415
+ session.console.print(
416
+ f"[red]The parameter 'tag3' needs to be one of the following {', '.join(SCRIPT_TAGS)}[/red]"
417
+ )
418
+ return
419
+
420
+ if session.is_local():
421
+ session.console.print(
422
+ "[red]Recording session to the OpenBB Hub is not supported in guest mode.[/red]"
423
+ )
424
+ session.console.print(
425
+ "\n[yellow]Visit the OpenBB Hub to register: http://my.openbb.co[/yellow]"
426
+ )
427
+ session.console.print(
428
+ "\n[yellow]Your routine will be saved locally.[/yellow]\n"
429
+ )
430
+
431
+ # Check if title has a valid format
432
+ title = " ".join(ns_parser.name) if ns_parser.name else ""
433
+ pattern = re.compile(r"^[a-zA-Z0-9\s]+$")
434
+ if not pattern.match(title):
435
+ session.console.print(
436
+ f"[red]Title '{title}' has invalid format. Please use only digits, characters and whitespaces.[/]"
437
+ )
438
+ return
439
+
440
+ global RECORD_SESSION # noqa: PLW0603
441
+ global RECORD_SESSION_LOCAL_ONLY # noqa: PLW0603
442
+ global SESSION_RECORDED_NAME # noqa: PLW0603
443
+ global SESSION_RECORDED_DESCRIPTION # noqa: PLW0603
444
+ global SESSION_RECORDED_TAGS # noqa: PLW0603
445
+ global SESSION_RECORDED_PUBLIC # noqa: PLW0603
446
+
447
+ RECORD_SESSION_LOCAL_ONLY = session.is_local()
448
+ RECORD_SESSION = True
449
+ SESSION_RECORDED_NAME = title
450
+ SESSION_RECORDED_DESCRIPTION = (
451
+ " ".join(ns_parser.description)
452
+ if isinstance(ns_parser.description, list)
453
+ else ns_parser.description
454
+ )
455
+ SESSION_RECORDED_TAGS = tag1 if tag1 else ""
456
+ SESSION_RECORDED_TAGS += "," + tag2 if tag2 else ""
457
+ SESSION_RECORDED_TAGS += "," + tag3 if tag3 else ""
458
+
459
+ SESSION_RECORDED_PUBLIC = ns_parser.public
460
+
461
+ session.console.print(
462
+ f"[green]The routine '{title}' is successfully being recorded.[/green]"
463
+ )
464
+ session.console.print(
465
+ "\n[yellow]Remember to run 'stop' command when you are done!\n[/yellow]"
466
+ )
467
+
468
+ def call_stop(self, other_args) -> None:
469
+ """Process stop command."""
470
+ parser = argparse.ArgumentParser(
471
+ add_help=False,
472
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
473
+ prog="stop",
474
+ description="Stop recording session into .openbb routine file",
475
+ )
476
+ # This is only for auto-completion purposes
477
+ _, _ = self.parse_simple_args(parser, other_args)
478
+
479
+ if "-h" not in other_args and "--help" not in other_args:
480
+ global RECORD_SESSION # noqa: PLW0603
481
+ global SESSION_RECORDED # noqa: PLW0603
482
+
483
+ if not RECORD_SESSION:
484
+ session.console.print(
485
+ "[red]There is no session being recorded. Start one using the command 'record'[/red]\n"
486
+ )
487
+ elif len(SESSION_RECORDED) < 5:
488
+ session.console.print(
489
+ "[red]Run at least 4 commands before stopping recording a session.[/red]\n"
490
+ )
491
+ else:
492
+ current_user = session.user
493
+
494
+ # Check if the user just wants to store routine locally
495
+ # This works regardless of whether they are logged in or not
496
+ if RECORD_SESSION_LOCAL_ONLY:
497
+ # Whitespaces are replaced by underscores and an .openbb extension is added
498
+ title_for_local_storage = (
499
+ SESSION_RECORDED_NAME.replace(" ", "_") + ".openbb"
500
+ )
501
+
502
+ routine_file = os.path.join(
503
+ f"{current_user.preferences.export_directory}/routines",
504
+ title_for_local_storage,
505
+ )
506
+
507
+ # If file already exists, add a timestamp to the name
508
+ if os.path.isfile(routine_file):
509
+ i = session.console.input(
510
+ "A local routine with the same name already exists, "
511
+ "do you want to override it? (y/n): "
512
+ )
513
+ session.console.print("")
514
+ while i.lower() not in ["y", "yes", "n", "no"]:
515
+ i = session.console.input("Select 'y' or 'n' to proceed: ")
516
+ session.console.print("")
517
+
518
+ if i.lower() in ["n", "no"]:
519
+ new_name = (
520
+ datetime.now().strftime("%Y%m%d_%H%M%S_")
521
+ + title_for_local_storage
522
+ )
523
+ routine_file = os.path.join(
524
+ current_user.preferences.export_directory,
525
+ "routines",
526
+ new_name,
527
+ )
528
+ session.console.print(
529
+ f"[yellow]The routine name has been updated to '{new_name}'[/yellow]\n"
530
+ )
531
+
532
+ # Writing to file
533
+ Path(os.path.dirname(routine_file)).mkdir(
534
+ parents=True, exist_ok=True
535
+ )
536
+
537
+ with open(routine_file, "w") as file1:
538
+ lines = ["# OpenBB Platform CLI - Routine", "\n"]
539
+
540
+ username = getattr(
541
+ session.user.profile.hub_session, "username", "local"
542
+ )
543
+
544
+ lines += (
545
+ [f"# Author: {username}", "\n\n"] if username else ["\n"]
546
+ )
547
+ lines += [
548
+ f"# Title: {SESSION_RECORDED_NAME}",
549
+ "\n",
550
+ f"# Tags: {SESSION_RECORDED_TAGS}",
551
+ "\n\n",
552
+ f"# Description: {SESSION_RECORDED_DESCRIPTION}",
553
+ "\n\n",
554
+ ]
555
+ lines += [c + "\n" for c in SESSION_RECORDED[:-1]]
556
+ # Writing data to a file
557
+ file1.writelines(lines)
558
+
559
+ session.console.print(
560
+ f"[green]Your routine has been recorded and saved here: {routine_file}[/green]\n"
561
+ )
562
+
563
+ # If user doesn't specify they want to store routine locally
564
+ # Confirm that the user is logged in
565
+ elif not session.is_local():
566
+ routine = "\n".join(SESSION_RECORDED[:-1])
567
+ hub_session = current_user.profile.hub_session
568
+
569
+ if routine is not None:
570
+ auth_header = (
571
+ f"{hub_session.token_type} {hub_session.access_token.get_secret_value()}"
572
+ if hub_session
573
+ else None
574
+ )
575
+ kwargs = {
576
+ "auth_header": auth_header,
577
+ "name": SESSION_RECORDED_NAME,
578
+ "description": SESSION_RECORDED_DESCRIPTION,
579
+ "routine": routine,
580
+ "tags": SESSION_RECORDED_TAGS,
581
+ "public": SESSION_RECORDED_PUBLIC,
582
+ }
583
+ response = upload_routine(**kwargs) # type: ignore
584
+ if response is not None and response.status_code == 409:
585
+ i = session.console.input(
586
+ "A routine with the same name already exists, "
587
+ "do you want to replace it? (y/n): "
588
+ )
589
+ session.console.print("")
590
+ if i.lower() in ["y", "yes"]:
591
+ kwargs["override"] = True # type: ignore
592
+ response = upload_routine(**kwargs) # type: ignore
593
+ else:
594
+ session.console.print("[info]Aborted.[/info]")
595
+
596
+ # Clear session to be recorded again
597
+ RECORD_SESSION = False
598
+ SESSION_RECORDED = list()
599
+
600
+ def call_whoami(self, other_args: List[str]) -> None:
601
+ """Process whoami command."""
602
+ parser = argparse.ArgumentParser(
603
+ add_help=False,
604
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
605
+ prog="whoami",
606
+ description="Show current user",
607
+ )
608
+ ns_parser, _ = self.parse_simple_args(parser, other_args)
609
+
610
+ if ns_parser:
611
+ current_user = session.user
612
+ local_user = session.is_local()
613
+ if not local_user:
614
+ hub_session = current_user.profile.hub_session
615
+ session.console.print(
616
+ f"[info]email:[/info] {hub_session.email if hub_session else 'N/A'}"
617
+ )
618
+ session.console.print(
619
+ f"[info]uuid:[/info] {hub_session.user_uuid if hub_session else 'N/A'}"
620
+ )
621
+ else:
622
+ print_guest_block_msg()
623
+
624
+ def call_results(self, other_args: List[str]):
625
+ """Process results command."""
626
+ parser = argparse.ArgumentParser(
627
+ add_help=False,
628
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
629
+ prog="results",
630
+ description="Process results command. This command displays a registry of "
631
+ "'OBBjects' where all execution results are stored. "
632
+ "It is organized as a stack, with the most recent result at index 0.",
633
+ )
634
+ parser.add_argument("--index", dest="index", help="Index of the result.")
635
+ parser.add_argument("--key", dest="key", help="Key of the result.")
636
+ parser.add_argument(
637
+ "--chart", action="store_true", dest="chart", help="Display chart."
638
+ )
639
+ parser.add_argument(
640
+ "--export",
641
+ default="",
642
+ type=check_file_type_saved(["csv", "json", "xlsx", "png", "jpg"]),
643
+ dest="export",
644
+ help="Export raw data into csv, json, xlsx and figure into png or jpg.",
645
+ nargs="+",
646
+ )
647
+ parser.add_argument(
648
+ "--sheet-name",
649
+ dest="sheet_name",
650
+ default=None,
651
+ nargs="+",
652
+ help="Name of excel sheet to save data to. Only valid for .xlsx files.",
653
+ )
654
+
655
+ ns_parser, unknown_args = self.parse_simple_args(
656
+ parser, other_args, unknown_args=True
657
+ )
658
+
659
+ if ns_parser:
660
+ kwargs = parse_unknown_args_to_dict(unknown_args)
661
+ if not ns_parser.index and not ns_parser.key:
662
+ results = session.obbject_registry.all
663
+ if results:
664
+ df = pd.DataFrame.from_dict(results, orient="index")
665
+ print_rich_table(
666
+ df,
667
+ show_index=True,
668
+ index_name="stack index",
669
+ title="OBBject Results",
670
+ )
671
+ else:
672
+ session.console.print("[info]No results found.[/info]")
673
+ elif ns_parser.index:
674
+ try:
675
+ index = int(ns_parser.index)
676
+ obbject = session.obbject_registry.get(index)
677
+ if obbject:
678
+ handle_obbject_display(
679
+ obbject=obbject,
680
+ chart=ns_parser.chart,
681
+ export=ns_parser.export,
682
+ sheet_name=ns_parser.sheet_name,
683
+ **kwargs,
684
+ )
685
+ else:
686
+ session.console.print(
687
+ f"[info]No result found at index {index}.[/info]"
688
+ )
689
+ except ValueError:
690
+ session.console.print(
691
+ f"[red]Index must be an integer, not '{ns_parser.index}'.[/red]"
692
+ )
693
+ elif ns_parser.key:
694
+ obbject = session.obbject_registry.get(ns_parser.key)
695
+ if obbject:
696
+ handle_obbject_display(
697
+ obbject=obbject,
698
+ chart=ns_parser.chart,
699
+ export=ns_parser.export,
700
+ sheet_name=ns_parser.sheet_name,
701
+ **kwargs,
702
+ )
703
+ else:
704
+ session.console.print(
705
+ f"[info]No result found with key '{ns_parser.key}'.[/info]"
706
+ )
707
+
708
+ @staticmethod
709
+ def parse_simple_args(
710
+ parser: argparse.ArgumentParser,
711
+ other_args: List[str],
712
+ unknown_args: bool = False,
713
+ ) -> Tuple[Optional[argparse.Namespace], Optional[List[str]]]:
714
+ """Parse list of arguments into the supplied parser.
715
+
716
+ Parameters
717
+ ----------
718
+ parser: argparse.ArgumentParser
719
+ Parser with predefined arguments
720
+ other_args: List[str]
721
+ List of arguments to parse
722
+ unknown_args: bool
723
+ Flag to indicate if unknown arguments should be returned
724
+
725
+ Returns
726
+ -------
727
+ ns_parser: argparse.Namespace
728
+ Namespace with parsed arguments
729
+ l_unknown_args: List[str]
730
+ List of unknown arguments
731
+ """
732
+ parser.add_argument(
733
+ "-h", "--help", action="store_true", help="show this help message"
734
+ )
735
+
736
+ if session.settings.USE_CLEAR_AFTER_CMD:
737
+ system_clear()
738
+
739
+ try:
740
+ (ns_parser, l_unknown_args) = parser.parse_known_args(other_args)
741
+ except SystemExit:
742
+ # In case the command has required argument that isn't specified
743
+ session.console.print("\n")
744
+ return None, None
745
+
746
+ if ns_parser.help:
747
+ txt_help = parser.format_help()
748
+ session.console.print(f"[help]{txt_help}[/help]")
749
+ return None, None
750
+
751
+ if l_unknown_args and not unknown_args:
752
+ session.console.print(
753
+ f"The following args couldn't be interpreted: {l_unknown_args}\n"
754
+ )
755
+ return ns_parser, l_unknown_args
756
+
757
+ @classmethod
758
+ def parse_known_args_and_warn(
759
+ cls,
760
+ parser: argparse.ArgumentParser,
761
+ other_args: List[str],
762
+ export_allowed: Literal[
763
+ "no_export", "raw_data_only", "figures_only", "raw_data_and_figures"
764
+ ] = "no_export",
765
+ raw: bool = False,
766
+ limit: int = 0,
767
+ ):
768
+ """Parse list of arguments into the supplied parser.
769
+
770
+ Parameters
771
+ ----------
772
+ parser: argparse.ArgumentParser
773
+ Parser with predefined arguments
774
+ other_args: List[str]
775
+ list of arguments to parse
776
+ export_allowed: Literal["no_export", "raw_data_only", "figures_only", "raw_data_and_figures"]
777
+ Export options
778
+ raw: bool
779
+ Add the --raw flag
780
+ limit: int
781
+ Add a --limit flag with this number default
782
+
783
+ Returns
784
+ ----------
785
+ ns_parser:
786
+ Namespace with parsed arguments
787
+ """
788
+ parser.add_argument(
789
+ "-h", "--help", action="store_true", help="show this help message"
790
+ )
791
+
792
+ if export_allowed != "no_export":
793
+ choices_export = []
794
+ help_export = "Does not export!"
795
+
796
+ if export_allowed == "raw_data_only":
797
+ choices_export = ["csv", "json", "xlsx"]
798
+ help_export = "Export raw data into csv, json or xlsx."
799
+ elif export_allowed == "figures_only":
800
+ choices_export = ["png", "jpg"]
801
+ help_export = "Export figure into png or jpg."
802
+ else:
803
+ choices_export = ["csv", "json", "xlsx", "png", "jpg"]
804
+ help_export = (
805
+ "Export raw data into csv, json, xlsx and figure into png or jpg."
806
+ )
807
+
808
+ parser.add_argument(
809
+ "--export",
810
+ default="",
811
+ type=check_file_type_saved(choices_export),
812
+ dest="export",
813
+ help=help_export,
814
+ nargs="+",
815
+ )
816
+
817
+ # If excel is an option, add the sheet name
818
+ if export_allowed in [
819
+ "raw_data_only",
820
+ "raw_data_and_figures",
821
+ ]:
822
+ parser.add_argument(
823
+ "--sheet-name",
824
+ dest="sheet_name",
825
+ default=None,
826
+ nargs="+",
827
+ help="Name of excel sheet to save data to. Only valid for .xlsx files.",
828
+ )
829
+
830
+ if raw:
831
+ parser.add_argument(
832
+ "--raw",
833
+ dest="raw",
834
+ action="store_true",
835
+ default=False,
836
+ help="Flag to display raw data",
837
+ )
838
+ if limit > 0:
839
+ parser.add_argument(
840
+ "-l",
841
+ "--limit",
842
+ dest="limit",
843
+ default=limit,
844
+ help="Number of entries to show in data.",
845
+ type=check_positive,
846
+ )
847
+
848
+ parser.add_argument(
849
+ "--register_obbject",
850
+ dest="register_obbject",
851
+ action="store_false",
852
+ default=True,
853
+ help="Flag to store data in the OBBject registry, True by default.",
854
+ )
855
+ parser.add_argument(
856
+ "--register_key",
857
+ dest="register_key",
858
+ default="",
859
+ help="Key to reference data in the OBBject registry.",
860
+ type=validate_register_key,
861
+ )
862
+
863
+ if session.settings.USE_CLEAR_AFTER_CMD:
864
+ system_clear()
865
+
866
+ if "--help" in other_args or "-h" in other_args:
867
+ txt_help = parser.format_help() + "\n"
868
+ session.console.print(f"[help]{txt_help}[/help]")
869
+ return None
870
+
871
+ try:
872
+ # Determine the index of the routine arguments
873
+ routine_args_index = next(
874
+ (
875
+ i + 1
876
+ for i, arg in enumerate(other_args)
877
+ if arg in ("-i", "--input")
878
+ and "routine_args"
879
+ in [
880
+ action.dest
881
+ for action in parser._actions # pylint: disable=protected-access
882
+ ]
883
+ ),
884
+ -1,
885
+ )
886
+ # Split comma-separated arguments, except for the argument at routine_args_index
887
+ other_args = [
888
+ part
889
+ for index, arg in enumerate(other_args)
890
+ for part in (arg.split(",") if index != routine_args_index else [arg])
891
+ ]
892
+
893
+ # Check if the action has optional choices, if yes, remove them
894
+ for action in parser._actions: # pylint: disable=protected-access
895
+ if hasattr(action, "optional_choices") and action.optional_choices:
896
+ action.choices = None
897
+
898
+ (ns_parser, l_unknown_args) = parser.parse_known_args(other_args)
899
+
900
+ if export_allowed in [
901
+ "raw_data_only",
902
+ "raw_data_and_figures",
903
+ ]:
904
+ ns_parser.is_image = any(
905
+ ext in ns_parser.export for ext in ["png", "jpg"]
906
+ )
907
+
908
+ except SystemExit:
909
+ # In case the command has required argument that isn't specified
910
+
911
+ return None
912
+
913
+ if l_unknown_args:
914
+ session.console.print(
915
+ f"The following args couldn't be interpreted: {l_unknown_args}"
916
+ )
917
+ return ns_parser
918
+
919
+ def menu(self, custom_path_menu_above: str = ""):
920
+ """Enter controller menu."""
921
+ settings = session.settings
922
+ an_input = "HELP_ME"
923
+
924
+ while True:
925
+ # There is a command in the queue
926
+ if self.queue and len(self.queue) > 0:
927
+ if self.queue[0] in ("q", "..", "quit"):
928
+ self.save_class()
929
+ # Go back to the root in order to go to the right directory because
930
+ # there was a jump between indirect menus
931
+ if custom_path_menu_above:
932
+ self.queue.insert(1, custom_path_menu_above)
933
+
934
+ if len(self.queue) > 1:
935
+ return self.queue[1:]
936
+
937
+ if settings.ENABLE_EXIT_AUTO_HELP:
938
+ return ["help"]
939
+ return []
940
+
941
+ # Consume 1 element from the queue
942
+ an_input = self.queue[0]
943
+ self.queue = self.queue[1:]
944
+
945
+ # Print location because this was an instruction and we want user to know the action
946
+ if (
947
+ an_input
948
+ and an_input != "home"
949
+ and an_input != "help"
950
+ and an_input.split(" ")[0] in self.controller_choices
951
+ ):
952
+ session.console.print(
953
+ f"{get_flair_and_username()} {self.PATH} $ {an_input}"
954
+ )
955
+
956
+ # Get input command from user
957
+ else:
958
+ # Display help menu when entering on this menu from a level above
959
+ if an_input == "HELP_ME":
960
+ self.print_help()
961
+
962
+ try:
963
+ prompt_session = session.prompt_session
964
+ if prompt_session and settings.USE_PROMPT_TOOLKIT:
965
+ # Check if toolbar hint was enabled
966
+ if settings.TOOLBAR_HINT:
967
+ an_input = prompt_session.prompt(
968
+ f"{get_flair_and_username()} {self.PATH} $ ",
969
+ completer=self.completer,
970
+ search_ignore_case=True,
971
+ bottom_toolbar=HTML(
972
+ '<style bg="ansiblack" fg="ansiwhite">[h]</style> help menu '
973
+ '<style bg="ansiblack" fg="ansiwhite">[q]</style> return to previous menu '
974
+ '<style bg="ansiblack" fg="ansiwhite">[e]</style> exit the program '
975
+ '<style bg="ansiblack" fg="ansiwhite">[cmd -h]</style> '
976
+ "see usage and available options "
977
+ f"{self.path[-1].capitalize()} (cmd/menu) Documentation"
978
+ ),
979
+ style=Style.from_dict(
980
+ {"bottom-toolbar": "#ffffff bg:#333333"}
981
+ ),
982
+ )
983
+ else:
984
+ an_input = prompt_session.prompt(
985
+ f"{get_flair_and_username()} {self.PATH} $ ",
986
+ completer=self.completer,
987
+ search_ignore_case=True,
988
+ )
989
+ # Get input from user without auto-completion
990
+ else:
991
+ an_input = input(f"{get_flair_and_username()} {self.PATH} $ ")
992
+
993
+ except (KeyboardInterrupt, EOFError):
994
+ # Exit in case of keyboard interrupt
995
+ an_input = "exit"
996
+
997
+ try:
998
+ # Allow user to go back to root
999
+ an_input = "home" if an_input == "/" else an_input
1000
+
1001
+ # Process the input command
1002
+ self.queue = self.switch(an_input)
1003
+
1004
+ except SystemExit:
1005
+ session.console.print(
1006
+ f"[red]The command '{an_input}' doesn't exist on the {self.PATH} menu.[/red]\n",
1007
+ )
1008
+ similar_cmd = difflib.get_close_matches(
1009
+ an_input.split(" ")[0] if " " in an_input else an_input,
1010
+ self.controller_choices,
1011
+ n=1,
1012
+ cutoff=0.7,
1013
+ )
1014
+ if similar_cmd:
1015
+ if " " in an_input:
1016
+ candidate_input = (
1017
+ f"{similar_cmd[0]} {' '.join(an_input.split(' ')[1:])}"
1018
+ )
1019
+ if candidate_input == an_input:
1020
+ an_input = ""
1021
+ self.queue = []
1022
+ session.console.print("\n")
1023
+ continue
1024
+
1025
+ an_input = candidate_input
1026
+ else:
1027
+ an_input = similar_cmd[0]
1028
+
1029
+ session.console.print(
1030
+ f"[green]Replacing by '{an_input}'.[/green]\n"
1031
+ )
1032
+ self.queue.insert(0, an_input)
cli/openbb_cli/controllers/base_platform_controller.py ADDED
@@ -0,0 +1,392 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Platform Equity Controller."""
2
+
3
+ import os
4
+ from functools import partial, update_wrapper
5
+ from types import MethodType
6
+ from typing import Dict, List, Optional
7
+
8
+ import pandas as pd
9
+ from openbb import obb
10
+ from openbb_charting.core.openbb_figure import OpenBBFigure
11
+ from openbb_cli.argparse_translator.argparse_class_processor import (
12
+ ArgparseClassProcessor,
13
+ )
14
+ from openbb_cli.config.menu_text import MenuText
15
+ from openbb_cli.controllers.base_controller import BaseController
16
+ from openbb_cli.controllers.utils import export_data, print_rich_table
17
+ from openbb_cli.session import Session
18
+ from openbb_core.app.model.obbject import OBBject
19
+
20
+ session = Session()
21
+
22
+
23
+ class DummyTranslation:
24
+ """Dummy Translation for testing."""
25
+
26
+ def __init__(self):
27
+ """Construct a Dummy Translation Class."""
28
+ self.paths = {}
29
+ self.translators = {}
30
+
31
+
32
+ class PlatformController(BaseController):
33
+ """Platform Controller Base class."""
34
+
35
+ CHOICES_GENERATION = True
36
+
37
+ def __init__( # pylint: disable=too-many-positional-arguments
38
+ self,
39
+ name: str,
40
+ parent_path: List[str],
41
+ platform_target: Optional[type] = None,
42
+ queue: Optional[List[str]] = None,
43
+ translators: Optional[Dict] = None,
44
+ ):
45
+ """Construct a Platform based Controller."""
46
+ self.PATH = f"/{'/'.join(parent_path)}/{name}/" if parent_path else f"/{name}/"
47
+ super().__init__(queue)
48
+ self._name = name
49
+
50
+ if not (platform_target or translators):
51
+ raise ValueError("Either platform_target or translators must be provided.")
52
+
53
+ self._translated_target = (
54
+ ArgparseClassProcessor(
55
+ target_class=platform_target, reference=obb.reference["paths"] # type: ignore
56
+ )
57
+ if platform_target
58
+ else DummyTranslation()
59
+ )
60
+ self.translators = (
61
+ translators
62
+ if translators is not None
63
+ else getattr(self._translated_target, "translators", {})
64
+ )
65
+ self.paths = getattr(self._translated_target, "paths", {})
66
+
67
+ if self.translators:
68
+ self._link_obbject_to_data_processing_commands()
69
+ self._generate_commands()
70
+ self._generate_sub_controllers()
71
+ self.update_completer(self.choices_default)
72
+
73
+ def _link_obbject_to_data_processing_commands(self):
74
+ """Link data processing commands to OBBject registry."""
75
+ for _, trl in self.translators.items():
76
+ for action in trl._parser._actions: # pylint: disable=protected-access
77
+ if action.dest == "data":
78
+ # Generate choices by combining indexed and key-based choices
79
+ action.choices = [
80
+ "OBB" + str(i)
81
+ for i in range(len(session.obbject_registry.obbjects))
82
+ ] + [
83
+ obbject.extra["register_key"]
84
+ for obbject in session.obbject_registry.obbjects
85
+ if "register_key" in obbject.extra
86
+ ]
87
+
88
+ action.type = str
89
+ action.nargs = None
90
+
91
+ def _intersect_data_processing_commands(self, ns_parser):
92
+ """Intersect data processing commands and change the obbject id into an actual obbject."""
93
+ if hasattr(ns_parser, "data"):
94
+ if "OBB" in ns_parser.data:
95
+ ns_parser.data = int(ns_parser.data.replace("OBB", ""))
96
+
97
+ if (ns_parser.data in range(len(session.obbject_registry.obbjects))) or (
98
+ ns_parser.data in session.obbject_registry.obbject_keys
99
+ ):
100
+ obbject = session.obbject_registry.get(ns_parser.data)
101
+ if obbject and isinstance(obbject, OBBject):
102
+ setattr(ns_parser, "data", obbject.results)
103
+
104
+ return ns_parser
105
+
106
+ def _generate_sub_controllers(self):
107
+ """Handle paths."""
108
+ for path, value in self.paths.items():
109
+ if value == "path":
110
+ continue
111
+
112
+ sub_menu_translators = {}
113
+ choices_commands = []
114
+
115
+ for translator_name, translator in self.translators.items():
116
+ if f"{self._name}_{path}" in translator_name:
117
+ new_name = translator_name.replace(f"{self._name}_{path}_", "")
118
+ sub_menu_translators[new_name] = translator
119
+ choices_commands.append(new_name)
120
+
121
+ if translator_name in self.CHOICES_COMMANDS:
122
+ self.CHOICES_COMMANDS.remove(translator_name)
123
+
124
+ # Create the sub controller as a new class
125
+ class_name = f"{self._name.capitalize()}{path.capitalize()}Controller"
126
+ SubController = type(
127
+ class_name,
128
+ (PlatformController,),
129
+ {
130
+ "CHOICES_GENERATION": True,
131
+ # "CHOICES_MENUS": [],
132
+ "CHOICES_COMMANDS": choices_commands,
133
+ },
134
+ )
135
+
136
+ self._generate_controller_call(
137
+ controller=SubController,
138
+ name=path,
139
+ parent_path=self.path,
140
+ translators=sub_menu_translators,
141
+ )
142
+
143
+ def _generate_commands(self):
144
+ """Generate commands."""
145
+ for name, translator in self.translators.items():
146
+ # Prepare the translator name to create a command call in the controller
147
+ new_name = name.replace(f"{self._name}_", "")
148
+
149
+ self._generate_command_call(name=new_name, translator=translator)
150
+
151
+ def _generate_command_call(self, name, translator):
152
+ """Generate command call."""
153
+
154
+ def method(self, other_args: List[str], translator=translator):
155
+ """Call the translator."""
156
+ parser = translator.parser
157
+
158
+ if ns_parser := self.parse_known_args_and_warn(
159
+ parser=parser,
160
+ other_args=other_args,
161
+ export_allowed="raw_data_and_figures",
162
+ ):
163
+ try:
164
+ ns_parser = self._intersect_data_processing_commands(ns_parser)
165
+ export = hasattr(ns_parser, "export") and ns_parser.export
166
+ store_obbject = (
167
+ hasattr(ns_parser, "register_obbject")
168
+ and ns_parser.register_obbject
169
+ )
170
+
171
+ obbject = translator.execute_func(parsed_args=ns_parser)
172
+ df: pd.DataFrame = pd.DataFrame()
173
+ fig: Optional[OpenBBFigure] = None
174
+ title = f"{self.PATH}{translator.func.__name__}"
175
+
176
+ if obbject:
177
+ if isinstance(obbject, list):
178
+ obbject = OBBject(results=obbject)
179
+
180
+ if isinstance(obbject, OBBject):
181
+ if (
182
+ session.max_obbjects_exceeded()
183
+ and obbject.results
184
+ and store_obbject
185
+ ):
186
+ session.obbject_registry.remove()
187
+ session.console.print(
188
+ "[yellow]Maximum number of OBBjects reached. The oldest entry was removed.[yellow]"
189
+ )
190
+
191
+ # use the obbject to store the command so we can display it later on results
192
+ obbject.extra["command"] = f"{title} {' '.join(other_args)}"
193
+ # if there is a registry key in the parser, store to the obbject
194
+ if (
195
+ hasattr(ns_parser, "register_key")
196
+ and ns_parser.register_key
197
+ ):
198
+ if (
199
+ ns_parser.register_key
200
+ not in session.obbject_registry.obbject_keys
201
+ ):
202
+ obbject.extra["register_key"] = str(
203
+ ns_parser.register_key
204
+ )
205
+ else:
206
+ session.console.print(
207
+ f"[yellow]Key `{ns_parser.register_key}` already exists in the registry."
208
+ "The `OBBject` was kept without the key.[/yellow]"
209
+ )
210
+
211
+ if store_obbject:
212
+ # store the obbject in the registry
213
+ register_result = session.obbject_registry.register(
214
+ obbject
215
+ )
216
+
217
+ # we need to force to re-link so that the new obbject
218
+ # is immediately available for data processing commands
219
+ self._link_obbject_to_data_processing_commands()
220
+ # also update the completer
221
+ self.update_completer(self.choices_default)
222
+
223
+ if (
224
+ session.settings.SHOW_MSG_OBBJECT_REGISTRY
225
+ and register_result
226
+ ):
227
+ session.console.print(
228
+ "Added `OBBject` to cached results."
229
+ )
230
+
231
+ # making the dataframe available either for printing or exporting
232
+ df = obbject.to_dataframe()
233
+
234
+ if hasattr(ns_parser, "chart") and ns_parser.chart:
235
+ fig = obbject.chart.fig if obbject.chart else None
236
+ if not export:
237
+ obbject.show()
238
+ elif session.settings.USE_INTERACTIVE_DF and not export:
239
+ obbject.charting.table()
240
+ else:
241
+ if isinstance(df.columns, pd.RangeIndex):
242
+ df.columns = [str(i) for i in df.columns]
243
+
244
+ print_rich_table(
245
+ df=df, show_index=True, title=title, export=export
246
+ )
247
+
248
+ elif isinstance(obbject, dict):
249
+ df = pd.DataFrame.from_dict(obbject, orient="columns")
250
+ print_rich_table(
251
+ df=df, show_index=True, title=title, export=export
252
+ )
253
+
254
+ elif not isinstance(obbject, OBBject):
255
+ session.console.print(obbject)
256
+
257
+ if export and not df.empty:
258
+ sheet_name = getattr(ns_parser, "sheet_name", None)
259
+ if sheet_name and isinstance(sheet_name, list):
260
+ sheet_name = sheet_name[0]
261
+
262
+ export_data(
263
+ export_type=",".join(ns_parser.export),
264
+ dir_path=os.path.dirname(os.path.abspath(__file__)),
265
+ func_name=translator.func.__name__,
266
+ df=df,
267
+ sheet_name=sheet_name,
268
+ figure=fig,
269
+ )
270
+ elif export and df.empty:
271
+ session.console.print("[yellow]No data to export.[/yellow]")
272
+
273
+ except Exception as e:
274
+ session.console.print(f"[red]{e}[/]\n")
275
+ return
276
+
277
+ # Bind the method to the class
278
+ bound_method = MethodType(method, self)
279
+
280
+ # Update the wrapper and set the attribute
281
+ bound_method = update_wrapper( # type: ignore
282
+ partial(bound_method, translator=translator), method
283
+ )
284
+ setattr(self, f"call_{name}", bound_method)
285
+
286
+ def _generate_controller_call(self, controller, name, parent_path, translators):
287
+ """Generate controller call."""
288
+
289
+ def method(self, _, controller, name, parent_path, translators):
290
+ """Call the controller."""
291
+ self.queue = self.load_class(
292
+ class_ins=controller,
293
+ name=name,
294
+ parent_path=parent_path,
295
+ translators=translators,
296
+ queue=self.queue,
297
+ )
298
+
299
+ # Bind the method to the class
300
+ bound_method = MethodType(method, self)
301
+
302
+ # Update the wrapper and set the attribute
303
+ bound_method = update_wrapper( # type: ignore
304
+ partial(
305
+ bound_method,
306
+ name=name,
307
+ parent_path=parent_path,
308
+ translators=translators,
309
+ controller=controller,
310
+ ),
311
+ method,
312
+ )
313
+ setattr(self, f"call_{name}", bound_method)
314
+
315
+ def _get_command_description(self, command: str) -> str:
316
+ """Get command description."""
317
+ command_description = (
318
+ obb.reference["paths"] # type: ignore
319
+ .get(f"{self.PATH}{command}", {})
320
+ .get("description", "")
321
+ )
322
+
323
+ if not command_description:
324
+ trl = self.translators.get(
325
+ f"{self._name}_{command}"
326
+ ) or self.translators.get(command)
327
+ if trl and hasattr(trl, "parser"):
328
+ command_description = trl.parser.description
329
+
330
+ return command_description.split(".")[0].lower()
331
+
332
+ def _get_menu_description(self, menu: str) -> str:
333
+ """Get menu description."""
334
+
335
+ def _get_sub_menu_commands():
336
+ """Get sub menu commands."""
337
+ sub_path = f"{self.PATH[1:].replace('/','_')}{menu}"
338
+ commands = []
339
+ for trl in self.translators:
340
+ if sub_path in trl:
341
+ commands.append(trl.replace(f"{sub_path}_", ""))
342
+ return commands
343
+
344
+ menu_description = (
345
+ obb.reference["routers"] # type: ignore
346
+ .get(f"{self.PATH}{menu}", {})
347
+ .get("description", "")
348
+ ) or ""
349
+ if menu_description:
350
+ return menu_description.split(".")[0].lower()
351
+
352
+ # If no description is found, return the sub menu commands
353
+ return ", ".join(_get_sub_menu_commands())
354
+
355
+ def print_help(self):
356
+ """Print help."""
357
+ mt = MenuText(self.PATH)
358
+
359
+ if self.CHOICES_MENUS:
360
+ for menu in self.CHOICES_MENUS:
361
+ description = self._get_menu_description(menu)
362
+ mt.add_menu(name=menu, description=description)
363
+
364
+ if self.CHOICES_COMMANDS:
365
+ mt.add_raw("\n")
366
+
367
+ if self.CHOICES_COMMANDS:
368
+ for command in self.CHOICES_COMMANDS:
369
+ command_description = self._get_command_description(command)
370
+ mt.add_cmd(
371
+ name=command.replace(f"{self._name}_", ""),
372
+ description=command_description,
373
+ )
374
+
375
+ if session.obbject_registry.obbjects:
376
+ mt.add_info("\nCached Results")
377
+ for key, value in list(session.obbject_registry.all.items())[
378
+ : session.settings.N_TO_DISPLAY_OBBJECT_REGISTRY
379
+ ]:
380
+ mt.add_raw(
381
+ f"[yellow]OBB{key}[/yellow]: {value['command']}",
382
+ left_spacing=True,
383
+ )
384
+
385
+ session.console.print(text=mt.menu_text, menu=self.PATH)
386
+
387
+ if mt.warnings:
388
+ session.console.print("")
389
+ for w in mt.warnings:
390
+ w_str = str(w).replace("{", "").replace("}", "").replace("'", "")
391
+ session.console.print(f"[yellow]{w_str}[/yellow]")
392
+ session.console.print("")
cli/openbb_cli/controllers/choices.py ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """This module contains functions to build the choice map for the controllers."""
2
+
3
+ from argparse import SUPPRESS, ArgumentParser
4
+ from contextlib import contextmanager
5
+ from inspect import isfunction, unwrap
6
+ from types import MethodType
7
+ from typing import Callable, List, Literal, Tuple
8
+ from unittest.mock import patch
9
+
10
+ from openbb_cli.controllers.utils import (
11
+ check_file_type_saved,
12
+ check_positive,
13
+ validate_register_key,
14
+ )
15
+ from openbb_cli.session import Session
16
+
17
+ session = Session()
18
+
19
+
20
+ def __mock_parse_known_args_and_warn(
21
+ controller, # pylint: disable=unused-argument
22
+ parser: ArgumentParser,
23
+ other_args: List[str],
24
+ export_allowed: Literal[
25
+ "no_export", "raw_data_only", "figures_only", "raw_data_and_figures"
26
+ ] = "no_export",
27
+ raw: bool = False,
28
+ limit: int = 0,
29
+ ) -> None:
30
+ """Add arguments.
31
+
32
+ Add the arguments that would have normally added by :
33
+ - openbb_cli.base_controller.BaseController.parse_known_args_and_warn
34
+
35
+ Parameters
36
+ ----------
37
+ parser: argparse.ArgumentParser
38
+ Parser with predefined arguments
39
+ other_args: List[str]
40
+ list of arguments to parse
41
+ export_allowed: Literal["no_export", "raw_data_only", "figures_only", "raw_data_and_figures"]
42
+ Export options
43
+ raw: bool
44
+ Add the --raw flag
45
+ limit: int
46
+ Add a --limit flag with this number default
47
+ """
48
+ _ = other_args
49
+
50
+ parser.add_argument(
51
+ "-h", "--help", action="store_true", help="show this help message"
52
+ )
53
+
54
+ if export_allowed != "no_export":
55
+ choices_export = []
56
+ help_export = "Does not export!"
57
+
58
+ if export_allowed == "raw_data_only":
59
+ choices_export = ["csv", "json", "xlsx"]
60
+ help_export = "Export raw data into csv, json or xlsx."
61
+ elif export_allowed == "figures_only":
62
+ choices_export = ["png", "jpg"]
63
+ help_export = "Export figure into png or jpg."
64
+ else:
65
+ choices_export = ["csv", "json", "xlsx", "png", "jpg"]
66
+ help_export = (
67
+ "Export raw data into csv, json, xlsx and figure into png or jpg."
68
+ )
69
+
70
+ parser.add_argument(
71
+ "--export",
72
+ default="",
73
+ type=check_file_type_saved(choices_export),
74
+ dest="export",
75
+ help=help_export,
76
+ choices=choices_export,
77
+ )
78
+
79
+ if raw:
80
+ parser.add_argument(
81
+ "--raw",
82
+ dest="raw",
83
+ action="store_true",
84
+ default=False,
85
+ help="Flag to display raw data",
86
+ )
87
+ if limit > 0:
88
+ parser.add_argument(
89
+ "-l",
90
+ "--limit",
91
+ dest="limit",
92
+ default=limit,
93
+ help="Number of entries to show in data.",
94
+ type=check_positive,
95
+ )
96
+
97
+ parser.add_argument(
98
+ "--register_obbject",
99
+ dest="register_obbject",
100
+ action="store_false",
101
+ default=True,
102
+ help="Flag to store data in the OBBject registry, True by default.",
103
+ )
104
+ parser.add_argument(
105
+ "--register_key",
106
+ dest="register_key",
107
+ default="",
108
+ help="Key to reference data in the OBBject registry.",
109
+ type=validate_register_key,
110
+ )
111
+
112
+
113
+ def __mock_parse_simple_args(parser: ArgumentParser, other_args: List[str]) -> Tuple:
114
+ """Add arguments.
115
+
116
+ Add the arguments that would have normally added by:
117
+ - openbb_cli.parent_classes.BaseController.parse_simple_args
118
+
119
+ Parameters
120
+ ----------
121
+ parser: argparse.ArgumentParser
122
+ Parser with predefined arguments
123
+ other_args: List[str]
124
+ List of arguments to parse
125
+ """
126
+ parser.add_argument(
127
+ "-h", "--help", action="store_true", help="show this help message"
128
+ )
129
+ _ = other_args
130
+ return None, None
131
+
132
+
133
+ def __get_command_func(controller, command: str):
134
+ """Get the function with the name `f"call_{command}"` from controller object.
135
+
136
+ Parameters
137
+ ----------
138
+ controller: BaseController
139
+ Instance of the CLI Controller.
140
+ command: str
141
+ A name from controller.CHOICES_COMMANDS
142
+
143
+ Returns
144
+ -------
145
+ Callable: Command function.
146
+ """
147
+ if command not in controller.CHOICES_COMMANDS:
148
+ raise AttributeError(
149
+ f"The following command is not inside `CHOICES_COMMANDS` : '{command}'"
150
+ )
151
+
152
+ command = f"call_{command}"
153
+ command_func = getattr(controller, command)
154
+ command_func = unwrap(func=command_func)
155
+
156
+ if isfunction(command_func):
157
+ command_func = MethodType(command_func, controller)
158
+
159
+ return command_func
160
+
161
+
162
+ def contains_functions_to_patch(command_func: Callable) -> bool:
163
+ """Check command function.
164
+
165
+ Check if a `command_func` actually contains the functions we want to mock, i.e.:
166
+ - parse_simple_args
167
+ - parse_known_args_and_warn
168
+
169
+ Parameters
170
+ ----------
171
+ command_func: Callable
172
+ Function to check.
173
+
174
+ Returns
175
+ -------
176
+ bool: Whether or not `command_func` contains the mocked functions.
177
+ """
178
+ co_names = command_func.__code__.co_names
179
+
180
+ return bool(
181
+ "parse_simple_args" in co_names or "parse_known_args_and_warn" in co_names
182
+ )
183
+
184
+
185
+ @contextmanager
186
+ def __patch_controller_functions(controller):
187
+ """Patch controller functions.
188
+
189
+ Patch the following function from a BaseController instance:
190
+ - parse_simple_args
191
+ - parse_known_args_and_warn
192
+
193
+ These functions take an 'argparse.ArgumentParser' object as parameter.
194
+ We want to intercept this 'argparse.ArgumentParser' object.
195
+
196
+ Parameters
197
+ ----------
198
+ controller: BaseController
199
+ BaseController object that needs to be patched.
200
+
201
+ Returns
202
+ -------
203
+ List[Callable]: List of mocked functions.
204
+ """
205
+ bound_mock_parse_known_args_and_warn = MethodType(
206
+ __mock_parse_known_args_and_warn,
207
+ controller,
208
+ )
209
+
210
+ rich = patch(
211
+ target="openbb_cli.config.console.Console.print",
212
+ return_value=None,
213
+ )
214
+
215
+ patcher_list = [
216
+ patch.object(
217
+ target=controller,
218
+ attribute="parse_simple_args",
219
+ side_effect=__mock_parse_simple_args,
220
+ return_value=(None, None),
221
+ ),
222
+ patch.object(
223
+ target=controller,
224
+ attribute="parse_known_args_and_warn",
225
+ side_effect=bound_mock_parse_known_args_and_warn,
226
+ return_value=None,
227
+ ),
228
+ ]
229
+
230
+ if not session.settings.DEBUG_MODE:
231
+ rich.start()
232
+ patched_function_list = []
233
+ for patcher in patcher_list:
234
+ patched_function_list.append(patcher.start())
235
+
236
+ yield patched_function_list
237
+
238
+ if not session.settings.DEBUG_MODE:
239
+ rich.stop()
240
+ for patcher in patcher_list:
241
+ patcher.stop()
242
+
243
+
244
+ def _get_argument_parser(
245
+ controller,
246
+ command: str,
247
+ ) -> ArgumentParser:
248
+ """Intercept the ArgumentParser instance from the command function.
249
+
250
+ A command function being a function starting with `call_`, like:
251
+ - call_help
252
+ - call_overview
253
+ - call_load
254
+
255
+ Parameters
256
+ ----------
257
+ controller: BaseController
258
+ Instance of the CLI Controller.
259
+ command: str
260
+ A name from `controller.CHOICES_COMMANDS`.
261
+
262
+ Returns
263
+ -------
264
+ ArgumentParser: ArgumentParser instance from the command function.
265
+ """
266
+ command_func: Callable = __get_command_func(controller=controller, command=command)
267
+
268
+ if not contains_functions_to_patch(command_func=command_func):
269
+ raise AssertionError(
270
+ f"One of these functions should be inside `call_{command}`:\n"
271
+ " - parse_simple_args\n"
272
+ " - parse_known_args_and_warn\n"
273
+ )
274
+
275
+ with __patch_controller_functions(controller=controller) as patched_function_list:
276
+ command_func([])
277
+
278
+ call_count = 0
279
+ for patched_function in patched_function_list:
280
+ call_count += patched_function.call_count
281
+ if patched_function.call_count == 1:
282
+ args, kwargs = patched_function.call_args
283
+ argument_parser = (
284
+ kwargs["parser"] if kwargs.get("parser", None) else args[0]
285
+ )
286
+
287
+ if call_count != 1:
288
+ raise AssertionError(
289
+ f"One of these functions should be called once inside `call_{command}`:\n"
290
+ " - parse_simple_args\n"
291
+ " - parse_known_args_and_warn\n"
292
+ )
293
+
294
+ # pylint: disable=possibly-used-before-assignment
295
+ return argument_parser
296
+
297
+
298
+ def _build_command_choice_map(argument_parser: ArgumentParser) -> dict:
299
+ """Build the choice map for a command."""
300
+ choice_map: dict = {}
301
+ for action in argument_parser._actions: # pylint: disable=protected-access
302
+ if action.help == SUPPRESS:
303
+ continue
304
+ if len(action.option_strings) == 1:
305
+ long_name = action.option_strings[0]
306
+ short_name = ""
307
+ elif len(action.option_strings) == 2:
308
+ short_name = action.option_strings[0]
309
+ long_name = action.option_strings[1]
310
+ else:
311
+ raise AttributeError(f"Invalid argument_parser: {argument_parser}")
312
+
313
+ if hasattr(action, "choices") and action.choices:
314
+ choice_map[long_name] = {str(c): {} for c in action.choices}
315
+ else:
316
+ choice_map[long_name] = {}
317
+
318
+ if short_name and long_name:
319
+ choice_map[short_name] = long_name
320
+
321
+ return choice_map
322
+
323
+
324
+ def build_controller_choice_map(controller) -> dict:
325
+ """Build the choice map for a controller."""
326
+ command_list = controller.CHOICES_COMMANDS
327
+ controller_choice_map: dict = {c: {} for c in controller.controller_choices}
328
+
329
+ for command in command_list:
330
+ try:
331
+ argument_parser = _get_argument_parser(
332
+ controller=controller,
333
+ command=command,
334
+ )
335
+ controller_choice_map[command] = _build_command_choice_map(
336
+ argument_parser=argument_parser
337
+ )
338
+ except Exception as exception:
339
+ if session.settings.DEBUG_MODE:
340
+ raise Exception(
341
+ f"On command : `{command}`.\n{str(exception)}"
342
+ ) from exception
343
+
344
+ return controller_choice_map
cli/openbb_cli/controllers/cli_controller.py ADDED
@@ -0,0 +1,944 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """Main CLI Module."""
3
+
4
+ import argparse
5
+ import contextlib
6
+ import difflib
7
+ import os
8
+ import re
9
+ import sys
10
+ import time
11
+ import webbrowser
12
+ from datetime import datetime
13
+ from functools import partial, update_wrapper
14
+ from pathlib import Path
15
+ from types import MethodType
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ import pandas as pd
19
+ import requests
20
+ from openbb import obb
21
+ from openbb_cli.config import constants
22
+ from openbb_cli.config.constants import (
23
+ ASSETS_DIRECTORY,
24
+ ENV_FILE_SETTINGS,
25
+ HOME_DIRECTORY,
26
+ REPOSITORY_DIRECTORY,
27
+ )
28
+ from openbb_cli.config.menu_text import MenuText
29
+ from openbb_cli.controllers.base_controller import BaseController
30
+ from openbb_cli.controllers.platform_controller_factory import (
31
+ PlatformControllerFactory,
32
+ )
33
+ from openbb_cli.controllers.script_parser import is_reset, parse_openbb_script
34
+ from openbb_cli.controllers.utils import (
35
+ bootup,
36
+ first_time_user,
37
+ get_flair_and_username,
38
+ parse_and_split_input,
39
+ print_goodbye,
40
+ print_rich_table,
41
+ reset,
42
+ suppress_stdout,
43
+ welcome_message,
44
+ )
45
+ from openbb_cli.session import Session
46
+ from prompt_toolkit.formatted_text import HTML
47
+ from prompt_toolkit.styles import Style
48
+ from pydantic import BaseModel
49
+
50
+ PLATFORM_ROUTERS = {
51
+ d: "menu" if not isinstance(getattr(obb, d), BaseModel) else "command"
52
+ for d in dir(obb)
53
+ if "_" not in d
54
+ }
55
+ NON_DATA_ROUTERS = ["coverage", "account", "reference", "system", "user"]
56
+ DATA_PROCESSING_ROUTERS = ["technical", "quantitative", "econometrics"]
57
+
58
+ # pylint: disable=too-many-public-methods,import-outside-toplevel, too-many-function-args
59
+ # pylint: disable=too-many-branches,no-member,C0302,too-many-return-statements, inconsistent-return-statements
60
+
61
+ env_file = str(ENV_FILE_SETTINGS)
62
+ session = Session()
63
+
64
+
65
+ class CLIController(BaseController):
66
+ """CLI Controller class."""
67
+
68
+ CHOICES_COMMANDS = ["record", "stop", "exe", "results"]
69
+ CHOICES_MENUS = [
70
+ "settings",
71
+ ]
72
+
73
+ for router, value in PLATFORM_ROUTERS.items():
74
+ if value == "menu":
75
+ CHOICES_MENUS.append(router)
76
+ else:
77
+ CHOICES_COMMANDS.append(router)
78
+
79
+ PATH = "/"
80
+ CHOICES_GENERATION = False
81
+
82
+ def __init__(self, jobs_cmds: Optional[List[str]] = None):
83
+ """Construct CLI controller."""
84
+ self.ROUTINE_FILES: Dict[str, str] = dict()
85
+ self.ROUTINE_DEFAULT_FILES: Dict[str, str] = dict()
86
+ self.ROUTINE_PERSONAL_FILES: Dict[str, str] = dict()
87
+ self.ROUTINE_CHOICES: Dict[str, Any] = dict()
88
+
89
+ super().__init__(jobs_cmds)
90
+
91
+ self.queue: List[str] = list()
92
+
93
+ if jobs_cmds:
94
+ self.queue = parse_and_split_input(
95
+ an_input=" ".join(jobs_cmds), custom_filters=[]
96
+ )
97
+
98
+ self.update_success = False
99
+
100
+ self._generate_platform_commands()
101
+
102
+ self.update_runtime_choices()
103
+
104
+ def _generate_platform_commands(self):
105
+ """Generate Platform based commands/menus."""
106
+
107
+ def method_call_class(self, _, controller, name, parent_path, target):
108
+ self.queue = self.load_class(
109
+ controller, name, parent_path, target, self.queue
110
+ )
111
+
112
+ # pylint: disable=unused-argument
113
+ def method_call_command(self, _, router: str):
114
+ """Call command."""
115
+ mdl = getattr(obb, router)
116
+ df = pd.DataFrame.from_dict(mdl.model_dump(), orient="index")
117
+ if isinstance(df.columns, pd.RangeIndex):
118
+ df.columns = [str(i) for i in df.columns]
119
+ return print_rich_table(df, show_index=True)
120
+
121
+ for router, value in PLATFORM_ROUTERS.items():
122
+ target = getattr(obb, router)
123
+
124
+ if value == "menu":
125
+ pcf = PlatformControllerFactory(
126
+ target, reference=obb.reference["paths"] # type: ignore
127
+ )
128
+ DynamicController = pcf.create()
129
+
130
+ # Bind the method to the class
131
+ bound_method = MethodType(method_call_class, self)
132
+
133
+ # Update the wrapper and set the attribute
134
+ bound_method = update_wrapper( # type: ignore
135
+ partial(
136
+ bound_method,
137
+ controller=DynamicController,
138
+ name=router,
139
+ target=target,
140
+ parent_path=self.path,
141
+ ),
142
+ method_call_class,
143
+ )
144
+ else:
145
+ bound_method = MethodType(method_call_command, self)
146
+ bound_method = update_wrapper( # type: ignore
147
+ partial(bound_method, router=router),
148
+ method_call_command,
149
+ )
150
+
151
+ setattr(self, f"call_{router}", bound_method)
152
+
153
+ def update_runtime_choices(self):
154
+ """Update runtime choices."""
155
+ routines_directory = Path(session.user.preferences.export_directory, "routines")
156
+
157
+ if session.prompt_session and session.settings.USE_PROMPT_TOOLKIT:
158
+ # choices: dict = self.choices_default
159
+ choices: dict = {c: {} for c in self.controller_choices} # type: ignore
160
+
161
+ self.ROUTINE_FILES = {
162
+ filepath.name: filepath # type: ignore
163
+ for filepath in routines_directory.rglob("*.openbb")
164
+ }
165
+ self.ROUTINE_DEFAULT_FILES = {
166
+ filepath.name: filepath # type: ignore
167
+ for filepath in Path(routines_directory / "hub" / "default").rglob(
168
+ "*.openbb"
169
+ )
170
+ }
171
+ self.ROUTINE_PERSONAL_FILES = {
172
+ filepath.name: filepath # type: ignore
173
+ for filepath in Path(routines_directory / "hub" / "personal").rglob(
174
+ "*.openbb"
175
+ )
176
+ }
177
+
178
+ choices["exe"] = {
179
+ "--file": {
180
+ filename: {} for filename in list(self.ROUTINE_FILES.keys())
181
+ },
182
+ "-f": "--file",
183
+ "--example": None,
184
+ "-e": "--example",
185
+ "--input": None,
186
+ "-i": "--input",
187
+ "--url": None,
188
+ "--help": None,
189
+ "-h": "--help",
190
+ }
191
+ choices["record"] = {
192
+ "--name": None,
193
+ "-n": "--name",
194
+ "--description": None,
195
+ "-d": "--description",
196
+ "--public": None,
197
+ "-p": "--public",
198
+ "--tag1": {c: None for c in constants.SCRIPT_TAGS},
199
+ "--tag2": {c: None for c in constants.SCRIPT_TAGS},
200
+ "--tag3": {c: None for c in constants.SCRIPT_TAGS},
201
+ "--help": None,
202
+ "-h": "--help",
203
+ }
204
+ choices["stop"] = {"--help": None, "-h": "--help"}
205
+ choices["results"] = {
206
+ "--help": None,
207
+ "-h": "--help",
208
+ "--export": {c: None for c in ["csv", "json", "xlsx", "png", "jpg"]},
209
+ "--index": None,
210
+ "--key": None,
211
+ "--chart": None,
212
+ "--sheet_name": None,
213
+ }
214
+
215
+ self.update_completer(choices)
216
+
217
+ def print_help(self):
218
+ """Print help."""
219
+ mt = MenuText("")
220
+
221
+ mt.add_info("Configure the platform and manage your account")
222
+ for router, value in PLATFORM_ROUTERS.items():
223
+ if router not in NON_DATA_ROUTERS or router in ["reference", "coverage"]:
224
+ continue
225
+ if value == "menu":
226
+ menu_description = (
227
+ obb.reference["routers"] # type: ignore
228
+ .get(f"{self.PATH}{router}", {})
229
+ .get("description")
230
+ ) or ""
231
+ mt.add_menu(
232
+ name=router,
233
+ description=menu_description.split(".")[0].lower(),
234
+ )
235
+ else:
236
+ mt.add_cmd(router)
237
+
238
+ mt.add_info("\nConfigure your CLI")
239
+ mt.add_menu(
240
+ "settings",
241
+ description="enable and disable feature flags, preferences and settings",
242
+ )
243
+ mt.add_raw("\n")
244
+ mt.add_info("Record and execute your own .openbb routine scripts")
245
+ mt.add_cmd("record", description="start recording current session")
246
+ mt.add_cmd(
247
+ "stop", description="stop session recording and convert to .openbb routine"
248
+ )
249
+ mt.add_cmd(
250
+ "exe",
251
+ description="execute .openbb routine scripts (use exe --example for an example)",
252
+ )
253
+ mt.add_raw("\n")
254
+ mt.add_info("Retrieve data from different asset classes and providers")
255
+
256
+ for router, value in PLATFORM_ROUTERS.items():
257
+ if router in NON_DATA_ROUTERS or router in DATA_PROCESSING_ROUTERS:
258
+ continue
259
+ if value == "menu":
260
+ menu_description = (
261
+ obb.reference["routers"] # type: ignore
262
+ .get(f"{self.PATH}{router}", {})
263
+ .get("description")
264
+ ) or ""
265
+ mt.add_menu(
266
+ name=router,
267
+ description=menu_description.split(".")[0].lower(),
268
+ )
269
+ else:
270
+ mt.add_cmd(router)
271
+
272
+ if any(router in PLATFORM_ROUTERS for router in DATA_PROCESSING_ROUTERS):
273
+ mt.add_info("\nAnalyze and process previously obtained data")
274
+
275
+ for router, value in PLATFORM_ROUTERS.items():
276
+ if router not in DATA_PROCESSING_ROUTERS:
277
+ continue
278
+ if value == "menu":
279
+ menu_description = (
280
+ obb.reference["routers"] # type: ignore
281
+ .get(f"{self.PATH}{router}", {})
282
+ .get("description")
283
+ ) or ""
284
+ mt.add_menu(
285
+ name=router,
286
+ description=menu_description.split(".")[0].lower(),
287
+ )
288
+ else:
289
+ mt.add_cmd(router)
290
+
291
+ mt.add_raw("\n")
292
+ mt.add_cmd("results")
293
+ if session.obbject_registry.obbjects:
294
+ mt.add_info("\nCached Results")
295
+ for key, value in list(session.obbject_registry.all.items())[ # type: ignore
296
+ : session.settings.N_TO_DISPLAY_OBBJECT_REGISTRY
297
+ ]:
298
+ mt.add_raw(
299
+ f"[yellow]OBB{key}[/yellow]: {value['command']}", # type: ignore[index]
300
+ left_spacing=True,
301
+ )
302
+
303
+ session.console.print(text=mt.menu_text, menu="Home")
304
+ self.update_runtime_choices()
305
+
306
+ def parse_input(self, an_input: str) -> List:
307
+ """Overwrite the BaseController parse_input for `askobb` and 'exe'.
308
+
309
+ This will allow us to search for something like "P/E" ratio.
310
+ """
311
+ # Filtering out sorting parameters with forward slashes like P/E
312
+ sort_filter = r"((\ -q |\ --question|\ ).*?(/))"
313
+ # Filter out urls
314
+ url = r"(exe (--url )?(https?://)?my\.openbb\.(dev|co)/u/.*/routine/.*)"
315
+ custom_filters = [sort_filter, url]
316
+ return parse_and_split_input(an_input=an_input, custom_filters=custom_filters)
317
+
318
+ def call_settings(self, _):
319
+ """Process settings command."""
320
+ from openbb_cli.controllers.settings_controller import (
321
+ SettingsController,
322
+ )
323
+
324
+ self.queue = self.load_class(SettingsController, self.queue)
325
+
326
+ def call_exe(self, other_args: List[str]):
327
+ """Process exe command."""
328
+ # Merge rest of string path to other_args and remove queue since it is a dir
329
+ other_args += self.queue
330
+
331
+ if not other_args:
332
+ session.console.print(
333
+ "[info]Provide a path to the routine you wish to execute. For an example, please use "
334
+ "`exe --example`.\n[/info]"
335
+ )
336
+ return
337
+ parser = argparse.ArgumentParser(
338
+ add_help=False,
339
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
340
+ prog="exe",
341
+ description="Execute automated routine script. For an example, please use "
342
+ "`exe --example`.",
343
+ )
344
+ parser.add_argument(
345
+ "--file",
346
+ "-f",
347
+ help="The path or .openbb file to run.",
348
+ dest="file",
349
+ required="-h" not in other_args
350
+ and "--help" not in other_args
351
+ and "-e" not in other_args
352
+ and "--example" not in other_args
353
+ and "--url" not in other_args
354
+ and "my.openbb" not in other_args[0],
355
+ type=str,
356
+ nargs="+",
357
+ )
358
+ parser.add_argument(
359
+ "-i",
360
+ "--input",
361
+ help="Select multiple inputs to be replaced in the routine and separated by commas. E.g. GME,AMC,BTC-USD",
362
+ dest="routine_args",
363
+ type=str,
364
+ )
365
+ parser.add_argument(
366
+ "-e",
367
+ "--example",
368
+ help="Run an example script to understand how routines can be used.",
369
+ dest="example",
370
+ action="store_true",
371
+ default=False,
372
+ )
373
+ parser.add_argument(
374
+ "--url", help="URL to run openbb script from.", dest="url", type=str
375
+ )
376
+ if other_args and "-" not in other_args[0][0]:
377
+ if other_args[0].startswith("my.") or other_args[0].startswith("http"):
378
+ other_args.insert(0, "--url")
379
+ else:
380
+ other_args.insert(0, "--file")
381
+ ns_parser = self.parse_known_args_and_warn(parser, other_args)
382
+ if ns_parser:
383
+ if ns_parser.example:
384
+ routine_path = ASSETS_DIRECTORY / "routines" / "routine_example.openbb"
385
+ session.console.print( # TODO: Point to docs when ready
386
+ "[info]Executing an example, please visit our docs "
387
+ "to learn how to create your own script.[/info]\n"
388
+ )
389
+ time.sleep(3)
390
+ elif ns_parser.url:
391
+ if not ns_parser.url.startswith(
392
+ "https"
393
+ ) and not ns_parser.url.startswith("http:"):
394
+ url = "https://" + ns_parser.url
395
+ elif ns_parser.url.startswith("http://"):
396
+ url = ns_parser.url.replace("http://", "https://")
397
+ else:
398
+ url = ns_parser.url
399
+ username = url.split("/")[-3]
400
+ script_name = url.split("/")[-1]
401
+ file_name = f"{username}_{script_name}.openbb"
402
+ final_url = f"{url}?raw=true"
403
+ response = requests.get(final_url, timeout=10)
404
+ if response.status_code != 200:
405
+ session.console.print(
406
+ "[red]Could not find the requested script.[/red]"
407
+ )
408
+ return
409
+ routine_text = response.json()["script"]
410
+ file_path = Path(session.user.preferences.export_directory, "routines")
411
+ routine_path = file_path / file_name
412
+ with open(routine_path, "w") as file:
413
+ file.write(routine_text)
414
+ self.update_runtime_choices()
415
+
416
+ elif ns_parser.file:
417
+ file_path = " ".join(ns_parser.file) # type: ignore
418
+ # if string is not in this format "default/file.openbb" then check for files in ROUTINE_FILES
419
+ full_path = file_path
420
+ hub_routine = file_path.split("/") # type: ignore
421
+ # Change with: my.openbb.co
422
+ if hub_routine[0] == "default":
423
+ routine_path = Path(
424
+ self.ROUTINE_DEFAULT_FILES.get(hub_routine[1], full_path)
425
+ )
426
+ elif hub_routine[0] == "personal":
427
+ routine_path = Path(
428
+ self.ROUTINE_PERSONAL_FILES.get(hub_routine[1], full_path)
429
+ )
430
+ else:
431
+ routine_path = Path(self.ROUTINE_FILES.get(file_path, full_path)) # type: ignore
432
+ else:
433
+ return
434
+
435
+ try:
436
+ with open(routine_path) as fp:
437
+ raw_lines = list(fp)
438
+
439
+ script_inputs = []
440
+ # Capture ARGV either as list if args separated by commas or as single value
441
+ if routine_args := ns_parser.routine_args:
442
+ pattern = r"\[(.*?)\]"
443
+ matches = re.findall(pattern, routine_args)
444
+
445
+ for match in matches:
446
+ routine_args = routine_args.replace(f"[{match}]", "")
447
+ script_inputs.append(match)
448
+
449
+ script_inputs.extend(
450
+ [val for val in routine_args.split(",") if val]
451
+ )
452
+
453
+ err, parsed_script = parse_openbb_script(
454
+ raw_lines=raw_lines, script_inputs=script_inputs
455
+ )
456
+
457
+ # If there err output is not an empty string then it means there was an
458
+ # issue in parsing the routine and therefore we don't want to feed it
459
+ # to the terminal
460
+ if err:
461
+ session.console.print(err)
462
+ return
463
+
464
+ self.queue = [
465
+ val
466
+ for val in parse_and_split_input(
467
+ an_input=parsed_script, custom_filters=[]
468
+ )
469
+ if val
470
+ ]
471
+
472
+ if "export" in self.queue[0]:
473
+ export_path = self.queue[0].split(" ")[1]
474
+ # If the path selected does not start from the user root, give relative location from root
475
+ if export_path[0] == "~":
476
+ export_path = export_path.replace(
477
+ "~", HOME_DIRECTORY.as_posix()
478
+ )
479
+ elif export_path[0] != "/":
480
+ export_path = os.path.join(
481
+ os.path.dirname(os.path.abspath(__file__)), export_path
482
+ )
483
+
484
+ # Check if the directory exists
485
+ if os.path.isdir(export_path):
486
+ session.console.print(
487
+ f"Export data to be saved in the selected folder: '{export_path}'"
488
+ )
489
+ else:
490
+ os.makedirs(export_path)
491
+ session.console.print(
492
+ f"[green]Folder '{export_path}' successfully created.[/green]"
493
+ )
494
+ self.queue = self.queue[1:]
495
+
496
+ except FileNotFoundError:
497
+ session.console.print(
498
+ f"[red]File '{routine_path}' doesn't exist.[/red]"
499
+ )
500
+ return
501
+
502
+
503
+ def handle_job_cmds(jobs_cmds: Optional[List[str]]) -> Optional[List[str]]:
504
+ """Handle job commands."""
505
+ export_path = ""
506
+ if jobs_cmds and "export" in jobs_cmds[0]:
507
+ commands = jobs_cmds[0].split("/")
508
+ first_split = commands[0].split(" ")
509
+ if len(first_split) > 1:
510
+ export_path = first_split[1]
511
+ jobs_cmds = ["/".join(commands[1:])]
512
+ if not export_path:
513
+ return jobs_cmds
514
+ if export_path[0] == "~":
515
+ export_path = export_path.replace("~", HOME_DIRECTORY.as_posix())
516
+ elif export_path[0] != "/":
517
+ export_path = os.path.join(
518
+ os.path.dirname(os.path.abspath(__file__)), export_path
519
+ )
520
+
521
+ # Check if the directory exists
522
+ if os.path.isdir(export_path):
523
+ session.console.print(
524
+ f"Export data to be saved in the selected folder: '{export_path}'"
525
+ )
526
+ else:
527
+ os.makedirs(export_path)
528
+ session.console.print(
529
+ f"[green]Folder '{export_path}' successfully created.[/green]"
530
+ )
531
+ return jobs_cmds
532
+
533
+
534
+ # pylint: disable=unused-argument
535
+ def run_cli(jobs_cmds: Optional[List[str]] = None, test_mode=False):
536
+ """Run the CLI menu."""
537
+ ret_code = 1
538
+ t_controller = CLIController(jobs_cmds)
539
+ an_input = ""
540
+
541
+ jobs_cmds = handle_job_cmds(jobs_cmds)
542
+
543
+ bootup()
544
+ if not jobs_cmds:
545
+ welcome_message()
546
+
547
+ if first_time_user():
548
+ with contextlib.suppress(EOFError):
549
+ webbrowser.open("https://docs.openbb.co/cli")
550
+
551
+ t_controller.print_help()
552
+
553
+ while ret_code:
554
+ # There is a command in the queue
555
+ if t_controller.queue and len(t_controller.queue) > 0:
556
+ # If the command is quitting the menu we want to return in here
557
+ if t_controller.queue[0] in ("q", "..", "quit"):
558
+ print_goodbye()
559
+ break
560
+
561
+ # Consume 1 element from the queue
562
+ an_input = t_controller.queue[0]
563
+ t_controller.queue = t_controller.queue[1:]
564
+
565
+ # Print the current location because this was an instruction and we want user to know what was the action
566
+ if an_input and an_input.split(" ")[0] in t_controller.CHOICES_COMMANDS:
567
+ session.console.print(f"{get_flair_and_username()} / $ {an_input}")
568
+
569
+ # Get input command from user
570
+ else:
571
+ try:
572
+ # Get input from user using auto-completion
573
+ if session.prompt_session and session.settings.USE_PROMPT_TOOLKIT:
574
+ # Check if toolbar hint was enabled
575
+ if session.settings.TOOLBAR_HINT:
576
+ an_input = session.prompt_session.prompt( # type: ignore[union-attr]
577
+ f"{get_flair_and_username()} / $ ",
578
+ completer=t_controller.completer,
579
+ search_ignore_case=True,
580
+ bottom_toolbar=HTML(
581
+ '<style bg="ansiblack" fg="ansiwhite">[h]</style> help menu '
582
+ '<style bg="ansiblack" fg="ansiwhite">[q]</style> return to previous menu '
583
+ '<style bg="ansiblack" fg="ansiwhite">[e]</style> exit the program '
584
+ '<style bg="ansiblack" fg="ansiwhite">[cmd -h]</style> '
585
+ "see usage and available options "
586
+ ),
587
+ style=Style.from_dict(
588
+ {
589
+ "bottom-toolbar": "#ffffff bg:#333333",
590
+ }
591
+ ),
592
+ )
593
+ else:
594
+ an_input = session.prompt_session.prompt( # type: ignore[union-attr]
595
+ f"{get_flair_and_username()} / $ ",
596
+ completer=t_controller.completer,
597
+ search_ignore_case=True,
598
+ )
599
+
600
+ # Get input from user without auto-completion
601
+ else:
602
+ an_input = input(f"{get_flair_and_username()} / $ ")
603
+
604
+ except (KeyboardInterrupt, EOFError):
605
+ print_goodbye()
606
+ break
607
+
608
+ try:
609
+ # Process the input command
610
+ t_controller.queue = t_controller.switch(an_input)
611
+
612
+ if an_input in ("q", "quit", "..", "exit", "e"):
613
+ print_goodbye()
614
+ break
615
+
616
+ # Check if the user wants to reset application
617
+ if an_input in ("r", "reset") or t_controller.update_success:
618
+ reset(t_controller.queue if t_controller.queue else [])
619
+ break
620
+
621
+ except SystemExit:
622
+ session.console.print(
623
+ f"[red]The command '{an_input}' doesn't exist on the / menu.[/red]\n",
624
+ )
625
+ similar_cmd = difflib.get_close_matches(
626
+ an_input.split(" ")[0] if " " in an_input else an_input,
627
+ t_controller.controller_choices,
628
+ n=1,
629
+ cutoff=0.7,
630
+ )
631
+ if similar_cmd:
632
+ an_input = similar_cmd[0]
633
+ if " " in an_input:
634
+ candidate_input = (
635
+ f"{similar_cmd[0]} {' '.join(an_input.split(' ')[1:])}"
636
+ )
637
+ if candidate_input == an_input:
638
+ an_input = ""
639
+ t_controller.queue = []
640
+ session.console.print("\n")
641
+ continue
642
+ an_input = candidate_input
643
+
644
+ session.console.print(f"[green]Replacing by '{an_input}'.[/green]")
645
+ t_controller.queue.insert(0, an_input)
646
+
647
+
648
+ def insert_start_slash(cmds: List[str]) -> List[str]:
649
+ """Insert a slash at the beginning of a command sequence."""
650
+ if not cmds[0].startswith("/"):
651
+ cmds[0] = f"/{cmds[0]}"
652
+ if cmds[0].startswith("/home"):
653
+ cmds[0] = f"/{cmds[0][5:]}"
654
+ return cmds
655
+
656
+
657
+ def run_scripts(
658
+ path: Path,
659
+ test_mode: bool = False,
660
+ verbose: bool = False,
661
+ routines_args: Optional[List[str]] = None,
662
+ special_arguments: Optional[Dict[str, str]] = None,
663
+ output: bool = True,
664
+ ):
665
+ """Run given .openbb scripts.
666
+
667
+ Parameters
668
+ ----------
669
+ path : str
670
+ The location of the .openbb file
671
+ test_mode : bool
672
+ Whether the CLI is in test mode
673
+ verbose : bool
674
+ Whether to run tests in verbose mode
675
+ routines_args : List[str]
676
+ One or multiple inputs to be replaced in the routine and separated by commas.
677
+ E.g. GME,AMC,BTC-USD
678
+ special_arguments: Optional[Dict[str, str]]
679
+ Replace `${key=default}` with `value` for every key in the dictionary
680
+ output: bool
681
+ Whether to log tests to txt files
682
+ """
683
+ if not path.exists():
684
+ session.console.print(f"File '{path}' doesn't exist. Launching base CLI.\n")
685
+ if not test_mode:
686
+ run_cli()
687
+
688
+ # THIS NEEDS TO BE REFACTORED!!! - ITS USED FOR TESTING
689
+ with path.open() as fp:
690
+ raw_lines = [x for x in fp if (not is_reset(x)) and ("#" not in x) and x]
691
+ raw_lines = [
692
+ raw_line.strip("\n") for raw_line in raw_lines if raw_line.strip("\n")
693
+ ]
694
+
695
+ if routines_args:
696
+ lines = []
697
+ for rawline in raw_lines:
698
+ templine = rawline
699
+ for i, arg in enumerate(routines_args):
700
+ templine = templine.replace(f"$ARGV[{i}]", arg)
701
+ lines.append(templine)
702
+ # Handle new testing arguments:
703
+ elif special_arguments:
704
+ lines = []
705
+ for line in raw_lines:
706
+ new_line = re.sub(
707
+ r"\${[^{]+=[^{]+}",
708
+ lambda x: replace_dynamic(x, special_arguments), # type: ignore
709
+ line,
710
+ )
711
+ lines.append(new_line)
712
+
713
+ else:
714
+ lines = raw_lines
715
+
716
+ if test_mode and "exit" not in lines[-1]:
717
+ lines.append("exit")
718
+
719
+ # Deals with the export with a path with "/" in it
720
+ export_folder = ""
721
+ if "export" in lines[0]:
722
+ export_folder = lines[0].split("export ")[1].rstrip()
723
+ lines = lines[1:]
724
+
725
+ simulate_argv = f"/{'/'.join([line.rstrip() for line in lines])}"
726
+ file_cmds = simulate_argv.replace("//", "/home/").split()
727
+ file_cmds = insert_start_slash(file_cmds) if file_cmds else file_cmds
728
+ file_cmds = (
729
+ [f"export {export_folder}{' '.join(file_cmds)}"]
730
+ if export_folder
731
+ else [" ".join(file_cmds)]
732
+ )
733
+
734
+ if not test_mode or verbose:
735
+ run_cli(file_cmds, test_mode=True)
736
+ else:
737
+ with suppress_stdout():
738
+ session.console.print(f"To ensure: {output}")
739
+ if output:
740
+ timestamp = datetime.now().timestamp()
741
+ stamp_str = str(timestamp).replace(".", "")
742
+ whole_path = Path(REPOSITORY_DIRECTORY / "integration_test_output")
743
+ whole_path.mkdir(parents=True, exist_ok=True)
744
+ first_cmd = file_cmds[0].split("/")[1]
745
+ with open(
746
+ whole_path / f"{stamp_str}_{first_cmd}_output.txt", "w"
747
+ ) as output_file, contextlib.redirect_stdout(output_file):
748
+ run_cli(file_cmds, test_mode=True)
749
+ else:
750
+ run_cli(file_cmds, test_mode=True)
751
+
752
+
753
+ def replace_dynamic(match: re.Match, special_arguments: Dict[str, str]) -> str:
754
+ """Replace ${key=default} with value in special_arguments if it exists, else with default.
755
+
756
+ Parameters
757
+ ----------
758
+ match: re.Match[str]
759
+ The match object
760
+ special_arguments: Dict[str, str]
761
+ The key value pairs to replace in the scripts
762
+
763
+ Returns
764
+ ----------
765
+ str
766
+ The new string
767
+ """
768
+ cleaned = match[0].replace("{", "").replace("}", "").replace("$", "")
769
+ key, default = cleaned.split("=")
770
+ dict_value = special_arguments.get(key, default)
771
+ if dict_value:
772
+ return dict_value
773
+ return default
774
+
775
+
776
+ def run_routine(file: str, routines_args=Optional[str]):
777
+ """Execute command routine from .openbb file."""
778
+ user_routine_path = Path(session.user.preferences.export_directory, "routines")
779
+ default_routine_path = ASSETS_DIRECTORY / "routines" / file
780
+
781
+ if user_routine_path.exists():
782
+ run_scripts(path=user_routine_path, routines_args=routines_args)
783
+ elif default_routine_path.exists():
784
+ run_scripts(path=default_routine_path, routines_args=routines_args)
785
+ else:
786
+ session.console.print(
787
+ f"Routine not found, please put your `.openbb` file into : {user_routine_path}."
788
+ )
789
+
790
+
791
+ # pylint: disable=unused-argument
792
+ def main(
793
+ debug: bool,
794
+ dev: bool,
795
+ path_list: List[str],
796
+ routines_args: Optional[List[str]] = None,
797
+ **kwargs,
798
+ ):
799
+ """Run the CLI with various options.
800
+
801
+ Parameters
802
+ ----------
803
+ debug : bool
804
+ Whether to run the CLI in debug mode
805
+ dev:
806
+ Points backend towards development environment instead of production
807
+ test : bool
808
+ Whether to run the CLI in integrated test mode
809
+ filtert : str
810
+ Filter test files with given string in name
811
+ paths : List[str]
812
+ The paths to run for scripts or to test
813
+ verbose : bool
814
+ Whether to show output from tests
815
+ routines_args : List[str]
816
+ One or multiple inputs to be replaced in the routine and separated by commas.
817
+ E.g. GME,AMC,BTC-USD
818
+ """
819
+ if debug:
820
+ session.settings.DEBUG_MODE = True
821
+
822
+ if dev:
823
+ session.settings.DEV_BACKEND = True
824
+ session.settings.BASE_URL = "https://payments.openbb.dev/"
825
+ session.settings.HUB_URL = "https://my.openbb.dev"
826
+
827
+ if isinstance(path_list, list) and path_list[0].endswith(".openbb"):
828
+ run_routine(file=path_list[0], routines_args=routines_args)
829
+ elif path_list:
830
+ argv_cmds = list([" ".join(path_list).replace(" /", "/home/")])
831
+ argv_cmds = insert_start_slash(argv_cmds) if argv_cmds else argv_cmds
832
+ run_cli(argv_cmds)
833
+ else:
834
+ run_cli()
835
+
836
+
837
+ def parse_args_and_run():
838
+ """Parse input arguments and run CLI."""
839
+ parser = argparse.ArgumentParser(
840
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
841
+ prog="cli",
842
+ description="The OpenBB Platform CLI.",
843
+ )
844
+ parser.add_argument(
845
+ "-d",
846
+ "--debug",
847
+ dest="debug",
848
+ action="store_true",
849
+ default=False,
850
+ help="Runs the CLI in debug mode.",
851
+ )
852
+ parser.add_argument(
853
+ "--dev",
854
+ dest="dev",
855
+ action="store_true",
856
+ default=False,
857
+ help="Points backend towards development environment instead of production",
858
+ )
859
+ parser.add_argument(
860
+ "--file",
861
+ help="The path or .openbb file to run.",
862
+ dest="path",
863
+ nargs="+",
864
+ default="",
865
+ type=str,
866
+ )
867
+ parser.add_argument(
868
+ "-i",
869
+ "--input",
870
+ help=(
871
+ "Select multiple inputs to be replaced in the routine and separated by commas."
872
+ "E.g. GME,AMC,BTC-USD"
873
+ ),
874
+ dest="routine_args",
875
+ type=lambda s: [str(item) for item in s.split(",")],
876
+ default=None,
877
+ )
878
+ parser.add_argument(
879
+ "-t",
880
+ "--test",
881
+ action="store_true",
882
+ help=(
883
+ "Run the CLI in testing mode. Also run this option and '-h'"
884
+ " to see testing argument options."
885
+ ),
886
+ )
887
+ # The args -m, -f and --HistoryManager.hist_file are used only in reports menu
888
+ # by papermill and that's why they have suppress help.
889
+ parser.add_argument(
890
+ "-m",
891
+ help=argparse.SUPPRESS,
892
+ dest="module",
893
+ default="",
894
+ type=str,
895
+ )
896
+ parser.add_argument(
897
+ "-f",
898
+ help=argparse.SUPPRESS,
899
+ dest="module_file",
900
+ default="",
901
+ type=str,
902
+ )
903
+ parser.add_argument(
904
+ "--HistoryManager.hist_file",
905
+ help=argparse.SUPPRESS,
906
+ dest="module_hist_file",
907
+ default="",
908
+ type=str,
909
+ )
910
+ if sys.argv[1:] and "-" not in sys.argv[1][0]:
911
+ sys.argv.insert(1, "--file")
912
+ ns_parser, unknown = parser.parse_known_args()
913
+
914
+ # This ensures that if cli.py receives unknown args it will not start.
915
+ # Use -d flag if you want to see the unknown args.
916
+ if unknown:
917
+ if ns_parser.debug:
918
+ session.console.print(unknown)
919
+ else:
920
+ sys.exit(-1)
921
+
922
+ main(
923
+ ns_parser.debug,
924
+ ns_parser.dev,
925
+ ns_parser.path,
926
+ ns_parser.routine_args,
927
+ module=ns_parser.module,
928
+ module_file=ns_parser.module_file,
929
+ module_hist_file=ns_parser.module_hist_file,
930
+ )
931
+
932
+
933
+ def launch(
934
+ debug: bool = False, dev: bool = False, queue: Optional[List[str]] = None
935
+ ) -> None:
936
+ """Launch CLI."""
937
+ if queue:
938
+ main(debug, dev, queue, module="")
939
+ else:
940
+ parse_args_and_run()
941
+
942
+
943
+ if __name__ == "__main__":
944
+ parse_args_and_run()
cli/openbb_cli/controllers/hub_service.py ADDED
@@ -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
cli/openbb_cli/controllers/platform_controller_factory.py ADDED
@@ -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
cli/openbb_cli/controllers/script_parser.py ADDED
@@ -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
cli/openbb_cli/controllers/settings_controller.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Settings Controller Module."""
2
+
3
+ import argparse
4
+ from functools import partial, update_wrapper
5
+ from types import MethodType
6
+ from typing import List, Literal, Optional, get_origin
7
+
8
+ from openbb_cli.config.menu_text import MenuText
9
+ from openbb_cli.controllers.base_controller import BaseController
10
+ from openbb_cli.models.settings import SettingGroups
11
+ from openbb_cli.session import Session
12
+
13
+ session = Session()
14
+
15
+
16
+ class SettingsController(BaseController):
17
+ """Settings Controller class."""
18
+
19
+ _COMMANDS = {
20
+ v.json_schema_extra.get("command"): {
21
+ "command": (v.json_schema_extra or {}).get("command"),
22
+ "group": (v.json_schema_extra or {}).get("group"),
23
+ "description": v.description,
24
+ "annotation": v.annotation,
25
+ "field_name": k,
26
+ }
27
+ for k, v in sorted(
28
+ session.settings.model_fields.items(),
29
+ key=lambda item: (item[1].json_schema_extra or {}).get("command", ""),
30
+ )
31
+ if v.json_schema_extra
32
+ }
33
+ CHOICES_COMMANDS: List[str] = list(_COMMANDS.keys())
34
+ PATH = "/settings/"
35
+ CHOICES_GENERATION = True
36
+
37
+ def __init__(self, queue: Optional[List[str]] = None):
38
+ """Initialize the Constructor."""
39
+ super().__init__(queue)
40
+ for cmd, field in self._COMMANDS.items():
41
+ group = field.get("group")
42
+ if group == SettingGroups.feature_flags:
43
+ self._generate_command(cmd, field, "toggle")
44
+ elif group == SettingGroups.preferences:
45
+ self._generate_command(cmd, field, "set")
46
+ self.update_completer(self.choices_default)
47
+
48
+ def print_help(self):
49
+ """Print help."""
50
+ mt = MenuText("settings/")
51
+ mt.add_info("Feature Flags")
52
+ for k, f in self._COMMANDS.items():
53
+ if f.get("group") == SettingGroups.feature_flags:
54
+ mt.add_setting(
55
+ name=k,
56
+ status=getattr(session.settings, f["field_name"]),
57
+ description=f["description"],
58
+ )
59
+ mt.add_raw("\n")
60
+ mt.add_info("Preferences")
61
+ for k, f in self._COMMANDS.items():
62
+ if f.get("group") == SettingGroups.preferences:
63
+ mt.add_cmd(
64
+ name=k,
65
+ description=f["description"],
66
+ )
67
+ session.console.print(text=mt.menu_text, menu="Settings")
68
+
69
+ def _generate_command(
70
+ self, cmd_name: str, field: dict, action_type: Literal["toggle", "set"]
71
+ ):
72
+ """Generate command call."""
73
+
74
+ def _toggle(self, other_args: List[str], field=field) -> None:
75
+ """Toggle setting value."""
76
+ field_name = field["field_name"]
77
+ parser = argparse.ArgumentParser(
78
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
79
+ prog=field["command"],
80
+ description=field["description"],
81
+ add_help=False,
82
+ )
83
+ ns_parser, _ = self.parse_simple_args(parser, other_args)
84
+ if ns_parser:
85
+ session.settings.set_item(
86
+ field_name, not getattr(session.settings, field_name)
87
+ )
88
+
89
+ def _set(self, other_args: List[str], field=field) -> None:
90
+ """Set preference value."""
91
+ field_name = field["field_name"]
92
+ annotation = field["annotation"]
93
+ command = field["command"]
94
+ type_ = str if get_origin(annotation) is Literal else annotation
95
+ choices = None
96
+ if get_origin(annotation) is Literal:
97
+ choices = annotation.__args__
98
+ elif command == "console_style":
99
+ # To have updated choices for console style
100
+ choices = session.style.available_styles
101
+ parser = argparse.ArgumentParser(
102
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
103
+ prog=command,
104
+ description=field["description"],
105
+ add_help=False,
106
+ )
107
+ parser.add_argument(
108
+ "-v",
109
+ "--value",
110
+ dest="value",
111
+ action="store",
112
+ required=False,
113
+ type=type_, # type: ignore[arg-type]
114
+ choices=choices,
115
+ )
116
+ ns_parser, _ = self.parse_simple_args(parser, other_args)
117
+ if ns_parser:
118
+ if ns_parser.value:
119
+ # Console style is applied immediately
120
+ if command == "console_style":
121
+ session.style.apply(ns_parser.value)
122
+ session.settings.set_item(field_name, ns_parser.value)
123
+ session.console.print(
124
+ f"[info]Current value:[/info] {getattr(session.settings, field_name)}"
125
+ )
126
+ elif not other_args:
127
+ session.console.print(
128
+ f"[info]Current value:[/info] {getattr(session.settings, field_name)}"
129
+ )
130
+
131
+ action = None
132
+ if action_type == "toggle":
133
+ action = _toggle
134
+ elif action_type == "set":
135
+ action = _set
136
+ else:
137
+ raise ValueError(f"Action type '{action_type}' not allowed.")
138
+
139
+ bound_method = update_wrapper(
140
+ wrapper=partial(MethodType(action, self), field=field), wrapped=action
141
+ )
142
+ setattr(self, f"call_{cmd_name}", bound_method)
cli/openbb_cli/controllers/utils.py ADDED
@@ -0,0 +1,1032 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_cli.config.constants import AVAILABLE_FLAIRS, ENV_FILE_SETTINGS
20
+ from openbb_cli.session import Session
21
+ from openbb_core.app.model.obbject import OBBject
22
+ from pytz import all_timezones, timezone
23
+ from rich.table import Table
24
+
25
+ if TYPE_CHECKING:
26
+ from openbb_charting.core.openbb_figure import OpenBBFigure
27
+
28
+ # pylint: disable=R1702,R0912
29
+
30
+
31
+ # pylint: disable=too-many-statements,no-member,too-many-branches,C0302
32
+
33
+ session = Session()
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 Workspace[/]: [cmds]https://openbb.co[/cmds]
87
+ [bold]OpenBB Platform:[/] [cmds]https://docs.openbb.co/platform[/cmds]
88
+ [bold]OpenBB Bot[/]: [cmds]https://docs.openbb.co/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
+ debug = session.settings.DEBUG_MODE
137
+ dev = session.settings.DEV_BACKEND
138
+
139
+ try:
140
+ # remove the hub routines
141
+ if not session.is_local():
142
+ remove_file(
143
+ Path(session.user.preferences.export_directory, "routines", "hub")
144
+ )
145
+
146
+ # if not get_current_user().profile.remember:
147
+ # Local.remove(HIST_FILE_PROMPT)
148
+
149
+ # we clear all openbb_cli modules from sys.modules
150
+ for module in list(sys.modules.keys()):
151
+ parts = module.split(".")
152
+ if parts[0] == "openbb_cli":
153
+ del sys.modules[module]
154
+
155
+ queue_list = ["/".join(queue) if len(queue) > 0 else ""] # type: ignore
156
+ # pylint: disable=import-outside-toplevel
157
+ # we run the cli again
158
+ if session.is_local():
159
+ from openbb_cli.controllers.cli_controller import main
160
+
161
+ main(debug, dev, queue_list, module="") # type: ignore
162
+ else:
163
+ from openbb_cli.controllers.cli_controller import launch
164
+
165
+ launch(queue=queue_list)
166
+
167
+ except Exception as e:
168
+ session.console.print(f"Unfortunately, resetting wasn't possible: {e}\n")
169
+ print_goodbye()
170
+
171
+
172
+ @contextmanager
173
+ def suppress_stdout():
174
+ """Suppress the stdout."""
175
+ with open(os.devnull, "w") as devnull:
176
+ old_stdout = sys.stdout
177
+ old_stderr = sys.stderr
178
+ sys.stdout = devnull
179
+ sys.stderr = devnull
180
+ try:
181
+ yield
182
+ finally:
183
+ sys.stdout = old_stdout
184
+ sys.stderr = old_stderr
185
+
186
+
187
+ def first_time_user() -> bool:
188
+ """Check whether a user is a first time user.
189
+
190
+ A first time user is someone with an empty .env file.
191
+ If this is true, it also adds an env variable to make sure this does not run again.
192
+
193
+ Returns
194
+ -------
195
+ bool
196
+ Whether or not the user is a first time user
197
+ """
198
+ if ENV_FILE_SETTINGS.stat().st_size == 0:
199
+ session.settings.set_item("PREVIOUS_USE", True)
200
+ return True
201
+ return False
202
+
203
+
204
+ def parse_and_split_input(an_input: str, custom_filters: List) -> List[str]:
205
+ """Filter and split the input queue.
206
+
207
+ Uses regex to filters command arguments that have forward slashes so that it doesn't
208
+ break the execution of the command queue.
209
+ Currently handles unix paths and sorting settings for screener menus.
210
+
211
+ Parameters
212
+ ----------
213
+ an_input : str
214
+ User input as string
215
+ custom_filters : List
216
+ Additional regular expressions to match
217
+
218
+ Returns
219
+ -------
220
+ List[str]
221
+ Command queue as list
222
+ """
223
+ # Make sure that the user can go back to the root when doing "/"
224
+ if an_input and an_input == "/":
225
+ an_input = "home"
226
+
227
+ # everything from ` -f ` to the next known extension
228
+ file_flag = r"(\ -f |\ --file )"
229
+ up_to = r".*?"
230
+ known_extensions = r"(\.(xlsx|csv|xls|tsv|json|yaml|ini|openbb|ipynb))"
231
+ unix_path_arg_exp = f"({file_flag}{up_to}{known_extensions})"
232
+
233
+ # Add custom expressions to handle edge cases of individual controllers
234
+ custom_filter = ""
235
+ for exp in custom_filters:
236
+ if exp is not None:
237
+ custom_filter += f"|{exp}"
238
+ del exp
239
+
240
+ slash_filter_exp = f"({unix_path_arg_exp}){custom_filter}"
241
+
242
+ filter_input = True
243
+ placeholders: Dict[str, str] = {}
244
+ while filter_input:
245
+ match = re.search(pattern=slash_filter_exp, string=an_input)
246
+ if match is not None:
247
+ placeholder = f"{{placeholder{len(placeholders)+1}}}"
248
+ placeholders[placeholder] = an_input[
249
+ match.span()[0] : match.span()[1] # noqa:E203
250
+ ]
251
+ an_input = (
252
+ an_input[: match.span()[0]]
253
+ + placeholder
254
+ + an_input[match.span()[1] :] # noqa:E203
255
+ )
256
+ else:
257
+ filter_input = False
258
+
259
+ commands = an_input.split("/") if "timezone" not in an_input else [an_input]
260
+
261
+ for command_num, command in enumerate(commands):
262
+ if command == commands[-1] == "":
263
+ return list(filter(None, commands))
264
+ matching_placeholders = [tag for tag in placeholders if tag in command]
265
+ if len(matching_placeholders) > 0:
266
+ for tag in matching_placeholders:
267
+ commands[command_num] = command.replace(tag, placeholders[tag])
268
+ return commands
269
+
270
+
271
+ def return_colored_value(value: str):
272
+ """Return the string value based on condition.
273
+
274
+ Return it with green, yellow, red or white color based on
275
+ whether the number is positive, negative, zero or other, respectively.
276
+
277
+ Parameters
278
+ ----------
279
+ value: str
280
+ string to be checked
281
+
282
+ Returns
283
+ -------
284
+ value: str
285
+ string with color based on value of number if it exists
286
+ """
287
+ values = re.findall(r"[-+]?(?:\d*\.\d+|\d+)", value)
288
+
289
+ # Finds exactly 1 number in the string
290
+ if len(values) == 1:
291
+ if float(values[0]) > 0:
292
+ return f"[green]{value}[/green]"
293
+
294
+ if float(values[0]) < 0:
295
+ return f"[red]{value}[/red]"
296
+
297
+ if float(values[0]) == 0:
298
+ return f"[yellow]{value}[/yellow]"
299
+
300
+ return f"{value}"
301
+
302
+
303
+ # pylint: disable=too-many-arguments,too-many-positional-arguments
304
+ def print_rich_table( # noqa: PLR0912
305
+ df: pd.DataFrame,
306
+ show_index: bool = False,
307
+ title: str = "",
308
+ index_name: str = "",
309
+ headers: Optional[Union[List[str], pd.Index]] = None,
310
+ floatfmt: Union[str, List[str]] = ".2f",
311
+ show_header: bool = True,
312
+ automatic_coloring: bool = False,
313
+ columns_to_auto_color: Optional[List[str]] = None,
314
+ rows_to_auto_color: Optional[List[str]] = None,
315
+ export: bool = False,
316
+ limit: Optional[int] = 1000,
317
+ columns_keep_types: Optional[List[str]] = None,
318
+ use_tabulate_df: bool = True,
319
+ ):
320
+ """Prepare a table from df in rich.
321
+
322
+ Parameters
323
+ ----------
324
+ df: pd.DataFrame
325
+ Dataframe to turn into table
326
+ show_index: bool
327
+ Whether to include index
328
+ title: str
329
+ Title for table
330
+ index_name : str
331
+ Title for index column
332
+ headers: List[str]
333
+ Titles for columns
334
+ floatfmt: Union[str, List[str]]
335
+ Float number formatting specs as string or list of strings. Defaults to ".2f"
336
+ show_header: bool
337
+ Whether to show the header row.
338
+ automatic_coloring: bool
339
+ Automatically color a table based on positive and negative values
340
+ columns_to_auto_color: List[str]
341
+ Columns to automatically color
342
+ rows_to_auto_color: List[str]
343
+ Rows to automatically color
344
+ export: bool
345
+ Whether we are exporting the table to a file. If so, we don't want to print it.
346
+ limit: Optional[int]
347
+ Limit the number of rows to show.
348
+ columns_keep_types: Optional[List[str]]
349
+ Columns to keep their types, i.e. not convert to numeric
350
+ """
351
+ if export:
352
+ return
353
+
354
+ MAX_COLS = session.settings.ALLOWED_NUMBER_OF_COLUMNS
355
+ MAX_ROWS = session.settings.ALLOWED_NUMBER_OF_ROWS
356
+
357
+ # Make a copy of the dataframe to avoid SettingWithCopyWarning
358
+ df = df.copy()
359
+
360
+ show_index = not isinstance(df.index, pd.RangeIndex) and show_index
361
+ # convert non-str that are not timestamp or int into str
362
+ # eg) praw.models.reddit.subreddit.Subreddit
363
+ for col in df.columns:
364
+ if columns_keep_types is not None and col in columns_keep_types:
365
+ continue
366
+ try:
367
+ if not any(
368
+ isinstance(df[col].iloc[x], pd.Timestamp)
369
+ for x in range(min(10, len(df)))
370
+ ):
371
+ df[col] = df[col].apply(pd.to_numeric)
372
+ except (ValueError, TypeError):
373
+ df[col] = df[col].astype(str)
374
+
375
+ def _get_headers(_headers: Union[List[str], pd.Index]) -> List[str]:
376
+ """Check if headers are valid and return them."""
377
+ output = _headers
378
+ if isinstance(_headers, pd.Index):
379
+ output = list(_headers)
380
+ if len(output) != len(df.columns):
381
+ raise ValueError("Length of headers does not match length of DataFrame.")
382
+ return output # type: ignore
383
+
384
+ if session.settings.USE_INTERACTIVE_DF:
385
+ df_outgoing = df.copy()
386
+ # If headers are provided, use them
387
+ if headers is not None:
388
+ # We check if headers are valid
389
+ df_outgoing.columns = _get_headers(headers)
390
+
391
+ if show_index and index_name not in df_outgoing.columns:
392
+ # If index name is provided, we use it
393
+ df_outgoing.index.name = index_name or "Index"
394
+ df_outgoing = df_outgoing.reset_index()
395
+
396
+ for col in df_outgoing.columns:
397
+ if col == "":
398
+ df_outgoing = df_outgoing.rename(columns={col: " "})
399
+
400
+ session._backend.send_table( # type: ignore # pylint: disable=protected-access
401
+ df_table=df_outgoing,
402
+ title=title,
403
+ theme=session.user.preferences.table_style,
404
+ )
405
+ return
406
+
407
+ df = df.copy() if not limit else df.copy().iloc[:limit]
408
+ if automatic_coloring:
409
+ if columns_to_auto_color:
410
+ for col in columns_to_auto_color:
411
+ # checks whether column exists
412
+ if col in df.columns:
413
+ df[col] = df[col].apply(lambda x: return_colored_value(str(x)))
414
+ if rows_to_auto_color:
415
+ for row in rows_to_auto_color:
416
+ # checks whether row exists
417
+ if row in df.index:
418
+ df.loc[row] = df.loc[row].apply(
419
+ lambda x: return_colored_value(str(x))
420
+ )
421
+
422
+ if columns_to_auto_color is None and rows_to_auto_color is None:
423
+ df = df.applymap(lambda x: return_colored_value(str(x)))
424
+
425
+ exceeds_allowed_columns = len(df.columns) > MAX_COLS
426
+ exceeds_allowed_rows = len(df) > MAX_ROWS
427
+
428
+ if exceeds_allowed_columns:
429
+ original_columns = df.columns.tolist()
430
+ trimmed_columns = df.columns.tolist()[:MAX_COLS]
431
+ df = df[trimmed_columns]
432
+ trimmed_columns = [
433
+ col for col in original_columns if col not in trimmed_columns
434
+ ]
435
+
436
+ if exceeds_allowed_rows:
437
+ n_rows = len(df.index)
438
+ max_rows = MAX_ROWS
439
+ df = df[:max_rows]
440
+ trimmed_rows_count = n_rows - max_rows
441
+
442
+ if use_tabulate_df:
443
+ table = Table(title=title, show_lines=True, show_header=show_header)
444
+
445
+ if show_index:
446
+ table.add_column(index_name)
447
+
448
+ if headers is not None:
449
+ headers = _get_headers(headers)
450
+ for header in headers:
451
+ table.add_column(str(header))
452
+ else:
453
+ for column in df.columns:
454
+ table.add_column(str(column))
455
+
456
+ if isinstance(floatfmt, list) and len(floatfmt) != len(df.columns):
457
+ raise (
458
+ ValueError(
459
+ "Length of floatfmt list does not match length of DataFrame columns."
460
+ )
461
+ )
462
+ if isinstance(floatfmt, str):
463
+ floatfmt = [floatfmt for _ in range(len(df.columns))]
464
+
465
+ for idx, values in zip(df.index.tolist(), df.values.tolist()):
466
+ # remove hour/min/sec from timestamp index - Format: YYYY-MM-DD # make better
467
+ row_idx = [str(idx)] if show_index else []
468
+ row_idx += [
469
+ (
470
+ str(x)
471
+ if not isinstance(x, float) and not isinstance(x, np.float64)
472
+ else (
473
+ f"{x:{floatfmt[idx]}}"
474
+ if isinstance(floatfmt, list)
475
+ else (
476
+ f"{x:.2e}"
477
+ if 0 < abs(float(x)) <= 0.0001
478
+ else f"{x:floatfmt}"
479
+ )
480
+ )
481
+ )
482
+ for idx, x in enumerate(values)
483
+ ]
484
+ table.add_row(*row_idx)
485
+ session.console.print(table)
486
+ else:
487
+ session.console.print(df.to_string(col_space=0))
488
+
489
+ if exceeds_allowed_columns:
490
+ session.console.print(
491
+ f"[yellow]\nAllowed number of columns exceeded ({session.settings.ALLOWED_NUMBER_OF_COLUMNS}).\n"
492
+ f"The following columns were removed from the output: {', '.join(trimmed_columns)}.\n[/yellow]"
493
+ )
494
+
495
+ if exceeds_allowed_rows:
496
+ session.console.print(
497
+ f"[yellow]\nAllowed number of rows exceeded ({session.settings.ALLOWED_NUMBER_OF_ROWS}).\n"
498
+ f"{trimmed_rows_count} rows were removed from the output.\n[/yellow]"
499
+ )
500
+
501
+ if exceeds_allowed_columns or exceeds_allowed_rows:
502
+ session.console.print(
503
+ "Use the `--export` flag to analyse the full output on a file."
504
+ )
505
+
506
+
507
+ def check_non_negative(value) -> int:
508
+ """Argparse type to check non negative int."""
509
+ new_value = int(value)
510
+ if new_value < 0:
511
+ raise argparse.ArgumentTypeError(f"{value} is negative")
512
+ return new_value
513
+
514
+
515
+ def check_positive(value) -> int:
516
+ """Argparse type to check positive int."""
517
+ new_value = int(value)
518
+ if new_value <= 0:
519
+ raise argparse.ArgumentTypeError(f"{value} is an invalid positive int value")
520
+ return new_value
521
+
522
+
523
+ def validate_register_key(value: str) -> str:
524
+ """Validate the register key to ensure it does not contain the reserved word 'OBB'."""
525
+ if "OBB" in value:
526
+ raise argparse.ArgumentTypeError(
527
+ "The register key cannot contain the reserved word 'OBB'."
528
+ )
529
+ return str(value)
530
+
531
+
532
+ def get_user_agent() -> str:
533
+ """Get a not very random user agent."""
534
+ user_agent_strings = [
535
+ "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.10; rv:86.1) Gecko/20100101 Firefox/86.1",
536
+ "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:86.1) Gecko/20100101 Firefox/86.1",
537
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:82.1) Gecko/20100101 Firefox/82.1",
538
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:86.0) Gecko/20100101 Firefox/86.0",
539
+ "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:86.0) Gecko/20100101 Firefox/86.0",
540
+ "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.10; rv:83.0) Gecko/20100101 Firefox/83.0",
541
+ "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:84.0) Gecko/20100101 Firefox/84.0",
542
+ ]
543
+
544
+ return random.choice(user_agent_strings) # nosec # noqa: S311
545
+
546
+
547
+ def get_flair() -> str:
548
+ """Get a flair icon."""
549
+ current_flair = str(session.settings.FLAIR)
550
+ flair = AVAILABLE_FLAIRS.get(current_flair, current_flair)
551
+ return flair
552
+
553
+
554
+ def get_dtime() -> str:
555
+ """Get a datetime string."""
556
+ dtime = ""
557
+ if session.settings.USE_DATETIME and get_user_timezone_or_invalid() != "INVALID":
558
+ dtime = datetime.now(timezone(get_user_timezone())).strftime("%Y %b %d, %H:%M")
559
+ return dtime
560
+
561
+
562
+ def get_flair_and_username() -> str:
563
+ """Get a flair icon and username."""
564
+ flair = get_flair()
565
+ if dtime := get_dtime():
566
+ dtime = f"{dtime} "
567
+
568
+ username = getattr(session.user.profile.hub_session, "username", "")
569
+ if username:
570
+ username = f"[{username}] "
571
+
572
+ return f"{dtime}{username}{flair}"
573
+
574
+
575
+ def is_timezone_valid(user_tz: str) -> bool:
576
+ """Check whether user timezone is valid.
577
+
578
+ Parameters
579
+ ----------
580
+ user_tz: str
581
+ Timezone to check for validity
582
+
583
+ Returns
584
+ -------
585
+ bool
586
+ True if timezone provided is valid
587
+ """
588
+ return user_tz in all_timezones
589
+
590
+
591
+ def get_user_timezone() -> str:
592
+ """Get user timezone if it is a valid one.
593
+
594
+ Returns
595
+ -------
596
+ str
597
+ user timezone based on .env file
598
+ """
599
+ return session.settings.TIMEZONE
600
+
601
+
602
+ def get_user_timezone_or_invalid() -> str:
603
+ """Get user timezone if it is a valid one.
604
+
605
+ Returns
606
+ -------
607
+ str
608
+ user timezone based on timezone.openbb file or INVALID
609
+ """
610
+ user_tz = get_user_timezone()
611
+ if is_timezone_valid(user_tz):
612
+ return f"{user_tz}"
613
+ return "INVALID"
614
+
615
+
616
+ def check_file_type_saved(valid_types: Optional[List[str]] = None):
617
+ """Provide valid types for the user to be able to select.
618
+
619
+ Parameters
620
+ ----------
621
+ valid_types: List[str]
622
+ List of valid types to export data
623
+
624
+ Returns
625
+ -------
626
+ check_filenames: Optional[List[str]]
627
+ Function that returns list of filenames to export data
628
+ """
629
+
630
+ def check_filenames(filenames: str = "") -> str:
631
+ """Check if filenames are valid.
632
+
633
+ Parameters
634
+ ----------
635
+ filenames: str
636
+ filenames to be saved separated with comma
637
+
638
+ Returns
639
+ ----------
640
+ str
641
+ valid filenames separated with comma
642
+ """
643
+ if not filenames or not valid_types:
644
+ return ""
645
+ valid_filenames = list()
646
+ for filename in filenames.split(","):
647
+ if filename.endswith(tuple(valid_types)):
648
+ valid_filenames.append(filename)
649
+ else:
650
+ session.console.print(
651
+ f"[red]Filename '{filename}' provided is not valid!\nPlease use one of the following file types:"
652
+ f"{','.join(valid_types)}[/red]\n"
653
+ )
654
+ return ",".join(valid_filenames)
655
+
656
+ return check_filenames
657
+
658
+
659
+ def remove_timezone_from_dataframe(df: pd.DataFrame) -> pd.DataFrame:
660
+ """Remove timezone information from a dataframe.
661
+
662
+ Parameters
663
+ ----------
664
+ df : pd.DataFrame
665
+ The dataframe to remove timezone information from
666
+
667
+ Returns
668
+ -------
669
+ pd.DataFrame
670
+ The dataframe with timezone information removed
671
+ """
672
+ date_cols = []
673
+ index_is_date = False
674
+
675
+ # Find columns and index containing date data
676
+ if (
677
+ df.index.dtype.kind == "M"
678
+ and hasattr(df.index.dtype, "tz")
679
+ and df.index.dtype.tz is not None
680
+ ):
681
+ index_is_date = True
682
+
683
+ for col, dtype in df.dtypes.items():
684
+ if dtype.kind == "M" and hasattr(df.index.dtype, "tz") and dtype.tz is not None:
685
+ date_cols.append(col)
686
+
687
+ # Remove the timezone information
688
+ for col in date_cols:
689
+ df[col] = df[col].dt.date
690
+
691
+ if index_is_date:
692
+ index_name = df.index.name
693
+ df.index = df.index.date
694
+ df.index.name = index_name
695
+
696
+ return df
697
+
698
+
699
+ def compose_export_path(func_name: str, dir_path: str) -> Path:
700
+ """Compose export path for data from the terminal.
701
+
702
+ Creates a path to a folder and a filename based on conditions.
703
+
704
+ Parameters
705
+ ----------
706
+ func_name : str
707
+ Name of the command that invokes this function
708
+ dir_path : str
709
+ Path of directory from where this function is called
710
+
711
+ Returns
712
+ -------
713
+ Path
714
+ Path variable containing the path of the exported file
715
+ """
716
+ now = datetime.now()
717
+ # Resolving all symlinks and also normalizing path.
718
+ resolve_path = Path(dir_path).resolve()
719
+ # Getting the directory names from the path. Instead of using split/replace (Windows doesn't like that)
720
+ # check if this is done in a main context to avoid saving with openbb_cli
721
+ if resolve_path.parts[-2] == "openbb_cli":
722
+ path_cmd = f"{resolve_path.parts[-1]}"
723
+ else:
724
+ path_cmd = f"{resolve_path.parts[-2]}_{resolve_path.parts[-1]}"
725
+
726
+ default_filename = f"{now.strftime('%Y%m%d_%H%M%S')}_{path_cmd}_{func_name}"
727
+
728
+ full_path = Path(session.user.preferences.export_directory) / default_filename
729
+
730
+ return full_path
731
+
732
+
733
+ def ask_file_overwrite(file_path: Path) -> Tuple[bool, bool]:
734
+ """Provide a prompt for overwriting existing files.
735
+
736
+ Returns two values, the first is a boolean indicating if the file exists and the
737
+ second is a boolean indicating if the user wants to overwrite the file.
738
+ """
739
+ if session.settings.FILE_OVERWRITE:
740
+ return False, True
741
+ if session.settings.TEST_MODE:
742
+ return False, True
743
+ if file_path.exists():
744
+ overwrite = input("\nFile already exists. Overwrite? [y/n]: ").lower()
745
+ if overwrite == "y":
746
+ file_path.unlink(missing_ok=True)
747
+ # File exists and user wants to overwrite
748
+ return True, True
749
+ # File exists and user does not want to overwrite
750
+ return True, False
751
+ # File does not exist
752
+ return False, True
753
+
754
+
755
+ # This is a false positive on pylint and being tracked in pylint #3060
756
+ # pylint: disable=abstract-class-instantiated,too-many-positional-arguments
757
+ def save_to_excel(df, saved_path, sheet_name, start_row=0, index=True, header=True):
758
+ """Save a Pandas DataFrame to an Excel file.
759
+
760
+ Args:
761
+ df: A Pandas DataFrame.
762
+ saved_path: The path to the Excel file to save to.
763
+ sheet_name: The name of the sheet to save the DataFrame to.
764
+ start_row: The row number to start writing the DataFrame at.
765
+ index: Whether to write the DataFrame index to the Excel file.
766
+ header: Whether to write the DataFrame header to the Excel file.
767
+ """
768
+ overwrite_options = {
769
+ "o": "replace",
770
+ "a": "overlay",
771
+ "n": "new",
772
+ }
773
+
774
+ if not saved_path.exists():
775
+ with pd.ExcelWriter(saved_path, engine="openpyxl") as writer:
776
+ df.to_excel(writer, sheet_name=sheet_name, index=index, header=header)
777
+
778
+ else:
779
+ with pd.ExcelFile(saved_path) as reader:
780
+ overwrite_option = "n"
781
+ if sheet_name in reader.sheet_names:
782
+ overwrite_option = input(
783
+ "\nSheet already exists. Overwrite/Append/New? [o/a/n]: "
784
+ ).lower()
785
+ start_row = 0
786
+ if overwrite_option == "a":
787
+ existing_df = pd.read_excel(saved_path, sheet_name=sheet_name)
788
+ start_row = existing_df.shape[0] + 1
789
+
790
+ with pd.ExcelWriter(
791
+ saved_path,
792
+ mode="a",
793
+ if_sheet_exists=overwrite_options[overwrite_option],
794
+ engine="openpyxl",
795
+ ) as writer:
796
+ df.to_excel(
797
+ writer,
798
+ sheet_name=sheet_name,
799
+ startrow=start_row,
800
+ index=index,
801
+ header=False if overwrite_option == "a" else header,
802
+ )
803
+
804
+
805
+ # This is a false positive on pylint and being tracked in pylint #3060
806
+ # pylint: disable=abstract-class-instantiated,too-many-positional-arguments
807
+ def export_data(
808
+ export_type: str,
809
+ dir_path: str,
810
+ func_name: str,
811
+ df: pd.DataFrame = pd.DataFrame(),
812
+ sheet_name: Optional[str] = None,
813
+ figure: Optional["OpenBBFigure"] = None,
814
+ margin: bool = True,
815
+ ) -> None:
816
+ """Export data to a file.
817
+
818
+ Parameters
819
+ ----------
820
+ export_type : str
821
+ Type of export between: csv,json,xlsx,xls
822
+ dir_path : str
823
+ Path of directory from where this function is called
824
+ func_name : str
825
+ Name of the command that invokes this function
826
+ df : pd.Dataframe
827
+ Dataframe of data to save
828
+ sheet_name : str
829
+ If provided. The name of the sheet to save in excel file
830
+ figure : Optional[OpenBBFigure]
831
+ Figure object to save as image file
832
+ margin : bool
833
+ Automatically adjust subplot parameters to give specified padding.
834
+ """
835
+ if export_type:
836
+ saved_path = compose_export_path(func_name, dir_path).resolve()
837
+ saved_path.parent.mkdir(parents=True, exist_ok=True)
838
+ for exp_type in export_type.split(","):
839
+ # In this scenario the path was provided, e.g. --export pt.csv, pt.jpg
840
+ if "." in exp_type:
841
+ saved_path = saved_path.with_name(exp_type)
842
+ # In this scenario we use the default filename
843
+ else:
844
+ if ".OpenBB_openbb_cli" in saved_path.name:
845
+ saved_path = saved_path.with_name(
846
+ saved_path.name.replace(".OpenBB_openbb_cli", "OpenBBCLI")
847
+ )
848
+ saved_path = saved_path.with_suffix(f".{exp_type}")
849
+
850
+ exists, overwrite = False, False
851
+ is_xlsx = exp_type.endswith("xlsx")
852
+ if sheet_name is None and is_xlsx or not is_xlsx:
853
+ exists, overwrite = ask_file_overwrite(saved_path)
854
+
855
+ if exists and not overwrite:
856
+ existing = len(list(saved_path.parent.glob(saved_path.stem + "*")))
857
+ saved_path = saved_path.with_stem(f"{saved_path.stem}_{existing + 1}")
858
+
859
+ df = df.replace(
860
+ {
861
+ r"\[yellow\]": "",
862
+ r"\[/yellow\]": "",
863
+ r"\[green\]": "",
864
+ r"\[/green\]": "",
865
+ r"\[red\]": "",
866
+ r"\[/red\]": "",
867
+ r"\[magenta\]": "",
868
+ r"\[/magenta\]": "",
869
+ },
870
+ regex=True,
871
+ )
872
+
873
+ if exp_type.endswith("csv"):
874
+ df.to_csv(saved_path)
875
+ elif exp_type.endswith("json"):
876
+ df.reset_index(drop=True, inplace=True)
877
+ df.to_json(saved_path)
878
+ elif exp_type.endswith("xlsx"):
879
+ # since xlsx does not support datetimes with timezones we need to remove it
880
+ df = remove_timezone_from_dataframe(df)
881
+
882
+ if sheet_name is None: # noqa: SIM223
883
+ df.to_excel(
884
+ saved_path,
885
+ index=True,
886
+ header=True,
887
+ )
888
+ else:
889
+ save_to_excel(df, saved_path, sheet_name)
890
+
891
+ elif saved_path.suffix in [".jpg", ".png"]:
892
+ if figure is None:
893
+ session.console.print("No plot to export.")
894
+ continue
895
+ figure.show(export_image=saved_path, margin=margin)
896
+ else:
897
+ session.console.print("Wrong export file specified.")
898
+ continue
899
+
900
+ if saved_path.exists():
901
+ session.console.print(f"Saved file: {saved_path}")
902
+ else:
903
+ session.console.print(f"Failed to save file: {saved_path}")
904
+
905
+ if figure is not None:
906
+ figure._exported = True # pylint: disable=protected-access
907
+
908
+
909
+ def system_clear():
910
+ """Clear screen."""
911
+ os.system("cls||clear") # nosec # noqa: S605,S607
912
+
913
+
914
+ # Write an abstract helper to make requests from a url with potential headers and params
915
+ def request(
916
+ url: str, method: str = "get", timeout: int = 0, **kwargs
917
+ ) -> requests.Response:
918
+ """Make requests from a url with potential headers and params.
919
+
920
+ Parameters
921
+ ----------
922
+ url : str
923
+ Url to make the request to
924
+ method : str
925
+ HTTP method to use. Choose from:
926
+ delete, get, head, patch, post, put, by default "get"
927
+ timeout : int
928
+ How many seconds to wait for the server to send data
929
+
930
+ Returns
931
+ -------
932
+ requests.Response
933
+ Request response object
934
+
935
+ Raises
936
+ ------
937
+ ValueError
938
+ If invalid method is passed
939
+ """
940
+ method = method.lower()
941
+ if method not in ["delete", "get", "head", "patch", "post", "put"]:
942
+ raise ValueError(f"Invalid method: {method}")
943
+ # We want to add a user agent to the request, so check if there are any headers
944
+ # If there are headers, check if there is a user agent, if not add one.
945
+ # Some requests seem to work only with a specific user agent, so we want to be able to override it.
946
+ headers = kwargs.pop("headers", {})
947
+ timeout = timeout or session.user.preferences.request_timeout
948
+
949
+ if "User-Agent" not in headers:
950
+ headers["User-Agent"] = get_user_agent()
951
+ func = getattr(requests, method)
952
+ return func(
953
+ url,
954
+ headers=headers,
955
+ timeout=timeout,
956
+ **kwargs,
957
+ )
958
+
959
+
960
+ def parse_unknown_args_to_dict(unknown_args: Optional[List[str]]) -> Dict[str, str]:
961
+ """Parse unknown arguments to a dictionary."""
962
+ unknown_args_dict = {}
963
+ if unknown_args:
964
+ for idx, arg in enumerate(unknown_args):
965
+ if arg.startswith("--"):
966
+ if idx + 1 < len(unknown_args):
967
+ try:
968
+ unknown_args_dict[arg.replace("--", "")] = (
969
+ eval( # noqa: S307, E501 pylint: disable=eval-used
970
+ unknown_args[idx + 1]
971
+ )
972
+ )
973
+ except Exception:
974
+ unknown_args_dict[arg] = unknown_args[idx + 1]
975
+ else:
976
+ session.console.print(
977
+ f"Missing value for argument {arg}. Skipping this argument."
978
+ )
979
+ return unknown_args_dict
980
+
981
+
982
+ def handle_obbject_display(
983
+ obbject: OBBject,
984
+ chart: bool = False,
985
+ export: str = "",
986
+ sheet_name: str = "",
987
+ **kwargs,
988
+ ):
989
+ """Handle the display of an OBBject."""
990
+ df: pd.DataFrame = pd.DataFrame()
991
+ fig: Optional[OpenBBFigure] = None
992
+ if chart:
993
+ try:
994
+ if obbject.chart:
995
+ obbject.show(**kwargs)
996
+ else:
997
+ obbject.charting.to_chart(**kwargs) # type: ignore
998
+ if export:
999
+ fig = obbject.chart.fig # type: ignore
1000
+ df = obbject.to_dataframe()
1001
+ except Exception as e:
1002
+ session.console.print(f"Failed to display chart: {e}")
1003
+ elif session.settings.USE_INTERACTIVE_DF:
1004
+ obbject.charting.table() # type: ignore
1005
+ else:
1006
+ df = obbject.to_dataframe()
1007
+ print_rich_table(
1008
+ df=df,
1009
+ show_index=True,
1010
+ title=obbject.extra.get("command", ""),
1011
+ export=bool(export),
1012
+ )
1013
+ if export and not df.empty:
1014
+ if sheet_name and isinstance(sheet_name, list):
1015
+ sheet_name = sheet_name[0]
1016
+
1017
+ func_name = (
1018
+ obbject.extra.get("command", "")
1019
+ .replace("/", "_")
1020
+ .replace(" ", "_")
1021
+ .replace("--", "_")
1022
+ )
1023
+ export_data(
1024
+ export_type=",".join(export),
1025
+ dir_path=os.path.dirname(os.path.abspath(__file__)),
1026
+ func_name=func_name,
1027
+ df=df,
1028
+ sheet_name=sheet_name,
1029
+ figure=fig,
1030
+ )
1031
+ elif export and df.empty:
1032
+ session.console.print("[yellow]No data to export.[/yellow]")
cli/openbb_cli/models/settings.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Settings model."""
2
+
3
+ from enum import Enum
4
+ from typing import Any, Literal
5
+
6
+ from dotenv import dotenv_values, set_key
7
+ from openbb_cli.config.constants import AVAILABLE_FLAIRS, ENV_FILE_SETTINGS
8
+ from openbb_core.app.version import get_package_version
9
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
10
+ from pytz import all_timezones
11
+
12
+ VERSION = get_package_version("openbb-cli")
13
+
14
+
15
+ class SettingGroups(Enum):
16
+ """Setting types."""
17
+
18
+ feature_flags = "feature_flag"
19
+ preferences = "preference"
20
+
21
+
22
+ class Settings(BaseModel):
23
+ """Settings model."""
24
+
25
+ # Platform CLI version
26
+ VERSION: str = VERSION
27
+
28
+ # DEVELOPMENT FLAGS
29
+ TEST_MODE: bool = False
30
+ DEBUG_MODE: bool = False
31
+ DEV_BACKEND: bool = False
32
+
33
+ # OPENBB
34
+ HUB_URL: str = "https://my.openbb.co"
35
+ BASE_URL: str = "https://payments.openbb.co"
36
+
37
+ # GENERAL
38
+ PREVIOUS_USE: bool = False
39
+
40
+ # FEATURE FLAGS
41
+ FILE_OVERWRITE: bool = Field(
42
+ default=False,
43
+ description="whether to overwrite Excel files if they already exists",
44
+ command="overwrite",
45
+ group=SettingGroups.feature_flags,
46
+ )
47
+ SHOW_VERSION: bool = Field(
48
+ default=True,
49
+ description="whether to show the version in the bottom right corner",
50
+ command="version",
51
+ group=SettingGroups.feature_flags,
52
+ )
53
+ USE_INTERACTIVE_DF: bool = Field(
54
+ default=True,
55
+ description="display tables in interactive window",
56
+ command="interactive",
57
+ group=SettingGroups.feature_flags,
58
+ )
59
+ USE_CLEAR_AFTER_CMD: bool = Field(
60
+ default=False,
61
+ description="clear console after each command",
62
+ command="cls",
63
+ group=SettingGroups.feature_flags,
64
+ )
65
+ USE_DATETIME: bool = Field(
66
+ default=True,
67
+ description="whether to show the date and time before the flair",
68
+ command="datetime",
69
+ group=SettingGroups.feature_flags,
70
+ )
71
+ USE_PROMPT_TOOLKIT: bool = Field(
72
+ default=True,
73
+ description="enable prompt toolkit (autocomplete and history)",
74
+ command="promptkit",
75
+ group=SettingGroups.feature_flags,
76
+ )
77
+ ENABLE_EXIT_AUTO_HELP: bool = Field(
78
+ default=True,
79
+ description="automatically print help when quitting menu",
80
+ command="exithelp",
81
+ group=SettingGroups.feature_flags,
82
+ )
83
+ ENABLE_RICH_PANEL: bool = Field(
84
+ default=True,
85
+ description="enable colorful rich CLI panel",
86
+ command="richpanel",
87
+ group=SettingGroups.feature_flags,
88
+ )
89
+ TOOLBAR_HINT: bool = Field(
90
+ default=True,
91
+ description="displays usage hints in the bottom toolbar",
92
+ command="tbhint",
93
+ group=SettingGroups.feature_flags,
94
+ )
95
+ SHOW_MSG_OBBJECT_REGISTRY: bool = Field(
96
+ default=False,
97
+ description="show obbject registry message after a new result is added",
98
+ command="obbject_msg",
99
+ group=SettingGroups.feature_flags,
100
+ )
101
+
102
+ # PREFERENCES
103
+ TIMEZONE: Literal[tuple(all_timezones)] = Field( # type: ignore[valid-type]
104
+ default="America/New_York",
105
+ description="pick timezone",
106
+ command="timezone",
107
+ group=SettingGroups.preferences,
108
+ )
109
+ FLAIR: Literal[tuple(AVAILABLE_FLAIRS)] = Field( # type: ignore[valid-type]
110
+ default=":openbb",
111
+ description="choose flair icon",
112
+ command="flair",
113
+ group=SettingGroups.preferences,
114
+ )
115
+ N_TO_KEEP_OBBJECT_REGISTRY: int = Field(
116
+ default=10,
117
+ description="define the maximum number of obbjects allowed in the registry",
118
+ command="obbject_res",
119
+ group=SettingGroups.preferences,
120
+ )
121
+ N_TO_DISPLAY_OBBJECT_REGISTRY: int = Field(
122
+ default=5,
123
+ description="define the maximum number of cached results to display on the help menu",
124
+ command="obbject_display",
125
+ group=SettingGroups.preferences,
126
+ )
127
+ RICH_STYLE: str = Field(
128
+ default="dark",
129
+ description="apply a custom rich style to the CLI",
130
+ command="console_style",
131
+ group=SettingGroups.preferences,
132
+ )
133
+ ALLOWED_NUMBER_OF_ROWS: int = Field(
134
+ default=20,
135
+ description="number of rows to show (when not using interactive tables).",
136
+ command="n_rows",
137
+ group=SettingGroups.preferences,
138
+ )
139
+ ALLOWED_NUMBER_OF_COLUMNS: int = Field(
140
+ default=5,
141
+ description="number of columns to show (when not using interactive tables).",
142
+ command="n_cols",
143
+ group=SettingGroups.preferences,
144
+ )
145
+
146
+ model_config = ConfigDict(validate_assignment=True)
147
+
148
+ def __repr__(self) -> str:
149
+ """Return a string representation of the model."""
150
+ return f"{self.__class__.__name__}\n\n" + "\n".join(
151
+ f"{k}: {v}" for k, v in self.model_dump().items()
152
+ )
153
+
154
+ @model_validator(mode="before")
155
+ @classmethod
156
+ def from_env(cls, values: dict) -> dict:
157
+ """Load settings from .env."""
158
+ settings = {}
159
+ settings.update(dotenv_values(ENV_FILE_SETTINGS))
160
+ settings.update(values)
161
+ filtered = {k.replace("OPENBB_", ""): v for k, v in settings.items()}
162
+ return filtered
163
+
164
+ def set_item(self, key: str, value: Any) -> None:
165
+ """Set an item in the model and save to .env."""
166
+ setattr(self, key, value)
167
+ set_key(str(ENV_FILE_SETTINGS), "OPENBB_" + key, str(value))
cli/openbb_cli/session.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Settings module."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from openbb import obb
8
+ from openbb_charting.core.backend import create_backend, get_backend
9
+ from openbb_core.app.model.abstract.singleton import SingletonMeta
10
+ from openbb_core.app.model.charts.charting_settings import ChartingSettings
11
+ from openbb_core.app.model.user_settings import UserSettings as User
12
+ from prompt_toolkit import PromptSession
13
+
14
+ from openbb_cli.argparse_translator.obbject_registry import Registry
15
+ from openbb_cli.config.completer import CustomFileHistory
16
+ from openbb_cli.config.console import Console
17
+ from openbb_cli.config.constants import HIST_FILE_PROMPT
18
+ from openbb_cli.config.style import Style
19
+ from openbb_cli.models.settings import Settings
20
+
21
+
22
+ def _get_backend():
23
+ """Get the Platform charting backend."""
24
+ try:
25
+ return get_backend()
26
+ except ValueError:
27
+ # backend might not be created yet
28
+ charting_settings = ChartingSettings(
29
+ system_settings=obb.system, user_settings=obb.user # type: ignore
30
+ )
31
+ create_backend(charting_settings)
32
+ get_backend().start(debug=charting_settings.debug_mode) # type: ignore
33
+ return get_backend()
34
+
35
+
36
+ class Session(metaclass=SingletonMeta):
37
+ """Session class."""
38
+
39
+ def __init__(self):
40
+ """Initialize session."""
41
+
42
+ self._obb = obb
43
+ self._settings = Settings()
44
+ self._style = Style(
45
+ style=self._settings.RICH_STYLE,
46
+ directory=Path(self._obb.user.preferences.user_styles_directory), # type: ignore[union-attr]
47
+ )
48
+ self._console = Console(
49
+ settings=self._settings, style=self._style.console_style
50
+ )
51
+ self._prompt_session = self._get_prompt_session()
52
+ self._obbject_registry = Registry()
53
+
54
+ self._backend = _get_backend()
55
+
56
+ @property
57
+ def user(self) -> User:
58
+ """Get platform user."""
59
+ return self._obb.user # type: ignore[union-attr]
60
+
61
+ @property
62
+ def settings(self) -> Settings:
63
+ """Get CLI settings."""
64
+ return self._settings
65
+
66
+ @property
67
+ def style(self) -> Style:
68
+ """Get CLI style."""
69
+ return self._style
70
+
71
+ @property
72
+ def console(self) -> Console:
73
+ """Get console."""
74
+ return self._console
75
+
76
+ @property
77
+ def obbject_registry(self) -> Registry:
78
+ """Get obbject registry."""
79
+ return self._obbject_registry
80
+
81
+ @property
82
+ def prompt_session(self) -> Optional[PromptSession]:
83
+ """Get prompt session."""
84
+ return self._prompt_session
85
+
86
+ def _get_prompt_session(self) -> Optional[PromptSession]:
87
+ """Initialize prompt session."""
88
+ try:
89
+ if sys.stdin.isatty():
90
+ prompt_session: Optional[PromptSession] = PromptSession(
91
+ history=CustomFileHistory(str(HIST_FILE_PROMPT))
92
+ )
93
+ else:
94
+ prompt_session = None
95
+ except Exception:
96
+ prompt_session = None
97
+
98
+ return prompt_session
99
+
100
+ def is_local(self) -> bool:
101
+ """Check if user is local."""
102
+ return not bool(self.user.profile.hub_session)
103
+
104
+ def max_obbjects_exceeded(self) -> bool:
105
+ """Check if max obbjects exceeded."""
106
+ return (
107
+ len(self.obbject_registry.all) >= self.settings.N_TO_KEEP_OBBJECT_REGISTRY
108
+ )
cli/openbb_cli/utils/utils.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OpenBB Platform CLI utilities."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ HOME_DIRECTORY = Path.home()
7
+ OPENBB_PLATFORM_DIRECTORY = Path(HOME_DIRECTORY, ".openbb_platform")
8
+ SYSTEM_SETTINGS_PATH = Path(OPENBB_PLATFORM_DIRECTORY, "system_settings.json")
9
+
10
+
11
+ def change_logging_sub_app() -> str:
12
+ """Build OpenBB Platform setting files."""
13
+ with open(SYSTEM_SETTINGS_PATH) as file:
14
+ system_settings = json.load(file)
15
+
16
+ initial_logging_sub_app = system_settings.get("logging_sub_app", "")
17
+
18
+ system_settings["logging_sub_app"] = "cli"
19
+
20
+ with open(SYSTEM_SETTINGS_PATH, "w") as file:
21
+ json.dump(system_settings, file, indent=4)
22
+
23
+ return initial_logging_sub_app
24
+
25
+
26
+ def reset_logging_sub_app(initial_logging_sub_app: str):
27
+ """Reset OpenBB Platform setting files."""
28
+ with open(SYSTEM_SETTINGS_PATH) as file:
29
+ system_settings = json.load(file)
30
+
31
+ system_settings["logging_sub_app"] = initial_logging_sub_app
32
+
33
+ with open(SYSTEM_SETTINGS_PATH, "w") as file:
34
+ json.dump(system_settings, file, indent=4)