[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 <henriquecjoaquim@gmail.com>
Co-authored-by: Diogo Sousa <montezdesousa@gmail.com>
Co-authored-by: montezdesousa <79287829+montezdesousa@users.noreply.github.com>
This commit is contained in:
Igor Radovanovic
2024-05-14 17:38:06 +02:00
committed by GitHub
parent f87c21ed3c
commit a116d9bbf2
35 changed files with 1502 additions and 416 deletions

View File

@@ -9,10 +9,12 @@ PLATFORM_DIR = ROOT_DIR / "openbb_platform"
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}")

View File

@@ -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
## Branch Name Check Workflow
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/<feature-name>
- Hotfix branches: hotfix/<hotfix-name>
- Release branches: release/<major.minor.patch>(rc<number>)
- Feature branches: feature/<feature-name>
- Hotfix branches: hotfix/<hotfix-name>
- Release branches: release/<major.minor.patch>(rc<number>)
## 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:
## Deploy to PyPI - Nightly
- `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
4. Trigger Docker Build: This step uses the `aurelien-baudet/workflow-dispatch` action to trigger the docker.yml workflow
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 `<currentVersion>.dev<date>`, where `<date>` 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 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.
The first job, `code-linting`, runs on an Ubuntu machine and performs several linting tasks on the code in the repository, including:
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`.
- 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 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.
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.
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.
## Deploy to GitHub Pages
## 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:
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.
1. Windows-Build
2. Build-Exe
## Pull Request Labels
- 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.
Automatic labelling of pull requests.
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 }}`.
## 🚉 Integration test Platform (API)
Run `openbb_platform` API integration tests,
## 🖥️ Unit test CLI
Run `cli` directory unit tests.
## 🚉 Unit test Platform
Run `openbb_platform` directory unit tests - providers, extensions, etc.

View File

@@ -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:

View File

@@ -1,4 +1,4 @@
name: Release Drafter
name: Draft release
on:
workflow_dispatch:

View File

@@ -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:

View File

@@ -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 }}"

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -1,4 +1,4 @@
name: API Integration Tests
name: 🚉 Integration test Platform (API)
on:
workflow_dispatch:

46
.github/workflows/test-unit-cli.yml vendored Normal file
View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}"

View File

@@ -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:

View File

@@ -106,20 +106,6 @@ class MenuText:
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"

View File

@@ -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

View File

@@ -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

45
cli/tests/test_cli.py Normal file
View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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"]

View File

@@ -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
)

View File

@@ -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
]

View File

@@ -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

View File

@@ -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")

View File

@@ -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")

View File

@@ -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

44
cli/tests/test_session.py Normal file
View File

@@ -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

View File

@@ -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)