From a116d9bbf2fb8f1a5308697cf3b8e651145f26a0 Mon Sep 17 00:00:00 2001 From: Igor Radovanovic <74266147+IgorWounds@users.noreply.github.com> Date: Tue, 14 May 2024 17:38:06 +0200 Subject: [PATCH] [Feature] - OpenBB Platform CLI Unit tests (#6397) * Unit test batch 1 * CLI controller * Test batch 3 * Test batch 4 * Test batch 5 * clean some workflows and setup actions * test * rename wfs * rename * update action * Skip * fix cli tests --------- Co-authored-by: Henrique Joaquim Co-authored-by: Diogo Sousa Co-authored-by: montezdesousa <79287829+montezdesousa@users.noreply.github.com> --- .github/scripts/noxfile.py | 20 +- .github/workflows/README.md | 226 ++++-------------- ...pi-nightly.yml => deploy-pypi-nightly.yml} | 0 ...pypi_platform.yml => deploy-test-pypi.yml} | 2 +- .github/workflows/draft-release.yml | 2 +- .../{linting.yml => general-linting.yml} | 4 - .../{labels-PR.yml => gh-pr-labels.yml} | 0 .github/workflows/labels-issue.yml | 27 --- .github/workflows/nightly-build.yml | 35 --- .github/workflows/pypi.yml | 78 ------ ...test.yml => test-integration-platform.yml} | 2 +- .github/workflows/test-unit-cli.yml | 46 ++++ ...atform-core.yml => test-unit-platform.yml} | 4 +- .github/workflows/unit-test.yml | 72 ------ .pre-commit-config.yaml | 2 +- cli/openbb_cli/config/menu_text.py | 14 -- cli/tests/test_argparse_translator.py | 94 ++++++++ ...st_argparse_translator_obbject_registry.py | 89 +++++++ cli/tests/test_cli.py | 45 ++++ cli/tests/test_config_completer.py | 78 ++++++ cli/tests/test_config_console.py | 47 ++++ cli/tests/test_config_menu_text.py | 68 ++++++ cli/tests/test_config_setup.py | 49 ++++ cli/tests/test_config_style.py | 60 +++++ cli/tests/test_controllers_base_controller.py | 79 ++++++ ...st_controllers_base_platform_controller.py | 70 ++++++ cli/tests/test_controllers_choices.py | 51 ++++ cli/tests/test_controllers_cli_controller.py | 79 ++++++ .../test_controllers_controller_factory.py | 61 +++++ cli/tests/test_controllers_script_parser.py | 106 ++++++++ .../test_controllers_settings_controller.py | 135 +++++++++++ cli/tests/test_controllers_utils.py | 157 ++++++++++++ cli/tests/test_models_settings.py | 72 ++++++ cli/tests/test_session.py | 44 ++++ openbb_platform/dev_install.py | 2 +- 35 files changed, 1503 insertions(+), 417 deletions(-) rename .github/workflows/{pypi-nightly.yml => deploy-pypi-nightly.yml} (100%) rename .github/workflows/{test_pypi_platform.yml => deploy-test-pypi.yml} (96%) rename .github/workflows/{linting.yml => general-linting.yml} (96%) rename .github/workflows/{labels-PR.yml => gh-pr-labels.yml} (100%) delete mode 100644 .github/workflows/labels-issue.yml delete mode 100644 .github/workflows/nightly-build.yml delete mode 100644 .github/workflows/pypi.yml rename .github/workflows/{platform-api-integration-test.yml => test-integration-platform.yml} (98%) create mode 100644 .github/workflows/test-unit-cli.yml rename .github/workflows/{platform-core.yml => test-unit-platform.yml} (89%) delete mode 100644 .github/workflows/unit-test.yml create mode 100644 cli/tests/test_argparse_translator.py create mode 100644 cli/tests/test_argparse_translator_obbject_registry.py create mode 100644 cli/tests/test_cli.py create mode 100644 cli/tests/test_config_completer.py create mode 100644 cli/tests/test_config_console.py create mode 100644 cli/tests/test_config_menu_text.py create mode 100644 cli/tests/test_config_setup.py create mode 100644 cli/tests/test_config_style.py create mode 100644 cli/tests/test_controllers_base_controller.py create mode 100644 cli/tests/test_controllers_base_platform_controller.py create mode 100644 cli/tests/test_controllers_choices.py create mode 100644 cli/tests/test_controllers_cli_controller.py create mode 100644 cli/tests/test_controllers_controller_factory.py create mode 100644 cli/tests/test_controllers_script_parser.py create mode 100644 cli/tests/test_controllers_settings_controller.py create mode 100644 cli/tests/test_controllers_utils.py create mode 100644 cli/tests/test_models_settings.py create mode 100644 cli/tests/test_session.py diff --git a/.github/scripts/noxfile.py b/.github/scripts/noxfile.py index ed4411601c58..5bc4af2ce43d 100644 --- a/.github/scripts/noxfile.py +++ b/.github/scripts/noxfile.py @@ -9,10 +9,12 @@ PLATFORM_TESTS = [ str(PLATFORM_DIR / p) for p in ["tests", "core", "providers", "extensions"] ] +CLI_DIR = ROOT_DIR / "cli" +CLI_TESTS = CLI_DIR / "tests" @nox.session(python=["3.9", "3.10", "3.11"]) -def tests(session): +def unit_test_platform(session): """Run the test suite.""" session.install("poetry", "toml") session.run( @@ -27,3 +29,19 @@ def tests(session): session.run( "pytest", *PLATFORM_TESTS, f"--cov={PLATFORM_DIR}", "-m", "not integration" ) + + +@nox.session(python=["3.9", "3.10", "3.11"]) +def unit_test_cli(session): + """Run the test suite.""" + session.install("poetry", "toml") + session.run( + "python", + str(PLATFORM_DIR / "dev_install.py"), + "-e", + "all", + external=True, + ) + session.install("pytest") + session.install("pytest-cov") + session.run("pytest", CLI_TESTS, f"--cov={CLI_DIR}") diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 87caf3212758..6b03303131b2 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -1,27 +1,9 @@ # OpenBB Workflows + This directory contains the workflows for the OpenBB 🦋 Project. The workflows are: -| Workflows | Summary | Branches -| :-------------------- |:------------------ | :------------------ -| branch-name-check.yml | Checks if the branch name is valid and follows the naming convention. | all branches -| build-release.yml | Builds the project and runs the tests. | main, release/* -| docker-build.yml | Builds the docker image and pushes it to the docker hub. | all branches (only pushes to docker hub on main) -| draft-release.yml | Creates a draft release when a new tag is pushed. | - -| gh-pages.yml | Builds the documentation and deploy to github pages. | main and release/* -| integration-test.yml | Runs the integration tests. | all branches -| labels-issue.yml | Creates an issue when a new bug is reported. | - -| labels-PR.yml | Adds labels to the issues and pull requests. | - -| linting.yml | Runs the linters. | all branches -| macos-build.yml | Builds the project on M1 Macs. | develop, main, release/* -| macos-ml.yml | Builds the project on Mac OS X Full Clean Build with ML. | main -| nightly-build.yml | Builds the project and runs the integration tests every night on the `develop` branch. | develop -| pypi.yml | Publishes the package to PyPI. | all branches (only pushes to PyPI on main) -| pypi-nightly.yml | Publishes the package to PyPI every night on the `develop` branch. | develop -| unit-test.yml | Runs the unit tests. | all branches -| windows_ml.yml | Builds the project on Windows 10 Full Clean Build with ML. | main -| windows10_build.yml | Builds the project on Windows 10. | all branches - -## Branch Name Check Workflow +## Branch Name Check + Objective: To check if pull request branch names follow the GitFlow naming convention before merging. Triggered by: A pull request event where the target branch is either develop or main. @@ -30,137 +12,22 @@ Branches checked: The source branch of a pull request and the target branch of a Steps: -1. Extract branch names: Using the jq tool, the source and target branch names are extracted from the pull request event. The branch names are then stored in environment variables and printed as output. +1. Extract branch names: Using the jq tool, the source and target branch names are extracted from the pull request event. The branch names are then stored in environment variables and printed as output. -2. Show Output result for source-branch and target-branch: The source and target branch names are printed to the console. +2. Show Output result for source-branch and target-branch: The source and target branch names are printed to the console. -3. Check branch name for develop PRs: If the target branch is develop, then the source branch is checked against a regular expression to ensure that it follows the GitFlow naming convention. If the branch name is invalid, a message is printed to the console and the workflow exits with a status code of 1. +3. Check branch name for develop PRs: If the target branch is develop, then the source branch is checked against a regular expression to ensure that it follows the GitFlow naming convention. If the branch name is invalid, a message is printed to the console and the workflow exits with a status code of 1. -4. Check branch name for main PRs: If the target branch is main, then the source branch is checked against a regular expression to ensure that it is either a hotfix or a release branch. If the branch name is invalid, a message is printed to the console and the workflow exits with a status code of 1. +4. Check branch name for main PRs: If the target branch is main, then the source branch is checked against a regular expression to ensure that it is either a hotfix or a release branch. If the branch name is invalid, a message is printed to the console and the workflow exits with a status code of 1. Note: The GitFlow naming convention for branches is as follows: -- Feature branches: feature/ -- Hotfix branches: hotfix/ -- Release branches: release/(rc) - -## Build Release Workflow -This GitHub Actions workflow is responsible for building and releasing software for multiple platforms (Windows, M1 MacOS, Intel MacOS, and Docker). -The workflow has four jobs: - -- `trigger-windows-build` -- `trigger-macos-build` -- `trigger-intel-build` -- `trigger-docker-build` - -Each job uses the `aurelien-baudet/workflow-dispatch` action to trigger another workflow, respectively `windows10_build.yml`, `m1_macos_build.yml`, `intel_macos_build.yml`, and `docker.yml`. The `GITHUB_TOKEN` is passed as a secret so that the triggered workflows have access to the necessary permissions. The `wait-for-completion-timeout` is set to 2 hours, which is the maximum amount of time the job will wait for the triggered workflow to complete. - -## Docker Workflow -This GitHub Actions workflow is responsible for building and pushing the docker image to the itHub Container Registry. This workflow is triggered when a new change is pushed to the main branch of the repository, and the Docker image is published to the GitHub Container Registry. - -Steps ------ - -1. Checkout Code: This step checks out the code from the GitHub repository. - -2. Login to GitHub Container Registry: This step logs into the GitHub Container Registry using the GitHub Actions token. - -3. Setup Commit Hash: This step sets the commit hash of the code that is being built. - -4. Build Env File: This step builds the environment file for the Docker image. - -5. Building the Docker Image: This step builds the Docker image using the scripts in the `build/docker` directory. - -6. Publishing the Docker Image: This step publishes the Docker image to the GitHub Container Registry. The Docker image is only pushed to the registry if the branch being built is `main`. - -## Release Drafter Workflow -This GitHub Actions workflow is designed to automatically generate and update draft releases in a GitHub repository. The workflow is triggered when it is manually dispatched, allowing you to control when the draft releases are updated. - -## GH Pages Workflow -This GitHub Actions workflow is responsible for building the documentation and deploying it to GitHub Pages. This workflow is triggered when a new change is pushed to the `main` or `release` branch of the repository, and the documentation is published to GitHub Pages. - -## Integration Test Workflow -This GitHub Action is used to run integration tests on your code repository. It is triggered on pushes to the `release/*` or `main` branches, and it runs on the latest version of Ubuntu. - -The workflow consists of the following steps: - -1. Check out the code from the repository -2. Set up Python 3.9 -3. Install Poetry, a package and dependency manager for Python -4. Load a cached virtual environment created by Poetry, to speed up the process if possible -5. Install dependencies specified in the `poetry.lock` file -6. Run the integration tests using the `terminal.py` script -7. Upload a summary of the test results to Slack - -The results of the tests are captured in a file called `result.txt`. The summary of the tests, including information about failed tests, is then uploaded to Slack using the `adrey/slack-file-upload-action` GitHub Action. - -## Linting Workflow -This GitHub Actions workflow is responsible for running linting checks on the codebase. This workflow is triggered on pull request events such as `opened`, `synchronize`, and `edited`, and push events on branches with names that start with `feature/`, `hotfix/`, or `release/`. The workflow also sets a number of environment variables and uses Github Actions caching to improve performance. - -It consists of two jobs: `code-linting` and `markdown-link-check`. - -The first job, `code-linting`, runs on an Ubuntu machine and performs several linting tasks on the code in the repository, including: - -- Checking out the code from the repository -- Setting up Python 3.9 -- Installing a number of Python packages necessary for the linting tasks -- Running `bandit` to check for security vulnerabilities -- Running `black` to check the code formatting -- Running `codespell` to check the spelling of comments, strings, and variable names -- Running `ruff` to check the use of Python -- Running `mypy` to check the type annotations -- Running `pyupgrade` to upgrade Python 2 code to Python 3 -- Running `pylint` to perform static analysis of the code - -The second job, `markdown-link-check`, runs on an Ubuntu machine and performs linting of the markdown files in the repository. It uses a Docker container `avtodev/markdown-lint` to perform the linting. - -## MacOS Build Workflow -This GitHub Actions workflow is used to build a version of the OpenBB Platform CLI for M1 MacOS. The build process includes installing necessary dependencies, building the terminal application using PyInstaller, creating a DMG file for distribution, and running integration tests on the built application. - -Jobs ----- - -The workflow consists of a single job named `Build` which runs on self-hosted MacOS systems with ARM64 architecture. The job performs the following steps: - -1. Checkout: The main branch of the repository is checked out, allowing for the commit hashes to line up. -2. Git Log: The log of the Git repository is displayed. -3. Install create-dmg: The `create-dmg` tool is installed using Homebrew. -4. Clean Previous Path: The previous PATH environment variable is cleared and restored to its default values. -5. Setup Conda Caching: The miniconda environment is set up using a caching mechanism for faster workflow execution after the first run. -6. Setup Miniconda: Miniconda is set up using the `conda-3-9-env-full.yaml` environment file, with channels `conda-forge` and `defaults`, and with the `build_env` environment activated. -7. Run Poetry: Poetry is used to install the dependencies for the project. -8. Install PyInstaller: PyInstaller is installed using Poetry. -9. Poetry Install Portfolio Optimization and Forecasting Toolkits: The portfolio optimization and forecasting toolkits are installed using Poetry. -10. Install Specific Papermill: A specific version of Papermill is installed using pip. -11. Build Bundle: The terminal application is built using PyInstaller, with icons and assets copied to the DMG directory. -12. Create DMG: The DMG file is created using the `create-dmg` tool. -13. Clean up Build Artifacts: The build artifacts such as the terminal directory and DMG directory are removed. -14. Save Build Artifact DMG: The DMG file is saved as a build artifact. -15. Convert & Mount DMG: The DMG file is converted and mounted. -16. Directory Change: The current directory is changed to the mounted DMG file. -17. Unmount DMG: The mounted DMG file is unmounted. -18. Run Integration Tests: The built terminal application is run with integration tests, and the results are displayed. - -Finally, the integration tests are run and the results are logged. The workflow is configured to run only when triggered by a workflow dispatch event and runs in a concurrent group, with the ability to cancel in-progress jobs. - -## Nightly Build Workflow -This code is a GitHub Actions workflow configuration file that is used to trigger other workflows when certain events occur. The main purpose of this workflow is to trigger builds on different platforms when a release is made or a pull request is made to the main branch. - -This workflow is triggered at UTC+0 daily by the GitHub Action schedule event. - -The job includes the following steps: - -1. Trigger Windows Build: This step uses the `aurelien-baudet/workflow-dispatch` action to trigger the windows10_build.yml workflow. - -2. Trigger macOS Build: This step uses the `aurelien-baudet/workflow-dispatch` action to trigger the m1_macos_build.yml workflow - -3. Trigger Intel Build: This step uses the `aurelien-baudet/workflow-dispatch` action to trigger the intel_macos_build.yml workflow +- Feature branches: feature/ +- Hotfix branches: hotfix/ +- Release branches: release/(rc) -4. Trigger Docker Build: This step uses the `aurelien-baudet/workflow-dispatch` action to trigger the docker.yml workflow +## Deploy to PyPI - Nightly -This workflow also uses a concurrency setting that groups the jobs by the workflow and ref, and cancels any in-progress jobs. - -## Nightly PyPI Publish Workflow This workflow is used to publish the latest version of the OpenBB Platform CLI to PyPI. The workflow is triggered at UTC+0 daily by the GitHub Action schedule event. It does this by first updating the `pyproject.toml` file with a pre-determined version string of the form `.dev`, where `` represents the current day's date as a 8 digit number. @@ -169,12 +36,13 @@ Then, the code installs `pypa/build` and uses `python -m build` to create a bina Finally, it uses the PyPA specific action `gh-action-pypi-publish` to publish the created files to PyPI. -## PYPI publish Workflow +## Deploy the OpenBB Platform to Test PyPI + The Github Action code `Deploy to PyPI` is used to deploy a Python project to PyPI (Python Package Index) and TestPyPI, which is a separate package index for testing purposes. The code is triggered on two events: -1. Push event: The code is triggered whenever there is a push to the `release/*` and `main` branches. +1. Push event: The code is triggered whenever there is a push to the `release/*` and `main` branches. -2. Workflow dispatch event: The code can be manually triggered by the workflow dispatch event. +2. Workflow dispatch event: The code can be manually triggered by the workflow dispatch event. The code sets the concurrency to the `group` and the option `cancel-in-progress` is set to `true` to ensure that the running jobs in the same `group` are cancelled in case another job is triggered. @@ -186,47 +54,47 @@ Similarly, the `deploy-pypi` job is triggered only if the pushed branch starts w Note: The code uses the `pypa/build` package for building the binary wheel and source tarball, and the `pypa/gh-action-pypi-publish@release/v1` Github Action for publishing the distributions to PyPI and TestPyPI. -## Unit Tests Workflow -This workflow is used to run unit tests on the OpenBB Platform CLI. The workflow is triggered on the following events: -The events this workflow will respond to are: +## Draft release -1. Pull requests that are opened, synchronized, edited, or closed. The pull request must be made to the `develop` or `main` branches. +This GitHub Actions workflow is designed to automatically generate and update draft releases in a GitHub repository. The workflow is triggered when it is manually dispatched, allowing you to control when the draft releases are updated. -2. Pushes to the `release/*` branches. +## General Linting -Each job in the workflow specifies a set of steps that are executed in order. +This GitHub Actions workflow is responsible for running linting checks on the codebase. This workflow is triggered on pull request events such as `opened`, `synchronize`, and `edited`, and push events on branches with names that start with `feature/`, `hotfix/`, or `release/`. The workflow also sets a number of environment variables and uses Github Actions caching to improve performance. -The first job, `check-files-changed`, checks whether there are any changes to certain file types in the repository, such as Python files and lockfiles. If there are changes, then the `check-changes` output variable is set to `true`. +It consists of two jobs: `code-linting` and `markdown-link-check`. + +The first job, `code-linting`, runs on an Ubuntu machine and performs several linting tasks on the code in the repository, including: + +- Checking out the code from the repository +- Setting up Python 3.9 +- Installing a number of Python packages necessary for the linting tasks +- Running `bandit` to check for security vulnerabilities +- Running `black` to check the code formatting +- Running `codespell` to check the spelling of comments, strings, and variable names +- Running `ruff` to check the use of Python +- Running `pylint` to perform static analysis of the code +- Running `mypy` to check the type annotations +- Running `pydocstyle` to check the docstrings + +The second job, `markdown-link-check`, runs on an Ubuntu machine and performs linting of the markdown files in the repository. It uses a Docker container `avtodev/markdown-lint` to perform the linting. + +## Deploy to GitHub Pages + +This GitHub Actions workflow is responsible for building the documentation and deploying it to GitHub Pages. This workflow is triggered when a new change is pushed to the `main` or `release` branch of the repository, and the documentation is published to GitHub Pages. -The next job, `base-test`, runs a series of tests if `check-changes` is `true` and the base branch of the pull request is `develop`. This job sets up a Python 3.9 environment, installs Poetry, and then runs tests using `pytest`. Finally, it starts the terminal and exits. +## Pull Request Labels -The next job, `tests-python`, runs tests for different versions of Python (3.8, 3.9, and 3.10) on the `ubuntu-latest` operating system. It sets up the specified Python version, installs Poetry and dependencies, and then runs tests using `pytest`. +Automatic labelling of pull requests. -The next job, `full-test`, uses the GitHub Actions `checkout` action to checkout the code, followed by the `setup-python` action to set up the specified version of Python. Then, the `install-poetry` action is used to install the package manager Poetry, and a cache is set up using the `actions/cache` action to avoid re-installing dependencies. After that, the dependencies are installed using Poetry, and a list of installed packages is displayed. Then, the tests are run using `pytest`, and finally, the `terminal.py` script is started and exited. +## 🚉 Integration test Platform (API) -The last job, `tests-conda`, sets up a Miniconda environment using the `setup-miniconda` action. The environment is specified using a YAML file and is activated. Then, the tests are run. +Run `openbb_platform` API integration tests, -## Windows 10 Build Workflow -This is a GitHub Actions workflow file that automates the build and testing process for the OpenBB Platform CLI on Windows 10. The workflow consists of two jobs: +## 🖥️ Unit test CLI -1. Windows-Build -2. Build-Exe +Run `cli` directory unit tests. -- The Windows-Build job does the following: - - Sets up the Windows Git configuration for long file paths. - - Checks out the repository code. - - Sets up Python 3.9 and creates an OpenBB environment using poetry. - - Installs necessary packages and builds the terminal using PyInstaller. - - Uploads the built artifact to GitHub as an artifact. -- The Build-Exe job does the following: - - Sets up the Windows Git configuration for long file paths. - - Checks out the repository code. - - Downloads the built artifact from the previous Windows-Build job. - - Copies the files into an app folder for building the EXE file. - - Builds the EXE file using NSIS. - - Uploads the built EXE as an artifact to GitHub. - - Runs integration tests on the terminal and saves the results to a text file. - - Uploads the test results summary to Slack. - - Cleans up previous build files and artifacts. +## 🚉 Unit test Platform -This workflow is triggered by the `workflow_dispatch` event and runs in concurrency with other workflows in the same group, with the ability to cancel in-progress builds. The concurrency group is defined as `${{ github.workflow }}-${{ github.ref }}`. \ No newline at end of file +Run `openbb_platform` directory unit tests - providers, extensions, etc. diff --git a/.github/workflows/pypi-nightly.yml b/.github/workflows/deploy-pypi-nightly.yml similarity index 100% rename from .github/workflows/pypi-nightly.yml rename to .github/workflows/deploy-pypi-nightly.yml diff --git a/.github/workflows/test_pypi_platform.yml b/.github/workflows/deploy-test-pypi.yml similarity index 96% rename from .github/workflows/test_pypi_platform.yml rename to .github/workflows/deploy-test-pypi.yml index 54fe09f7dc24..c5a3bb6a3756 100644 --- a/.github/workflows/test_pypi_platform.yml +++ b/.github/workflows/deploy-test-pypi.yml @@ -1,4 +1,4 @@ -name: Deploy the OpenBB Platform and the OpenBBTerminal to Test PyPI +name: Deploy the OpenBB Platform to Test PyPI on: push: diff --git a/.github/workflows/draft-release.yml b/.github/workflows/draft-release.yml index 3dab21e7812d..01e52ee80d56 100644 --- a/.github/workflows/draft-release.yml +++ b/.github/workflows/draft-release.yml @@ -1,4 +1,4 @@ -name: Release Drafter +name: Draft release on: workflow_dispatch: diff --git a/.github/workflows/linting.yml b/.github/workflows/general-linting.yml similarity index 96% rename from .github/workflows/linting.yml rename to .github/workflows/general-linting.yml index 2abf2e5455f0..02cf0b3454db 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/general-linting.yml @@ -1,10 +1,6 @@ name: General Linting env: - OPENBB_ENABLE_QUICK_EXIT: true - OPENBB_LOG_COLLECT: false - OPENBB_USE_PROMPT_TOOLKIT: false - OPENBB_FILE_OVERWRITE: true PIP_DEFAULT_TIMEOUT: 100 on: diff --git a/.github/workflows/labels-PR.yml b/.github/workflows/gh-pr-labels.yml similarity index 100% rename from .github/workflows/labels-PR.yml rename to .github/workflows/gh-pr-labels.yml diff --git a/.github/workflows/labels-issue.yml b/.github/workflows/labels-issue.yml deleted file mode 100644 index 01486ba0824a..000000000000 --- a/.github/workflows/labels-issue.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: "Set Issue Label and Assignee" -on: - issues: - types: [opened] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: Naturalclar/issue-action@v2.0.2 - with: - title-or-body: "both" - parameters: - '[ {"keywords": ["bug", "error", "issue"], "labels": ["bug"]}, - {"keywords": ["help", "guidance"], "labels": ["help-wanted"], "assignees": ["colin99d"]}, - {"keywords": ["portfolio"], "labels": ["portfolio"], "assignees": ["JerBouma", "montezdesousa"]}, - {"keywords": ["dashboards"], "labels": ["dashboards"], "assignees": ["colin99d"]}, - {"keywords": ["dependencies"], "labels": ["dependencies"], "assignees": ["piiq"]}, - {"keywords": ["build"], "labels": ["build"], "assignees": ["piiq"]}, - {"keywords": ["jupyter"], "labels": ["jupyterlab"], "assignees": ["piiq"]}, - {"keywords": ["reports"], "labels": ["notebookreports"], "assignees": ["piiq"]}, - {"keywords": ["installer"], "labels": ["installer"], "assignees": ["piiq", "andrewkenreich"]}, - {"keywords": ["pytest", "tests"], "labels": ["tests"], "assignees": ["Chavithra"]}, - {"keywords": ["guides"], "labels": ["guides"], "assignees": ["JerBouma"]}, - {"keywords": ["crypto"], "labels": ["crypto"], "assignees": ["minhhoang1023"]} - ]' - github-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml deleted file mode 100644 index 90d68cb9de5a..000000000000 --- a/.github/workflows/nightly-build.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Nightly Build - -env: - OPENBB_ENABLE_QUICK_EXIT: true - OPENBB_LOG_COLLECT: false - OPENBB_USE_PROMPT_TOOLKIT: false - PIP_DEFAULT_TIMEOUT: 100 - OPENBB_FILE_OVERWRITE: true - PYTHONNOUSERSITE: 1 - -on: - schedule: - - cron: "0 0 * * *" - -permissions: - actions: write - -jobs: - trigger-pypi-build: - runs-on: ubuntu-latest - steps: - - name: Trigger PyPI Build - uses: aurelien-baudet/workflow-dispatch@v2 - with: - workflow: pypi-nightly.yml - token: ${{ secrets.GITHUB_TOKEN }} - - trigger-api-integration-test: - runs-on: ubuntu-latest - steps: - - name: Trigger Platform API Integration Test - uses: aurelien-baudet/workflow-dispatch@v2 - with: - workflow: platform-api-integration-test.yml - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml deleted file mode 100644 index efc2fe5d9d8f..000000000000 --- a/.github/workflows/pypi.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Deploy to PyPI - -on: - push: - branches: - - release/v3/* - - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - deploy-test-pypi: - name: Build and publish 📦 to TestPyPI - if: startsWith(github.ref, 'refs/heads/release/') - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Setup Python 3.9 - uses: actions/setup-python@v4 - with: - python-version: "3.9" - - - name: Install pypa/build - run: >- - python -m - pip install - build - --user - - name: Build a binary wheel and a source tarball - run: >- - python -m - build - --sdist - --wheel - --outdir dist/ - . - - - name: Publish distribution 📦 to Test PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.TEST_PYPI_API_TOKEN }} - repository_url: https://test.pypi.org/legacy/ - - deploy-pypi: - name: Build and publish 📦 to PyPI - if: startsWith(github.ref, 'refs/heads/main') - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Setup Python 3.9 - uses: actions/setup-python@v4 - with: - python-version: "3.9" - - - name: Install pypa/build - run: >- - python -m - pip install - build - --user - - name: Build a binary wheel and a source tarball - run: >- - python -m - build - --sdist - --wheel - --outdir dist/ - . - - - name: Publish distribution 📦 to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/platform-api-integration-test.yml b/.github/workflows/test-integration-platform.yml similarity index 98% rename from .github/workflows/platform-api-integration-test.yml rename to .github/workflows/test-integration-platform.yml index 11b4c9c5d40f..143ca20d50a7 100644 --- a/.github/workflows/platform-api-integration-test.yml +++ b/.github/workflows/test-integration-platform.yml @@ -1,4 +1,4 @@ -name: API Integration Tests +name: 🚉 Integration test Platform (API) on: workflow_dispatch: diff --git a/.github/workflows/test-unit-cli.yml b/.github/workflows/test-unit-cli.yml new file mode 100644 index 000000000000..38c6348fc03a --- /dev/null +++ b/.github/workflows/test-unit-cli.yml @@ -0,0 +1,46 @@ +name: 🖥️ Unit test CLI + +on: + pull_request: + branches: + - develop + paths: + - 'cli/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + unit_tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + + matrix: + python_version: + ["3.9", "3.10", "3.11"] + steps: + - name: Checkout code + uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha }} + - name: Install Python ${{ matrix.python_version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python_version }} + allow-prereleases: true + cache: "pip" + + - name: Cache pip packages + uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('cli/poetry.lock') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Run tests + run: | + pip install nox + nox -f .github/scripts/noxfile.py -s unit_test_cli --python ${{ matrix.python_version }} diff --git a/.github/workflows/platform-core.yml b/.github/workflows/test-unit-platform.yml similarity index 89% rename from .github/workflows/platform-core.yml rename to .github/workflows/test-unit-platform.yml index 84ac783cfaf1..e5323f380500 100644 --- a/.github/workflows/platform-core.yml +++ b/.github/workflows/test-unit-platform.yml @@ -1,4 +1,4 @@ -name: Test Platform V4 +name: 🚉 Unit test Platform on: pull_request: @@ -43,4 +43,4 @@ jobs: - name: Run tests run: | pip install nox - nox -f .github/scripts/noxfile.py -s tests --python ${{ matrix.python_version }} + nox -f .github/scripts/noxfile.py -s unit_test_platform --python ${{ matrix.python_version }} diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml deleted file mode 100644 index 32dfc58d5aa7..000000000000 --- a/.github/workflows/unit-test.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Unit Test - -env: - OPENBB_ENABLE_QUICK_EXIT: true - OPENBB_LOG_COLLECT: false - OPENBB_USE_PROMPT_TOOLKIT: false - OPENBB_FILE_OVERWRITE: true - OPENBB_ENABLE_CHECK_API: false - OPENBB_PREVIOUS_USE: true - OPENBB_USE_INTERACTIVE_DF: false - PIP_DEFAULT_TIMEOUT: 100 - -on: - pull_request: - branches: - - develop - - main - types: [opened, synchronize, edited, closed, labeled] - push: - branches: - - release/* - merge_group: - types: [checks_requested] -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - check-files-changed: - name: Check for changes - runs-on: ubuntu-latest - # Run this job only if the PR is not merged, PR != draft and the event is not a push - if: github.event.pull_request.merged == false && github.event_name != 'push' && github.event.pull_request.draft == false - outputs: - check-changes: ${{ steps.check-changes.outputs.check-changes }} - check-platform-changes: ${{ steps.check-platform-changes.outputs.check-platform-changes }} # New output for openbb_platform changes - steps: - - name: Checkout - uses: actions/checkout@v1 # v1 is used to preserve the git history and works with the git diff command - with: - fetch-depth: 100 - # The GitHub token is preserved by default but this job doesn't need - # to be able to push to GitHub. - - # Check for changes to python files, lockfiles and the openbb_terminal folder - - name: Check for changes to files to trigger unit test - id: check-changes - run: | - current_branch=$(jq -r .pull_request.base.ref "$GITHUB_EVENT_PATH") - - if git diff --name-only origin/$current_branch HEAD | grep -E ".py$|openbb_terminal\/.*|pyproject.toml|poetry.lock|requirements.txt|requirements-full.txt"; then - echo "check-changes=true" >> $GITHUB_OUTPUT - else - echo "check-changes=false" >> $GITHUB_OUTPUT - fi - - # Check for changes to openbb_platform - - name: Check for changes to openbb_platform - id: check-platform-changes - run: | - current_branch=$(jq -r .pull_request.base.ref "$GITHUB_EVENT_PATH") - - if git diff --name-only origin/$current_branch HEAD | grep -E "openbb_platform\/.*"; then - echo "check-platform-changes=true" >> $GITHUB_OUTPUT - else - echo "check-platform-changes=false" >> $GITHUB_OUTPUT - fi - - - name: Show output of previous step - run: | - echo "check-changes=${{ steps.check-changes.outputs.check-changes }}" - echo "check-platform-changes=${{ steps.check-platform-changes.outputs.check-platform-changes }}" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5f507829eedd..887df4e3e2c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,7 +39,7 @@ repos: "--ignore-words-list=VAI,MIS,shs,gard,te,commun,parana,ro,zar,vie,hel,jewl,zlot,ba,buil,coo,ether,hist,hsi,mape,navagation,operatio,pres,ser,yeld,shold,ist,varian,datas,ake,creat,statics,ket,toke,certi,buidl,ot,fo", "--quiet-level=2", "--skip=./**/tests/**,./**/test_*.py,.git,*.css,*.csv,*.html,*.ini,*.ipynb,*.js,*.json,*.lock,*.scss,*.txt,*.yaml,build/pyinstaller/*,./website/config.toml", - "-x=.github/workflows/linting.yml" + "-x=.github/workflows/general-linting.yml" ] - repo: local hooks: diff --git a/cli/openbb_cli/config/menu_text.py b/cli/openbb_cli/config/menu_text.py index ac3d0b9ca7dc..ed2ba6ddb428 100644 --- a/cli/openbb_cli/config/menu_text.py +++ b/cli/openbb_cli/config/menu_text.py @@ -106,20 +106,6 @@ def add_raw(self, text: str, left_spacing: bool = False): else: self.menu_text += text - def add_section( - self, text: str, description: str = "", leading_new_line: bool = False - ): - """Append raw text (without translation).""" - spacing = (self.CMD_NAME_LENGTH - len(text) + self.SECTION_SPACING) * " " - if description: - text = f"{text}{spacing}{description}\n" - - if leading_new_line: - self.menu_text += "\n" + text - - else: - self.menu_text += text - def add_info(self, text: str): """Append information text (after translation).""" self.menu_text += f"[info]{text}:[/info]\n" diff --git a/cli/tests/test_argparse_translator.py b/cli/tests/test_argparse_translator.py new file mode 100644 index 000000000000..e88eb1b811f2 --- /dev/null +++ b/cli/tests/test_argparse_translator.py @@ -0,0 +1,94 @@ +"""Test the Argparse Translator.""" + +from argparse import ArgumentParser + +import pytest +from openbb_cli.argparse_translator.argparse_translator import ( + ArgparseTranslator, + CustomArgument, + CustomArgumentGroup, +) + +# pylint: disable=protected-access + + +def test_custom_argument_action_validation(): + """Test that CustomArgument raises an error for invalid actions.""" + with pytest.raises(ValueError) as excinfo: + CustomArgument( + name="test", + type=bool, + dest="test", + default=False, + required=True, + action="store", + help="Test argument", + nargs=None, + choices=None, + ) + assert 'action must be "store_true"' in str(excinfo.value) + + +def test_custom_argument_remove_props_on_store_true(): + """Test that CustomArgument removes type, nargs, and choices on store_true.""" + argument = CustomArgument( + name="verbose", + type=None, + dest="verbose", + default=None, + required=False, + action="store_true", + help="Verbose output", + nargs=None, + choices=None, + ) + assert argument.type is None + assert argument.nargs is None + assert argument.choices is None + + +def test_custom_argument_group(): + """Test the CustomArgumentGroup class.""" + args = [ + CustomArgument( + name="test", + type=int, + dest="test", + default=1, + required=True, + action="store", + help="Test argument", + nargs=None, + choices=None, + ) + ] + group = CustomArgumentGroup(name="Test Group", arguments=args) + assert group.name == "Test Group" + assert len(group.arguments) == 1 + assert group.arguments[0].name == "test" + + +def test_argparse_translator_setup(): + """Test the ArgparseTranslator setup.""" + + def test_function(test_arg: int): + """A test function.""" + return test_arg * 2 + + translator = ArgparseTranslator(func=test_function) + parser = translator.parser + assert isinstance(parser, ArgumentParser) + assert "--test_arg" in parser._option_string_actions + + +def test_argparse_translator_execution(): + """Test the ArgparseTranslator execution.""" + + def test_function(test_arg: int) -> int: + """A test function.""" + return test_arg * 2 + + translator = ArgparseTranslator(func=test_function) + parsed_args = translator.parser.parse_args(["--test_arg", "3"]) + result = translator.execute_func(parsed_args) + assert result == 6 diff --git a/cli/tests/test_argparse_translator_obbject_registry.py b/cli/tests/test_argparse_translator_obbject_registry.py new file mode 100644 index 000000000000..a37e5a335415 --- /dev/null +++ b/cli/tests/test_argparse_translator_obbject_registry.py @@ -0,0 +1,89 @@ +"""Test OBBject Registry.""" + +from unittest.mock import Mock + +import pytest +from openbb_cli.argparse_translator.obbject_registry import Registry +from openbb_core.app.model.obbject import OBBject + +# pylint: disable=redefined-outer-name, protected-access + + +@pytest.fixture +def registry(): + """Fixture to create a Registry instance for testing.""" + return Registry() + + +@pytest.fixture +def mock_obbject(): + """Fixture to create a mock OBBject for testing.""" + + class MockModel: + """Mock model for testing.""" + + def __init__(self, value): + self.mock_value = value + self._model_json_schema = "mock_json_schema" + + def model_json_schema(self): + return self._model_json_schema + + obb = Mock(spec=OBBject) + obb.id = "123" + obb.provider = "test_provider" + obb.extra = {"command": "test_command"} + obb._route = "/test/route" + obb._standard_params = Mock() + obb._standard_params.__dict__ = {} + obb.results = [MockModel(1), MockModel(2)] + return obb + + +def test_listing_all_obbjects(registry, mock_obbject): + """Test listing all obbjects with additional properties.""" + registry.register(mock_obbject) + + all_obbjects = registry.all + assert len(all_obbjects) == 1 + assert all_obbjects[0]["command"] == "test_command" + assert all_obbjects[0]["provider"] == "test_provider" + + +def test_registry_initialization(registry): + """Test the Registry is initialized correctly.""" + assert registry.obbjects == [] + + +def test_register_new_obbject(registry, mock_obbject): + """Test registering a new OBBject.""" + registry.register(mock_obbject) + assert mock_obbject in registry.obbjects + + +def test_register_duplicate_obbject(registry, mock_obbject): + """Test that duplicate OBBjects are not added.""" + registry.register(mock_obbject) + registry.register(mock_obbject) + assert len(registry.obbjects) == 1 + + +def test_get_obbject_by_index(registry, mock_obbject): + """Test retrieving an obbject by its index.""" + registry.register(mock_obbject) + retrieved = registry.get(0) + assert retrieved == mock_obbject + + +def test_remove_obbject_by_index(registry, mock_obbject): + """Test removing an obbject by index.""" + registry.register(mock_obbject) + registry.remove(0) + assert mock_obbject not in registry.obbjects + + +def test_remove_last_obbject_by_default(registry, mock_obbject): + """Test removing the last obbject by default.""" + registry.register(mock_obbject) + registry.remove() + assert not registry.obbjects diff --git a/cli/tests/test_cli.py b/cli/tests/test_cli.py new file mode 100644 index 000000000000..1fe19344f0f7 --- /dev/null +++ b/cli/tests/test_cli.py @@ -0,0 +1,45 @@ +"""Test the CLI module.""" + +from unittest.mock import patch + +from openbb_cli.cli import main + + +@patch("openbb_cli.cli.bootstrap") +@patch("openbb_cli.cli.launch") +@patch("sys.argv", ["openbb", "--dev", "--debug"]) +def test_main_with_dev_and_debug(mock_launch, mock_bootstrap): + """Test the main function with dev and debug flags.""" + main() + mock_bootstrap.assert_called_once() + mock_launch.assert_called_once_with(True, True) + + +@patch("openbb_cli.cli.bootstrap") +@patch("openbb_cli.cli.launch") +@patch("sys.argv", ["openbb"]) +def test_main_without_arguments(mock_launch, mock_bootstrap): + """Test the main function without arguments.""" + main() + mock_bootstrap.assert_called_once() + mock_launch.assert_called_once_with(False, False) + + +@patch("openbb_cli.cli.bootstrap") +@patch("openbb_cli.cli.launch") +@patch("sys.argv", ["openbb", "--dev"]) +def test_main_with_dev_only(mock_launch, mock_bootstrap): + """Test the main function with dev flag only.""" + main() + mock_bootstrap.assert_called_once() + mock_launch.assert_called_once_with(True, False) + + +@patch("openbb_cli.cli.bootstrap") +@patch("openbb_cli.cli.launch") +@patch("sys.argv", ["openbb", "--debug"]) +def test_main_with_debug_only(mock_launch, mock_bootstrap): + """Test the main function with debug flag only.""" + main() + mock_bootstrap.assert_called_once() + mock_launch.assert_called_once_with(False, True) diff --git a/cli/tests/test_config_completer.py b/cli/tests/test_config_completer.py new file mode 100644 index 000000000000..71efad99d5b8 --- /dev/null +++ b/cli/tests/test_config_completer.py @@ -0,0 +1,78 @@ +"""Test the Config completer.""" + +import pytest +from openbb_cli.config.completer import WordCompleter +from prompt_toolkit.completion import CompleteEvent +from prompt_toolkit.document import Document + +# pylint: disable=redefined-outer-name, import-outside-toplevel + + +@pytest.fixture +def word_completer(): + """Return a simple word completer.""" + words = ["test", "example", "demo"] + return WordCompleter(words, ignore_case=True) + + +def test_word_completer_simple(word_completer): + """Test the word completer with a simple word list.""" + doc = Document(text="ex", cursor_position=2) + completions = list(word_completer.get_completions(doc, CompleteEvent())) + assert len(completions) == 1 + assert completions[0].text == "example" + + +def test_word_completer_case_insensitive(word_completer): + """Test the word completer with case-insensitive matching.""" + doc = Document(text="Ex", cursor_position=2) + completions = list(word_completer.get_completions(doc, CompleteEvent())) + assert len(completions) == 1 + assert completions[0].text == "example" + + +def test_word_completer_no_match(word_completer): + """Test the word completer with no matches.""" + doc = Document(text="xyz", cursor_position=3) + completions = list(word_completer.get_completions(doc, CompleteEvent())) + assert len(completions) == 0 + + +@pytest.fixture +def nested_completer(): + """Return a nested completer.""" + from openbb_cli.config.completer import NestedCompleter + + data = { + "show": { + "version": None, + "interfaces": None, + "clock": None, + "ip": {"interface": {"brief": None}}, + }, + "exit": None, + "enable": None, + } + return NestedCompleter.from_nested_dict(data) + + +def test_nested_completer_root_command(nested_completer): + """Test the nested completer with a root command.""" + doc = Document(text="sh", cursor_position=2) + completions = list(nested_completer.get_completions(doc, CompleteEvent())) + assert "show" in [c.text for c in completions] + + +def test_nested_completer_sub_command(nested_completer): + """Test the nested completer with a sub-command.""" + doc = Document(text="show ", cursor_position=5) + completions = list(nested_completer.get_completions(doc, CompleteEvent())) + assert "version" in [c.text for c in completions] + assert "interfaces" in [c.text for c in completions] + + +def test_nested_completer_no_match(nested_completer): + """Test the nested completer with no matches.""" + doc = Document(text="random ", cursor_position=7) + completions = list(nested_completer.get_completions(doc, CompleteEvent())) + assert len(completions) == 0 diff --git a/cli/tests/test_config_console.py b/cli/tests/test_config_console.py new file mode 100644 index 000000000000..60ff91a88ac1 --- /dev/null +++ b/cli/tests/test_config_console.py @@ -0,0 +1,47 @@ +"""Test Config Console.""" + +from unittest.mock import patch + +import pytest +from openbb_cli.config.console import Console +from rich.text import Text + +# pylint: disable=redefined-outer-name, unused-argument, unused-variable, protected-access + + +@pytest.fixture +def mock_settings(): + """Mock settings to inject into Console.""" + + class MockSettings: + TEST_MODE = False + ENABLE_RICH_PANEL = True + SHOW_VERSION = True + VERSION = "1.0" + + return MockSettings() + + +@pytest.fixture +def console(mock_settings): + """Create a Console instance with mocked settings.""" + with patch("rich.console.Console") as MockRichConsole: # noqa: F841 + return Console(settings=mock_settings) + + +def test_print_without_panel(console, mock_settings): + """Test printing without a rich panel when disabled.""" + mock_settings.ENABLE_RICH_PANEL = False + with patch.object(console._console, "print") as mock_print: + console.print(text="Hello, world!", menu="Home Menu") + mock_print.assert_called_once_with("Hello, world!") + + +def test_blend_text(): + """Test blending text colors.""" + message = "Hello" + color1 = (255, 0, 0) # Red + color2 = (0, 0, 255) # Blue + blended_text = Console._blend_text(message, color1, color2) + assert isinstance(blended_text, Text) + assert "Hello" in blended_text.plain diff --git a/cli/tests/test_config_menu_text.py b/cli/tests/test_config_menu_text.py new file mode 100644 index 000000000000..10956ccbac55 --- /dev/null +++ b/cli/tests/test_config_menu_text.py @@ -0,0 +1,68 @@ +"""Test Config Menu Text.""" + +import pytest +from openbb_cli.config.menu_text import MenuText + +# pylint: disable=redefined-outer-name, protected-access + + +@pytest.fixture +def menu_text(): + """Fixture to create a MenuText instance for testing.""" + return MenuText(path="/test/path") + + +def test_initialization(menu_text): + """Test initialization of the MenuText class.""" + assert menu_text.menu_text == "" + assert menu_text.menu_path == "/test/path" + assert menu_text.warnings == [] + + +def test_add_raw(menu_text): + """Test adding raw text.""" + menu_text.add_raw("Example raw text") + assert "Example raw text" in menu_text.menu_text + + +def test_add_info(menu_text): + """Test adding informational text.""" + menu_text.add_info("Info text") + assert "[info]Info text:[/info]" in menu_text.menu_text + + +def test_add_cmd(menu_text): + """Test adding a command.""" + menu_text.add_cmd("command", "Performs an action") + assert "command" in menu_text.menu_text + assert "Performs an action" in menu_text.menu_text + + +def test_format_cmd_name(menu_text): + """Test formatting of command names that are too long.""" + long_name = "x" * 50 # Assuming CMD_NAME_LENGTH is 23 + formatted_name = menu_text._format_cmd_name(long_name) + assert len(formatted_name) <= menu_text.CMD_NAME_LENGTH + assert menu_text.warnings # Check that a warning was added + + +def test_format_cmd_description(menu_text): + """Test truncation of long descriptions.""" + long_description = "y" * 100 # Assuming CMD_DESCRIPTION_LENGTH is 65 + formatted_description = menu_text._format_cmd_description("cmd", long_description) + assert len(formatted_description) <= menu_text.CMD_DESCRIPTION_LENGTH + + +def test_add_menu(menu_text): + """Test adding a menu item.""" + menu_text.add_menu("Settings", "Configure your settings") + assert "Settings" in menu_text.menu_text + assert "Configure your settings" in menu_text.menu_text + + +def test_add_setting(menu_text): + """Test adding a setting.""" + menu_text.add_setting("Enable Feature", True, "Feature description") + assert "Enable Feature" in menu_text.menu_text + assert "Feature description" in menu_text.menu_text + assert "[green]" in menu_text.menu_text diff --git a/cli/tests/test_config_setup.py b/cli/tests/test_config_setup.py new file mode 100644 index 000000000000..7c01469063b4 --- /dev/null +++ b/cli/tests/test_config_setup.py @@ -0,0 +1,49 @@ +"""Test the Config Setup.""" + +from unittest.mock import patch + +import pytest +from openbb_cli.config.setup import bootstrap + +# pylint: disable=unused-variable + + +def test_bootstrap_creates_directory_and_file(): + """Test that bootstrap creates the settings directory and environment file.""" + with patch("pathlib.Path.mkdir") as mock_mkdir, patch( + "pathlib.Path.touch" + ) as mock_touch: + bootstrap() + mock_mkdir.assert_called_once_with(parents=True, exist_ok=True) + mock_touch.assert_called_once_with(exist_ok=True) + + +def test_bootstrap_directory_exists(): + """Test bootstrap when the directory already exists.""" + with patch("pathlib.Path.mkdir") as mock_mkdir, patch( + "pathlib.Path.touch" + ) as mock_touch: + bootstrap() + mock_mkdir.assert_called_once_with(parents=True, exist_ok=True) + mock_touch.assert_called_once_with(exist_ok=True) + + +def test_bootstrap_file_exists(): + """Test bootstrap when the environment file already exists.""" + with patch("pathlib.Path.mkdir") as mock_mkdir, patch( + "pathlib.Path.touch" + ) as mock_touch: + bootstrap() + mock_mkdir.assert_called_once_with(parents=True, exist_ok=True) + mock_touch.assert_called_once_with(exist_ok=True) + + +def test_bootstrap_permission_error(): + """Test bootstrap handles permission errors gracefully.""" + with patch("pathlib.Path.mkdir") as mock_mkdir, patch( + "pathlib.Path.touch" + ) as mock_touch, pytest.raises( # noqa: F841 + PermissionError + ): + mock_mkdir.side_effect = PermissionError("No permission to create directory") + bootstrap() # Expecting to raise a PermissionError and be caught by pytest.raises diff --git a/cli/tests/test_config_style.py b/cli/tests/test_config_style.py new file mode 100644 index 000000000000..72e9c2a2f38f --- /dev/null +++ b/cli/tests/test_config_style.py @@ -0,0 +1,60 @@ +"""Test Config Style.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from openbb_cli.config.style import Style + +# pylint: disable=redefined-outer-name, protected-access + + +@pytest.fixture +def mock_style_directory(tmp_path): + """Fixture to create a mock styles directory.""" + (tmp_path / "styles" / "default").mkdir(parents=True, exist_ok=True) + return tmp_path / "styles" + + +@pytest.fixture +def style(mock_style_directory): + """Fixture to create a Style instance for testing.""" + return Style(directory=mock_style_directory) + + +def test_initialization(style): + """Test that Style class initializes with default properties.""" + assert style.line_width == 1.5 + assert isinstance(style.console_style, dict) + + +@patch("pathlib.Path.exists", MagicMock(return_value=True)) +@patch("pathlib.Path.rglob") +def test_load_styles(mock_rglob, style, mock_style_directory): + """Test loading styles from directories.""" + mock_rglob.return_value = [mock_style_directory / "default" / "dark.richstyle.json"] + style._load(mock_style_directory) + assert "dark" in style.console_styles_available + + +@patch("builtins.open", new_callable=MagicMock) +@patch("json.load", MagicMock(return_value={"background": "black"})) +def test_from_json(mock_open, style, mock_style_directory): + """Test loading style from a JSON file.""" + json_file = mock_style_directory / "dark.richstyle.json" + result = style._from_json(json_file) + assert result == {"background": "black"} + mock_open.assert_called_once_with(json_file) + + +def test_apply_invalid_style(style, mock_style_directory, capsys): + """Test applying an invalid style and falling back to default.""" + style.apply("nonexistent", mock_style_directory) + captured = capsys.readouterr() + assert "Invalid console style" in captured.out + + +def test_available_styles(style): + """Test listing available styles.""" + style.console_styles_available = {"dark": Path("/path/to/dark.richstyle.json")} + assert "dark" in style.available_styles diff --git a/cli/tests/test_controllers_base_controller.py b/cli/tests/test_controllers_base_controller.py new file mode 100644 index 000000000000..465a5b61e5e6 --- /dev/null +++ b/cli/tests/test_controllers_base_controller.py @@ -0,0 +1,79 @@ +"""Test the base controller.""" + +from unittest.mock import MagicMock, patch + +import pytest +from openbb_cli.controllers.base_controller import BaseController + +# pylint: disable=unused-argument, unused-variable + + +class TestableBaseController(BaseController): + """Testable Base Controller.""" + + def __init__(self, queue=None): + """Initialize the TestableBaseController.""" + self.PATH = "/valid/path/" + super().__init__(queue=queue) + + def print_help(self): + """Print help.""" + + +def test_base_controller_initialization(): + """Test the initialization of the base controller.""" + with patch.object(TestableBaseController, "check_path", return_value=None): + controller = TestableBaseController() + assert controller.path == ["valid", "path"] # Checking for correct path split + + +def test_path_validation(): + """Test the path validation method.""" + controller = TestableBaseController() + + with pytest.raises(ValueError): + controller.PATH = "invalid/path" + controller.check_path() + + with pytest.raises(ValueError): + controller.PATH = "/invalid/path" + controller.check_path() + + with pytest.raises(ValueError): + controller.PATH = "/Invalid/Path/" + controller.check_path() + + controller.PATH = "/valid/path/" + + +def test_parse_input(): + """Test the parse input method.""" + controller = TestableBaseController() + input_str = "cmd1/cmd2/cmd3" + expected = ["cmd1", "cmd2", "cmd3"] + result = controller.parse_input(input_str) + assert result == expected + + +def test_switch(): + """Test the switch method.""" + controller = TestableBaseController() + with patch.object(controller, "call_exit", MagicMock()) as mock_exit: + controller.queue = ["exit"] + controller.switch("exit") + mock_exit.assert_called_once() + + +def test_call_help(): + """Test the call help method.""" + controller = TestableBaseController() + with patch("openbb_cli.controllers.base_controller.session.console.print"): + controller.call_help(None) + + +def test_call_exit(): + """Test the call exit method.""" + controller = TestableBaseController() + with patch.object(controller, "save_class", MagicMock()): + controller.queue = ["quit"] + controller.call_exit(None) diff --git a/cli/tests/test_controllers_base_platform_controller.py b/cli/tests/test_controllers_base_platform_controller.py new file mode 100644 index 000000000000..cabfe44cd6bd --- /dev/null +++ b/cli/tests/test_controllers_base_platform_controller.py @@ -0,0 +1,70 @@ +"""Test the BasePlatformController.""" + +from unittest.mock import MagicMock, patch + +import pytest +from openbb_cli.controllers.base_platform_controller import PlatformController, Session + +# pylint: disable=redefined-outer-name, protected-access, unused-argument, unused-variable + + +@pytest.fixture +def mock_session(): + """Mock session fixture.""" + with patch( + "openbb_cli.controllers.base_platform_controller.session", + MagicMock(spec=Session), + ) as mock: + yield mock + + +def test_initialization_with_valid_params(mock_session): + """Test the initialization of the BasePlatformController.""" + translators = {"dummy_translator": MagicMock()} + controller = PlatformController( + name="test", parent_path=["parent"], translators=translators + ) + assert controller._name == "test" + assert controller.translators == translators + + +def test_initialization_without_required_params(): + """Test the initialization of the BasePlatformController without required params.""" + with pytest.raises(ValueError): + PlatformController(name="test", parent_path=["parent"]) + + +def test_command_generation(mock_session): + """Test the command generation method.""" + translator = MagicMock() + translators = {"test_command": translator} + controller = PlatformController( + name="test", parent_path=["parent"], translators=translators + ) + + # Check if command function is correctly linked + assert "test_command" in controller.translators + + +def test_print_help(mock_session): + """Test the print help method.""" + translators = {"test_command": MagicMock()} + controller = PlatformController( + name="test", parent_path=["parent"], translators=translators + ) + + with patch( + "openbb_cli.controllers.base_platform_controller.MenuText" + ) as mock_menu_text: + controller.print_help() + mock_menu_text.assert_called_once_with("/parent/test/") + + +def test_sub_controller_generation(mock_session): + """Test the sub controller generation method.""" + translators = {"test_menu_item": MagicMock()} + controller = PlatformController( + name="test", parent_path=["parent"], translators=translators + ) + + assert "test_menu_item" in controller.translators diff --git a/cli/tests/test_controllers_choices.py b/cli/tests/test_controllers_choices.py new file mode 100644 index 000000000000..6d604751b1f6 --- /dev/null +++ b/cli/tests/test_controllers_choices.py @@ -0,0 +1,51 @@ +"""Test the choices controller.""" + +from argparse import ArgumentParser +from unittest.mock import patch + +import pytest +from openbb_cli.controllers.choices import ( + build_controller_choice_map, +) + +# pylint: disable=redefined-outer-name, protected-access, unused-argument, unused-variable + + +class MockController: + """Mock controller class for testing.""" + + CHOICES_COMMANDS = ["test_command"] + controller_choices = ["test_command", "help"] + + def call_test_command(self, args): + """Mock function for test_command.""" + parser = ArgumentParser() + parser.add_argument( + "--example", choices=["option1", "option2"], help="Example argument." + ) + return parser.parse_args(args) + + +@pytest.fixture +def mock_controller(): + """Mock controller fixture.""" + return MockController() + + +def test_build_command_choice_map(mock_controller): + """Test the building of a command choice map.""" + with patch( + "openbb_cli.controllers.choices._get_argument_parser" + ) as mock_get_parser: + parser = ArgumentParser() + parser.add_argument( + "--option", choices=["opt1", "opt2"], help="A choice option." + ) + mock_get_parser.return_value = parser + + choice_map = build_controller_choice_map(controller=mock_controller) + + assert "test_command" in choice_map + assert "--option" in choice_map["test_command"] + assert "opt1" in choice_map["test_command"]["--option"] + assert "opt2" in choice_map["test_command"]["--option"] diff --git a/cli/tests/test_controllers_cli_controller.py b/cli/tests/test_controllers_cli_controller.py new file mode 100644 index 000000000000..7cb7c6578a92 --- /dev/null +++ b/cli/tests/test_controllers_cli_controller.py @@ -0,0 +1,79 @@ +"""Test the CLI controller.""" + +from unittest.mock import MagicMock, patch + +import pytest +from openbb_cli.controllers.cli_controller import ( + CLIController, + handle_job_cmds, + parse_and_split_input, + run_cli, +) + +# pylint: disable=redefined-outer-name, unused-argument + + +def test_parse_and_split_input_custom_filters(): + """Test the parse_and_split_input function with custom filters.""" + input_cmd = "query -q AAPL/P" + result = parse_and_split_input( + input_cmd, custom_filters=[r"((\ -q |\ --question|\ ).*?(/))"] + ) + assert ( + "AAPL/P" not in result + ), "Should filter out terms that look like a sorting parameter" + + +@patch("openbb_cli.controllers.cli_controller.CLIController.print_help") +def test_cli_controller_print_help(mock_print_help): + """Test the CLIController print_help method.""" + controller = CLIController() + controller.print_help() + mock_print_help.assert_called_once() + + +@pytest.mark.parametrize( + "controller_input, expected_output", + [ + ("settings", True), + ("random_command", False), + ], +) +def test_CLIController_has_command(controller_input, expected_output): + """Test the CLIController has_command method.""" + controller = CLIController() + assert hasattr(controller, f"call_{controller_input}") == expected_output + + +def test_handle_job_cmds_with_export_path(): + """Test the handle_job_cmds function with an export path.""" + jobs_cmds = ["export /path/to/export some_command"] + result = handle_job_cmds(jobs_cmds) + expected = "some_command" + assert expected in result[0] # type: ignore + + +@patch("openbb_cli.controllers.cli_controller.CLIController.switch", return_value=[]) +@patch("openbb_cli.controllers.cli_controller.print_goodbye") +def test_run_cli_quit_command(mock_print_goodbye, mock_switch): + """Test the run_cli function with the quit command.""" + run_cli(["quit"], test_mode=True) + mock_print_goodbye.assert_called_once() + + +@pytest.mark.skip("This test is not working as expected") +def test_execute_openbb_routine_with_mocked_requests(): + """Test the call_exe function with mocked requests.""" + with patch("requests.get") as mock_get: + response = MagicMock() + response.status_code = 200 + response.json.return_value = {"script": "print('Hello World')"} + mock_get.return_value = response + # Here we need to call the correct function, assuming it's something like `call_exe` for URL-based scripts + controller = CLIController() + controller.call_exe( + ["--url", "https://my.openbb.co/u/test/routine/test.openbb"] + ) + mock_get.assert_called_with( + "https://my.openbb.co/u/test/routine/test.openbb?raw=true", timeout=10 + ) diff --git a/cli/tests/test_controllers_controller_factory.py b/cli/tests/test_controllers_controller_factory.py new file mode 100644 index 000000000000..8832885b47af --- /dev/null +++ b/cli/tests/test_controllers_controller_factory.py @@ -0,0 +1,61 @@ +"""Test the Controller Factory.""" + +from unittest.mock import MagicMock, patch + +import pytest +from openbb_cli.controllers.platform_controller_factory import ( + PlatformControllerFactory, +) + +# pylint: disable=redefined-outer-name, unused-argument + + +@pytest.fixture +def mock_processor(): + """Fixture to mock ArgparseClassProcessor.""" + with patch( + "openbb_cli.controllers.platform_controller_factory.ArgparseClassProcessor" + ) as mock: + instance = mock.return_value + instance.paths = {"settings": "menu"} + instance.translators = {"test_router_settings": MagicMock()} + yield instance + + +@pytest.fixture +def platform_router(): + """Fixture to provide a mock platform_router class.""" + + class MockRouter: + pass + + return MockRouter + + +@pytest.fixture +def factory(platform_router, mock_processor): + """Fixture to create a PlatformControllerFactory with mocked dependencies.""" + return PlatformControllerFactory( + platform_router=platform_router, reference={"test": "ref"} + ) + + +def test_init(mock_processor): + """Test the initialization of the PlatformControllerFactory.""" + factory = PlatformControllerFactory( + platform_router=MagicMock(), reference={"test": "ref"} + ) + assert factory.router_name.lower() == "magicmock" + assert factory.controller_name == "MagicmockController" + + +def test_create_controller(factory): + """Test the creation of a controller class.""" + ControllerClass = factory.create() + + assert "PlatformController" in [base.__name__ for base in ControllerClass.__bases__] + assert ControllerClass.CHOICES_GENERATION + assert "settings" in ControllerClass.CHOICES_MENUS + assert "test_router_settings" not in [ + cmd.replace("test_router_", "") for cmd in ControllerClass.CHOICES_COMMANDS + ] diff --git a/cli/tests/test_controllers_script_parser.py b/cli/tests/test_controllers_script_parser.py new file mode 100644 index 000000000000..4363b0d79fe0 --- /dev/null +++ b/cli/tests/test_controllers_script_parser.py @@ -0,0 +1,106 @@ +"""Test Script parser.""" + +from datetime import datetime, timedelta + +import pytest +from openbb_cli.controllers.script_parser import ( + match_and_return_openbb_keyword_date, + parse_openbb_script, +) + +# pylint: disable=import-outside-toplevel, unused-variable, line-too-long + + +@pytest.mark.parametrize( + "command, expected", + [ + ("reset", True), + ("r", True), + ("r\n", True), + ("restart", False), + ], +) +def test_is_reset(command, expected): + """Test the is_reset function.""" + from openbb_cli.controllers.script_parser import is_reset + + assert is_reset(command) == expected + + +@pytest.mark.parametrize( + "keyword, expected_date", + [ + ( + "$LASTFRIDAY", + ( + datetime.now() + - timedelta(days=((datetime.now().weekday() - 4) % 7 + 7) % 7) + ).strftime("%Y-%m-%d"), + ), + ], +) +def test_match_and_return_openbb_keyword_date(keyword, expected_date): + """Test the match_and_return_openbb_keyword_date function.""" + result = match_and_return_openbb_keyword_date(keyword) + assert result == expected_date + + +def test_parse_openbb_script_basic(): + """Test the parse_openbb_script function.""" + raw_lines = ["echo 'Hello World'"] + error, script = parse_openbb_script(raw_lines) + assert error == "" + assert script == "/echo 'Hello World'" + + +def test_parse_openbb_script_with_variable(): + """Test the parse_openbb_script function.""" + raw_lines = ["$VAR = 2022-01-01", "echo $VAR"] + error, script = parse_openbb_script(raw_lines) + assert error == "" + assert script == "/echo 2022-01-01" + + +def test_parse_openbb_script_with_foreach_loop(): + """Test the parse_openbb_script function.""" + raw_lines = ["foreach $$DATE in 2022-01-01,2022-01-02", "echo $$DATE", "end"] + error, script = parse_openbb_script(raw_lines) + assert error == "" + assert script == "/echo 2022-01-01/echo 2022-01-02" + + +def test_parse_openbb_script_with_error(): + """Test the parse_openbb_script function.""" + raw_lines = ["$VAR = ", "echo $VAR"] + error, script = parse_openbb_script(raw_lines) + assert "Variable $VAR not given" in error + + +@pytest.mark.parametrize( + "line, expected", + [ + ( + "foreach $$VAR in 2022-01-01", + "[red]The script has a foreach loop that doesn't terminate. Add the keyword 'end' to explicitly terminate loop[/red]", # noqa: E501 + ), + ("echo Hello World", ""), + ( + "end", + "[red]The script has a foreach loop that terminates before it gets started. Add the keyword 'foreach' to explicitly start loop[/red]", # noqa: E501 + ), + ], +) +def test_parse_openbb_script_foreach_errors(line, expected): + """Test the parse_openbb_script function.""" + error, script = parse_openbb_script([line]) + assert error == expected + + +def test_date_keyword_last_friday(): + """Test the match_and_return_openbb_keyword_date function.""" + today = datetime.now() + last_friday = today - timedelta(days=(today.weekday() - 4 + 7) % 7) + if last_friday > today: + last_friday -= timedelta(days=7) + expected_date = last_friday.strftime("%Y-%m-%d") + assert match_and_return_openbb_keyword_date("$LASTFRIDAY") == expected_date diff --git a/cli/tests/test_controllers_settings_controller.py b/cli/tests/test_controllers_settings_controller.py new file mode 100644 index 000000000000..439281392057 --- /dev/null +++ b/cli/tests/test_controllers_settings_controller.py @@ -0,0 +1,135 @@ +"""Test the SettingsController class.""" + +from unittest.mock import MagicMock, patch + +import pytest +from openbb_cli.controllers.settings_controller import SettingsController + +# pylint: disable=redefined-outer-name, unused-argument + + +@pytest.fixture +def mock_session(): + with patch("openbb_cli.controllers.settings_controller.session") as mock: + + mock.settings.USE_INTERACTIVE_DF = False + mock.settings.ALLOWED_NUMBER_OF_ROWS = 20 + mock.settings.TIMEZONE = "UTC" + + mock.settings.set_item = MagicMock() + + yield mock + + +def test_call_interactive(mock_session): + controller = SettingsController() + controller.call_interactive(None) + mock_session.settings.set_item.assert_called_once_with("USE_INTERACTIVE_DF", True) + + +@pytest.mark.parametrize( + "input_rows, expected", + [ + (10, 10), + (15, 15), + ], +) +def test_call_n_rows(input_rows, expected, mock_session): + controller = SettingsController() + args = ["--rows", str(input_rows)] + controller.call_n_rows(args) + mock_session.settings.set_item.assert_called_with( + "ALLOWED_NUMBER_OF_ROWS", expected + ) + + +def test_call_n_rows_no_args_provided(mock_session): + controller = SettingsController() + controller.call_n_rows([]) + mock_session.console.print.assert_called_with("Current number of rows: 20") + + +@pytest.mark.parametrize( + "timezone, valid", + [ + ("UTC", True), + ("Mars/Phobos", False), + ], +) +def test_call_timezone(timezone, valid, mock_session): + with patch( + "openbb_cli.controllers.settings_controller.is_timezone_valid", + return_value=valid, + ): + controller = SettingsController() + args = ["--timezone", timezone] + controller.call_timezone(args) + if valid: + mock_session.settings.set_item.assert_called_with("TIMEZONE", timezone) + else: + mock_session.settings.set_item.assert_not_called() + + +def test_call_console_style(mock_session): + controller = SettingsController() + args = ["--style", "dark"] + controller.call_console_style(args) + mock_session.console.print.assert_called() + + +def test_call_console_style_no_args(mock_session): + mock_session.settings.RICH_STYLE = "default" + controller = SettingsController() + controller.call_console_style([]) + mock_session.console.print.assert_called_with("Current console style: default") + + +def test_call_flair(mock_session): + controller = SettingsController() + args = ["--flair", "rocket"] + controller.call_flair(args) + + +def test_call_flair_no_args(mock_session): + mock_session.settings.FLAIR = "star" + controller = SettingsController() + controller.call_flair([]) + mock_session.console.print.assert_called_with("Current flair: star") + + +def test_call_obbject_display(mock_session): + controller = SettingsController() + args = ["--number", "5"] + controller.call_obbject_display(args) + mock_session.settings.set_item.assert_called_once_with( + "N_TO_DISPLAY_OBBJECT_REGISTRY", 5 + ) + + +def test_call_obbject_display_no_args(mock_session): + mock_session.settings.N_TO_DISPLAY_OBBJECT_REGISTRY = 10 + controller = SettingsController() + controller.call_obbject_display([]) + mock_session.console.print.assert_called_with( + "Current number of results to display from the OBBject registry: 10" + ) + + +@pytest.mark.parametrize( + "args, expected", + [ + (["--rows", "50"], 50), + (["--rows", "100"], 100), + ([], 20), + ], +) +def test_call_n_rows_v2(args, expected, mock_session): + mock_session.settings.ALLOWED_NUMBER_OF_ROWS = 20 + controller = SettingsController() + controller.call_n_rows(args) + if args: + mock_session.settings.set_item.assert_called_with( + "ALLOWED_NUMBER_OF_ROWS", expected + ) + else: + mock_session.console.print.assert_called_with("Current number of rows: 20") diff --git a/cli/tests/test_controllers_utils.py b/cli/tests/test_controllers_utils.py new file mode 100644 index 000000000000..84c7548c9df8 --- /dev/null +++ b/cli/tests/test_controllers_utils.py @@ -0,0 +1,157 @@ +"""Test the Controller utils.""" + +import argparse +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from openbb_cli.controllers.utils import ( + check_non_negative, + check_positive, + get_flair_and_username, + get_user_agent, + parse_and_split_input, + print_goodbye, + print_guest_block_msg, + remove_file, + reset, + welcome_message, +) + +# pylint: disable=redefined-outer-name, unused-argument + + +@pytest.fixture +def mock_session(): + """Mock the session and its dependencies.""" + with patch("openbb_cli.controllers.utils.Session", autospec=True) as mock: + mock.return_value.console.print = MagicMock() + mock.return_value.is_local = MagicMock(return_value=True) + mock.return_value.settings.VERSION = "1.0" + mock.return_value.user.profile.hub_session.username = "testuser" + mock.return_value.settings.FLAIR = "rocket" + yield mock + + +def test_remove_file_existing_file(): + """Test removing an existing file.""" + with patch("os.path.isfile", return_value=True), patch("os.remove") as mock_remove: + assert remove_file(Path("/path/to/file")) + mock_remove.assert_called_once() + + +def test_remove_file_directory(): + """Test removing a directory.""" + with patch("os.path.isfile", return_value=False), patch( + "os.path.isdir", return_value=True + ), patch("shutil.rmtree") as mock_rmtree: + assert remove_file(Path("/path/to/directory")) + mock_rmtree.assert_called_once() + + +def test_remove_file_failure(mock_session): + """Test removing a file that fails.""" + with patch("os.path.isfile", return_value=True), patch( + "os.remove", side_effect=Exception("Error") + ): + assert not remove_file(Path("/path/to/file")) + mock_session.return_value.console.print.assert_called() + + +def test_print_goodbye(mock_session): + """Test printing the goodbye message.""" + print_goodbye() + mock_session.return_value.console.print.assert_called() + + +def test_reset(mock_session): + """Test resetting the CLI.""" + with patch("openbb_cli.controllers.utils.remove_file"), patch( + "sys.modules", new_callable=dict + ): + reset() + mock_session.return_value.console.print.assert_called() + + +def test_parse_and_split_input(): + """Test parsing and splitting user input.""" + user_input = "ls -f /home/user/docs/document.xlsx" + result = parse_and_split_input(user_input, []) + assert "ls" in result[0] + + +@pytest.mark.parametrize( + "input_command, expected_output", + [ + ("/", ["home"]), + ("ls -f /path/to/file.txt", ["ls -f ", "path", "to", "file.txt"]), + ("rm -f /home/user/docs", ["rm -f ", "home", "user", "docs"]), + ], +) +def test_parse_and_split_input_special_cases(input_command, expected_output): + """Test parsing and splitting user input with special cases.""" + result = parse_and_split_input(input_command, []) + assert result == expected_output + + +def test_print_guest_block_msg(mock_session): + """Test printing the guest block message.""" + print_guest_block_msg() + mock_session.return_value.console.print.assert_called() + + +def test_welcome_message(mock_session): + """Test printing the welcome message.""" + welcome_message() + mock_session.return_value.console.print.assert_called_with( + "\nWelcome to OpenBB Platform CLI v1.0" + ) + + +def test_get_flair_and_username(mock_session): + """Test getting the flair and username.""" + result = get_flair_and_username() + assert "testuser" in result + assert "rocket" in result # + + +@pytest.mark.parametrize( + "value, expected", + [ + ("10", 10), + ("0", 0), + ("-1", pytest.raises(argparse.ArgumentTypeError)), + ("text", pytest.raises(ValueError)), + ], +) +def test_check_non_negative(value, expected): + """Test checking for a non-negative value.""" + if isinstance(expected, int): + assert check_non_negative(value) == expected + else: + with expected: + check_non_negative(value) + + +@pytest.mark.parametrize( + "value, expected", + [ + ("1", 1), + ("0", pytest.raises(argparse.ArgumentTypeError)), + ("-1", pytest.raises(argparse.ArgumentTypeError)), + ("text", pytest.raises(ValueError)), + ], +) +def test_check_positive(value, expected): + """Test checking for a positive value.""" + if isinstance(expected, int): + assert check_positive(value) == expected + else: + with expected: + check_positive(value) + + +def test_get_user_agent(): + """Test getting the user agent.""" + result = get_user_agent() + assert result.startswith("Mozilla/5.0") diff --git a/cli/tests/test_models_settings.py b/cli/tests/test_models_settings.py new file mode 100644 index 000000000000..0834355516f4 --- /dev/null +++ b/cli/tests/test_models_settings.py @@ -0,0 +1,72 @@ +"""Test the Models Settings module.""" + +from unittest.mock import mock_open, patch + +from openbb_cli.models.settings import Settings + +# pylint: disable=unused-argument + + +def test_default_values(): + """Test the default values of the settings model.""" + settings = Settings() + assert settings.VERSION == "1.0.0" + assert settings.TEST_MODE is False + assert settings.DEBUG_MODE is False + assert settings.DEV_BACKEND is False + assert settings.FILE_OVERWRITE is False + assert settings.SHOW_VERSION is True + assert settings.USE_INTERACTIVE_DF is True + assert settings.USE_CLEAR_AFTER_CMD is False + assert settings.USE_DATETIME is True + assert settings.USE_PROMPT_TOOLKIT is True + assert settings.ENABLE_EXIT_AUTO_HELP is True + assert settings.REMEMBER_CONTEXTS is True + assert settings.ENABLE_RICH_PANEL is True + assert settings.TOOLBAR_HINT is True + assert settings.SHOW_MSG_OBBJECT_REGISTRY is False + assert settings.TIMEZONE == "America/New_York" + assert settings.FLAIR == ":openbb" + assert settings.PREVIOUS_USE is False + assert settings.N_TO_KEEP_OBBJECT_REGISTRY == 10 + assert settings.N_TO_DISPLAY_OBBJECT_REGISTRY == 5 + assert settings.RICH_STYLE == "dark" + assert settings.ALLOWED_NUMBER_OF_ROWS == 20 + assert settings.ALLOWED_NUMBER_OF_COLUMNS == 5 + assert settings.HUB_URL == "https://my.openbb.co" + assert settings.BASE_URL == "https://payments.openbb.co" + + +# Test __repr__ output +def test_repr(): + """Test the __repr__ method of the settings model.""" + settings = Settings() + repr_str = settings.__repr__() # pylint: disable=C2801 + assert "Settings\n\n" in repr_str + assert "VERSION: 1.0.0" in repr_str + + +# Test loading from environment variables +@patch( + "openbb_cli.models.settings.dotenv_values", + return_value={"OPENBB_TEST_MODE": "True", "OPENBB_VERSION": "2.0.0"}, +) +def test_from_env(mock_dotenv_values): + """Test loading settings from environment variables.""" + settings = Settings.from_env({}) # type: ignore + assert settings["TEST_MODE"] == "True" + assert settings["VERSION"] == "2.0.0" + + +# Test setting an item and updating .env +@patch("openbb_cli.models.settings.set_key") +@patch( + "openbb_cli.models.settings.open", + new_callable=mock_open, + read_data="TEST_MODE=False\n", +) +def test_set_item(mock_file, mock_set_key): + """Test setting an item and updating the .env file.""" + settings = Settings() + settings.set_item("TEST_MODE", True) + assert settings.TEST_MODE is True diff --git a/cli/tests/test_session.py b/cli/tests/test_session.py new file mode 100644 index 000000000000..66ca9fccab8b --- /dev/null +++ b/cli/tests/test_session.py @@ -0,0 +1,44 @@ +"Test the Session class." +from unittest.mock import MagicMock, patch + +import pytest +from openbb_cli.models.settings import Settings +from openbb_cli.session import Session, sys + +# pylint: disable=redefined-outer-name, unused-argument, protected-access + + +def mock_isatty(return_value): + """Mock the isatty method.""" + original_isatty = sys.stdin.isatty + sys.stdin.isatty = MagicMock(return_value=return_value) # type: ignore + return original_isatty + + +@pytest.fixture +def session(): + """Session fixture.""" + return Session() + + +def test_session_initialization(session): + """Test the initialization of the Session class.""" + assert session.settings is not None + assert session.style is not None + assert session.console is not None + assert session.obbject_registry is not None + assert isinstance(session.settings, Settings) + + +@patch("sys.stdin.isatty", return_value=True) +def test_get_prompt_session_true(mock_isatty, session): + "Test get_prompt_session method." + prompt_session = session._get_prompt_session() + assert prompt_session is not None + + +@patch("sys.stdin.isatty", return_value=False) +def test_get_prompt_session_false(mock_isatty, session): + "Test get_prompt_session method." + prompt_session = session._get_prompt_session() + assert prompt_session is None diff --git a/openbb_platform/dev_install.py b/openbb_platform/dev_install.py index 391aa08fa280..0b8695366fd0 100644 --- a/openbb_platform/dev_install.py +++ b/openbb_platform/dev_install.py @@ -129,7 +129,7 @@ def install_local(_extras: bool = False): print("Restoring pyproject.toml and poetry.lock") # noqa: T201 finally: - # Revert pyproject.toml and poetry.lock to their original state + # Revert pyproject.toml and poetry.lock to their original state. with open(PYPROJECT, "w", encoding="utf-8", newline="\n") as f: f.write(original_pyproject)