From b46bfadba7e8bf78c20742e76c0f8e1aa2c201a0 Mon Sep 17 00:00:00 2001 From: Danglewood <85772166+deeleeramone@users.noreply.github.com> Date: Fri, 7 Nov 2025 10:51:18 -0800 Subject: [PATCH] [Feature] `openbb-cookiecutter`: Extension Template (#7261) * add openbb-cookiecutter to repo * typo * review items * some fixes * missing annotation * duplicate ignore in pylintrc * protected access --------- Co-authored-by: Theodore Aptekarev Co-authored-by: deeleeramone <> --- .codespell.skip | 1 + .coveragerc | 1 + .pylintrc | 8 +- cookiecutter/MANIFEST.in | 5 + cookiecutter/README.md | 72 ++++++++ cookiecutter/cookiecutter.json | 8 + cookiecutter/openbb_cookiecutter/__init__.py | 10 ++ cookiecutter/openbb_cookiecutter/cli.py | 76 ++++++++ .../template/cookiecutter.json | 11 ++ .../template/hooks/post_gen_project.py | 31 ++++ .../template/hooks/pre_gen_project.py | 32 ++++ .../{{cookiecutter.project_tag}}/.coveragerc | 7 + .../{{cookiecutter.project_tag}}/.gitignore | 170 ++++++++++++++++++ .../.secrets.baseline | 148 +++++++++++++++ .../{{cookiecutter.project_tag}}/README.md | 29 +++ .../pyproject.toml | 34 ++++ .../{{cookiecutter.project_tag}}/pytest.ini | 7 + .../{{cookiecutter.project_tag}}/ruff.toml | 48 +++++ .../tests/.gitkeep | 0 .../tests/conftest.py | 42 +++++ .../obbject/__init__.py | 1 + .../{{cookiecutter.obbject_name}}/__init__.py | 83 +++++++++ .../providers/__init__.py | 1 + .../__init__.py | 22 +++ .../models/__init__.py | 0 .../models/example.py | 121 +++++++++++++ .../models/ohlc_example.py | 127 +++++++++++++ .../utils/__init__.py | 1 + .../utils/helpers.py | 1 + .../routers/__init__.py | 1 + .../routers/depends.py | 11 ++ .../routers/{{cookiecutter.router_name}}.py | 78 ++++++++ .../{{cookiecutter.router_name}}_views.py | 41 +++++ cookiecutter/pyproject.toml | 24 +++ .../core/openbb_core/app/command_runner.py | 143 +++++++++------ .../core/openbb_core/app/model/credentials.py | 1 + .../core/tests/app/test_command_runner.py | 18 +- .../openbb_charting/core/openbb_figure.py | 4 +- ruff.toml | 1 + 39 files changed, 1359 insertions(+), 60 deletions(-) create mode 100644 cookiecutter/MANIFEST.in create mode 100644 cookiecutter/README.md create mode 100644 cookiecutter/cookiecutter.json create mode 100644 cookiecutter/openbb_cookiecutter/__init__.py create mode 100644 cookiecutter/openbb_cookiecutter/cli.py create mode 100644 cookiecutter/openbb_cookiecutter/template/cookiecutter.json create mode 100644 cookiecutter/openbb_cookiecutter/template/hooks/post_gen_project.py create mode 100644 cookiecutter/openbb_cookiecutter/template/hooks/pre_gen_project.py create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/.coveragerc create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/.gitignore create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/.secrets.baseline create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/README.md create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/pyproject.toml create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/pytest.ini create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/ruff.toml create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/tests/.gitkeep create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/tests/conftest.py create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/obbject/__init__.py create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/obbject/{{cookiecutter.obbject_name}}/__init__.py create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/__init__.py create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/__init__.py create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/models/__init__.py create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/models/example.py create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/models/ohlc_example.py create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/utils/__init__.py create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/utils/helpers.py create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/routers/__init__.py create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/routers/depends.py create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/routers/{{cookiecutter.router_name}}.py create mode 100644 cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/routers/{{cookiecutter.router_name}}_views.py create mode 100644 cookiecutter/pyproject.toml diff --git a/.codespell.skip b/.codespell.skip index ecba223735f..aa33a1b0c55 100644 --- a/.codespell.skip +++ b/.codespell.skip @@ -17,6 +17,7 @@ ./build/pyinstaller ./**/node_modules ./frontend-components/** +./cookiecutter/** ./openbb_platform/providers/econdb/openbb_econdb/utils/helpers.py ./openbb_platform/core/openbb/package/** ./openbb_platform/providers/imf/openbb_imf/utils/constants.py diff --git a/.coveragerc b/.coveragerc index 081cc71dbc6..81d4cc842b4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -4,4 +4,5 @@ omit = tests/* **/tests/** **/package/** + cookiecutter/** source = . diff --git a/.pylintrc b/.pylintrc index f0e59929841..46027ecca0d 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,14 +1,12 @@ [MASTER] - +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS,cookiecutter # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code extension-pkg-allow-list=math,binascii -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=CVS - # Add files or directories matching the regex patterns to the blacklist. The # regex matches against base names, not paths. ignore-patterns= diff --git a/cookiecutter/MANIFEST.in b/cookiecutter/MANIFEST.in new file mode 100644 index 00000000000..d68bc22e4dd --- /dev/null +++ b/cookiecutter/MANIFEST.in @@ -0,0 +1,5 @@ +include README.md +include LICENSE +recursive-include openbb_cookiecutter/template * +recursive-exclude openbb_cookiecutter/template *.pyc +recursive-exclude openbb_cookiecutter/template __pycache__ diff --git a/cookiecutter/README.md b/cookiecutter/README.md new file mode 100644 index 00000000000..bb9cd3976d8 --- /dev/null +++ b/cookiecutter/README.md @@ -0,0 +1,72 @@ +# OpenBB ODP Extensions Cookiecutter + +[Cookiecutter](https://cookiecutter.readthedocs.io/en/1.7.2/) is a command-line utility that creates projects from templates. + +This extension is a simple template for setting up new OpenBB Python Package extensions and projects. + +## Template Structure + +The Cookiecutter template prompts the user for information to use in the `pyproject.toml` file, and then generates a project based on that information. +All fields are optional. + +- Your Name +- Your Email +- Project Name +- Project Tag (some-distributable-package) +- Package Name ("include" code folder name - "some_package") +- Provider Name - name of the provider for the entry point - i.e, 'fmp' +- Router Name - name of the router path - i.e. `obb.{some_package}` +- OBBject Name - name of the OBBject accessor namespace. + +The template will generate all extension types as a single, installable Python project. +You likely won't always use all in tandem, just delete the unwanted folders and entrypoints. + +## Usage + +1. Install in a Python environment from PyPI with: + +``` +pip install openbb-cookiecutter +``` + +Alternatively, with `uvx`: + +``` +uvx openbb-cookiecutter +``` + +2. Navigate the current working directory to the desired output location and run: + +``` +openbb-cookiecutter +``` + +Enter values or press `enter` to continue with the default. + +3. Create a new Python environment for the project. + +4. Navigate into the generated folder and install with: + +``` +pip install -e . +``` + +5. Python static files will be generated on first import, or trigger with `openbb-build`. + +6. Import the Python package or start the API and use like any other OpenBB application. + +7. Modify the business logic and get started building! + +See the developer documentation [here](https://docs.openbb.co/python/developer). + +## Contributing + +We welcome contributions to this template! Please feel free to open an issue or submit a pull request with your improvements. + +## Contacts + +If you have any questions about the cookiecutter or anything OpenBB, feel free to email us at `support@openbb.co` + +If you want to say hi, or are interested in partnering with us, feel free to reach us at `hello@openbb.co` + +Any of our social media platforms: [openbb.co/links](https://openbb.co/links) diff --git a/cookiecutter/cookiecutter.json b/cookiecutter/cookiecutter.json new file mode 100644 index 00000000000..c6afe1a12d8 --- /dev/null +++ b/cookiecutter/cookiecutter.json @@ -0,0 +1,8 @@ +{ + "full_name": "Super Quant", + "email": "super@duper.quant", + "project_name": "Super Quant", + "project_tag": "{{ cookiecutter.project_name.lower().replace(' ', '-') }}", + "package_name": "{{ cookiecutter.project_name.lower().replace(' ', '_') }}", + "_template": "{% now 'utc', '%Y%m%d%H%M%S' %}" +} diff --git a/cookiecutter/openbb_cookiecutter/__init__.py b/cookiecutter/openbb_cookiecutter/__init__.py new file mode 100644 index 00000000000..926359aa858 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/__init__.py @@ -0,0 +1,10 @@ +"""OpenBB Cookiecutter Template.""" + +from pathlib import Path + +__version__ = "0.4.0" + + +def get_template_path() -> Path: + """Return the path to the cookiecutter template directory.""" + return Path(__file__).parent / "template" diff --git a/cookiecutter/openbb_cookiecutter/cli.py b/cookiecutter/openbb_cookiecutter/cli.py new file mode 100644 index 00000000000..744c4bb8622 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/cli.py @@ -0,0 +1,76 @@ +"""CLI for OpenBB Cookiecutter template.""" + +# pylint: disable=W0718 + +import argparse +import sys + +from cookiecutter.main import cookiecutter + +from . import get_template_path + + +def main(argv: list | None = None) -> int: + """Run the OpenBB cookiecutter template. + + Args: + argv: Command line arguments (defaults to sys.argv[1:]) + + Returns: + Exit code (0 for success, non-zero for error) + """ + parser = argparse.ArgumentParser( + description="Generate an OpenBB Platform extension from template" + ) + parser.add_argument( + "-o", + "--output-dir", + default=".", + help="Where to output the generated project (default: current directory)", + ) + parser.add_argument( + "--no-input", + action="store_true", + help="Do not prompt for parameters and use defaults", + ) + parser.add_argument( + "-f", "--overwrite-if-exists", action="store_true", help="Overwrite if exists" + ) + parser.add_argument( + "--extra-context", + action="append", + metavar="KEY=VALUE", + help="Extra context variables (can be used multiple times)", + ) + + args = parser.parse_args(argv) + + # Build extra context from arguments + extra_context = {} + if args.extra_context: + for item in args.extra_context: + if "=" not in item: + print(f"Error: extra-context must be in KEY=VALUE format: {item}") + return 1 + key, value = item.split("=", 1) + extra_context[key] = value + + # Get the bundled template path + template_path = get_template_path() + + try: + cookiecutter( + str(template_path), + output_dir=args.output_dir, + no_input=args.no_input, + overwrite_if_exists=args.overwrite_if_exists, + extra_context=extra_context if extra_context else None, + ) + return 0 + except Exception as e: + print(f"Error: {e}", file=sys.stderr) # noqa + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/cookiecutter/openbb_cookiecutter/template/cookiecutter.json b/cookiecutter/openbb_cookiecutter/template/cookiecutter.json new file mode 100644 index 00000000000..42db80735b4 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/cookiecutter.json @@ -0,0 +1,11 @@ +{ + "full_name": "Hello World", + "email": "hello@world.com", + "project_name": "OpenBB Python Extension Template", + "project_tag": "extension-template", + "package_name": "extension_template", + "provider_name": "template", + "router_name": "template", + "obbject_name": "template", + "_template": "{% now 'utc', '%Y%m%d%H%M%S' %}" +} \ No newline at end of file diff --git a/cookiecutter/openbb_cookiecutter/template/hooks/post_gen_project.py b/cookiecutter/openbb_cookiecutter/template/hooks/post_gen_project.py new file mode 100644 index 00000000000..e51b0fa082c --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/hooks/post_gen_project.py @@ -0,0 +1,31 @@ +"""OpenBB Platform Extension post-generation script.""" + +import re +import sys + +MODULE_REGEX = r"^[_a-zA-Z][_a-zA-Z0-9]+$" + +MODULE_NAME = "{{ cookiecutter.package_name }}" +PROVIDER_NAME = "{{ cookiecutter.provider_name }}" or "" +ROUTER_NAME = "{{ cookiecutter.router_name }}" or "" +OBBJECT_NAME = "{{ cookiecutter.obbject_name }}" or "" + +if not re.match(MODULE_REGEX, MODULE_NAME): + print(f"ERROR: {MODULE_NAME} is not a valid Python package name.") + + sys.exit(1) + +if PROVIDER_NAME and not re.match(MODULE_REGEX, PROVIDER_NAME): + print(f"ERROR: {PROVIDER_NAME} should be in lower snakecase.") + + sys.exit(1) + +if ROUTER_NAME and not re.match(MODULE_REGEX, ROUTER_NAME): + print(f"ERROR: {ROUTER_NAME} should be in lower snakecase.") + + sys.exit(1) + +if OBBJECT_NAME and not re.match(MODULE_REGEX, OBBJECT_NAME): + print(f"ERROR: {OBBJECT_NAME} should be in lower snakecase.") + + sys.exit(1) diff --git a/cookiecutter/openbb_cookiecutter/template/hooks/pre_gen_project.py b/cookiecutter/openbb_cookiecutter/template/hooks/pre_gen_project.py new file mode 100644 index 00000000000..6af9c9328a1 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/hooks/pre_gen_project.py @@ -0,0 +1,32 @@ +"""OpenBB Platform Extension pre-generation script.""" + +BANNER = """ + + + One of us ❤️ + + ~~~~~~~~~~~~~~~ + + + ___ ____ ____ + / _ \\ _ __ ___ _ __ | __ )| __ ) + | | | | '_ \\ / _ \\ '_ \\| _ \\| _ + | |_| | |_) | __/ | | | |_) | |_) | + \\___/| .__/ \\___|_| |_|____/|____/ + |_| + @@@ + @@@ + @@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@ + @@@ @@@ @@@ @@@ + @@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@ + @@@ @@@ + %%%%%%%%%%%%%%%%%@@@ @@@%%%%%%%%%%%%%%%%% + @@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@ + @@@ @@@ @@@ @@@ + @@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@ + + Investment research for everyone, anywhere. +""" + + +print(BANNER) diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/.coveragerc b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/.coveragerc new file mode 100644 index 00000000000..081cc71dbc6 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/.coveragerc @@ -0,0 +1,7 @@ +[coverage:run] +omit = + env/* + tests/* + **/tests/** + **/package/** +source = . diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/.gitignore b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/.gitignore new file mode 100644 index 00000000000..eee127b9000 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/.gitignore @@ -0,0 +1,170 @@ + +# OpenBB Platform extension specifig paths to ignore +{{cookiecutter.package_name}}/package/ + +# Bellow this line is a default .gitignore for Python projects. + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# VS Code project settings +.vscode/ +.DS_Store diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/.secrets.baseline b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/.secrets.baseline new file mode 100644 index 00000000000..78b9bf98553 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/.secrets.baseline @@ -0,0 +1,148 @@ +{ + "version": "1.4.0", + "plugins_used": [ + { + "name": "ArtifactoryDetector" + }, + { + "name": "AWSKeyDetector" + }, + { + "name": "AzureStorageKeyDetector" + }, + { + "name": "Base64HighEntropyString", + "limit": 4.5 + }, + { + "name": "BasicAuthDetector" + }, + { + "name": "CloudantDetector" + }, + { + "name": "DiscordBotTokenDetector" + }, + { + "name": "GitHubTokenDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3.0 + }, + { + "name": "IbmCloudIamDetector" + }, + { + "name": "IbmCosHmacDetector" + }, + { + "name": "JwtTokenDetector" + }, + { + "name": "KeywordDetector", + "keyword_exclude": "example" + }, + { + "name": "MailchimpDetector" + }, + { + "name": "NpmDetector" + }, + { + "name": "PrivateKeyDetector" + }, + { + "name": "SendGridDetector" + }, + { + "name": "SlackDetector" + }, + { + "name": "SoftlayerDetector" + }, + { + "name": "SquareOAuthDetector" + }, + { + "name": "StripeDetector" + }, + { + "name": "TwilioKeyDetector" + } + ], + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + }, + { + "path": "detect_secrets.filters.regex.should_exclude_file", + "pattern": [ + "test*.py" + ] + }, + { + "path": "detect_secrets.filters.regex.should_exclude_line", + "pattern": [ + ".*example.*", + ".*_api_key.*" + ] + }, + { + "path": "detect_secrets.filters.regex.should_exclude_secret", + "pattern": [ + "example", + "REPLACE_ME", + "PASSWORD", + "PASS", + "my_email", + "my_password", + "my_pat", + "fmp", + "other_key", + "polygon", + "fred", + "benzinga", + "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6ImRiMjEyZDdhZj", + "c2MWI0ZTNlOGNjZGM3OWQ5Zjk4YWM5In0.eyJhY2Nlc3NfdG9rZW4iOiJ0", + "b2tlbiIsInRva2VuX3R5cGUiOiJCZWFyZXIiLCJ1dWlkIjoidXVpZCIsInV", + "zZXJuYW1lIjoidXNlcm5hbWUiLCJlbWFpbCI6ImVtYWlsIiwicHJpbWFyeV9", + "1c2FnZSI6InByaW1hcnlfdXNhZ2UifQ.FAtE8-a1a-313Zoa6dREIxGZOHaW9", + "-JLZnFzyJ6dlHBZnkjQT2tfaaefxnTdAlSmToQwxGykvuatmI7L0wztPQ" + ] + } + ], + "results": {}, + "generated_at": "2023-12-14T18:07:51Z" +} diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/README.md b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/README.md new file mode 100644 index 00000000000..ee344db435d --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/README.md @@ -0,0 +1,29 @@ +# OpenBB ODP Extensions Cookiecutter Template + +## Introduction + +This is the generated cookiecutter template for the OpenBB Python Package. +It is used to help you create a new extension that can be integrated into the existing structure + +With it you can: + +- Create a new extension +- Build custom commands +- Interact with the standardization framework +- Build custom services and applications on top of the framework + +## Getting Started + +We recommend you check out the files in the following order: + +* `{{cookiecutter.package_name}}/routers/{{cookiecutter.router_name}}.py` +* `{{cookiecutter.package_name}}/prvoviders/{{cookiecutter.provider_name}}/models/example.py` +* `{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/__init__.py` +* `{{cookiecutter.package_name}}/obbject/{{cookiecutter.obbject_name}}/__init__.py` +* `{{cookiecutter.package_name}}/routers/{{cookiecutter.router_name}}_views.py` + +Check out the developer [documentation](https://docs.openbb.co/python/developer) for more information on getting started making OpenBB extensions. + +--- + +🦋 Made with [openbb cookiecutter](https://github.com/openbb-finance/OpenBB/cookiecutter). diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/pyproject.toml b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/pyproject.toml new file mode 100644 index 00000000000..8298527ffbe --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/pyproject.toml @@ -0,0 +1,34 @@ +[tool.poetry] +name = "{{ cookiecutter.project_tag }}" +version = "0.0.1" +description = "{{ cookiecutter.project_name }}" +authors = ["{{ cookiecutter.full_name }} <{{ cookiecutter.email }}>"] +readme = "README.md" +license = "AGPL-3.0-only" +packages = [{ include = "{{ cookiecutter.package_name }}" }] + +[tool.poetry.dependencies] +python = ">=3.10,<3.14" +openbb-core = "*" +openbb-platform-api = "*" + +[tool.poetry.group.dev.dependencies] +openbb-devtools = { version = "*" } + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.plugins."openbb_core_extension"] +{{ cookiecutter.router_name }} = "{{ cookiecutter.package_name }}.routers.{{ cookiecutter.router_name }}:router" + +[tool.poetry.plugins."openbb_charting_extension"] +{{ cookiecutter.router_name }} = "{{ cookiecutter.package_name }}.routers.{{ cookiecutter.router_name }}_views:{{cookiecutter.router_name.replace('_', ' ').title().replace(' ', '').replace('"', '')}}Views" + +[tool.poetry.plugins."openbb_provider_extension"] +{{ cookiecutter.provider_name }} = "{{ cookiecutter.package_name }}.providers.{{ cookiecutter.provider_name }}:{{ cookiecutter.provider_name }}_provider" + +[tool.poetry.plugins."openbb_obbject_extension"] +to_string = "{{ cookiecutter.package_name }}.obbject.{{ cookiecutter.obbject_name }}:ext" +{{ cookiecutter.obbject_name }} = "{{ cookiecutter.package_name }}.obbject.{{ cookiecutter.obbject_name }}:class_ext" +nonblocking_plugin = "{{ cookiecutter.package_name }}.obbject.{{ cookiecutter.obbject_name }}:nonblocking_plugin" diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/pytest.ini b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/pytest.ini new file mode 100644 index 00000000000..58b66ac2548 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +addopts = -p no:warnings +markers = + linux: tests that are not stable on Windows + integration: OpenBB Platform integration test marker +testpaths = + tests diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/ruff.toml b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/ruff.toml new file mode 100644 index 00000000000..e3f21f6e499 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/ruff.toml @@ -0,0 +1,48 @@ +line-length = 122 +target-version = "py310" +fix = true + +[lint] +select = [ + "E", + "W", + "F", + "Q", + "S", + "UP", + "I", + "PLC", + "PLE", + "PLR", + "PLW", + "SIM", + "T20", +] +# These ignores should be seen as temporary solutions to problems that will NEED fixed +ignore = ["PLR2004", "PLR0913", "PLR0915", "PLC0415", "E402"] + +[lint.per-file-ignores] +"**/tests/*" = ["S101"] +"*init*.py" = ["F401"] +"website/*" = ["T201", "PLR0915"] +"*integration/*" = ["S101"] + +[lint.isort] +combine-as-imports = true +force-wrap-aliases = true + +[lint.pylint] +max-args = 8 +max-branches = 26 +max-returns = 9 +max-statements = 30 + +[lint.pydocstyle] +convention = "numpy" + +[lint.flake8-import-conventions.aliases] +"matplotlib.pyplot" = "plt" +numpy = "np" +pandas = "pd" +seaborn = "sns" +openbb = "obb" diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/tests/.gitkeep b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/tests/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/tests/conftest.py b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/tests/conftest.py new file mode 100644 index 00000000000..6f9eb5ac026 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/tests/conftest.py @@ -0,0 +1,42 @@ +"""Root configuration for pytest.""" + +# flake8: noqa: S101 +# pylint: disable=unused-argument,unused-import + +import os +from pathlib import Path + +import pytest # noqa: F401 + +ROOT_DIR = Path(__file__).parent + + +def pytest_configure(): + """Set environment variables for testing.""" + os.environ["OPENBB_AUTO_BUILD"] = "true" + + +def pytest_collection_modifyitems(config, items): + """Modify test collection to ensure cleanup-dependent tests run first.""" + # Find tests that should run early (checking clean state) + early_tests: list = [] + other_tests: list = [] + + for item in items: + # Tests that check repository state should run first + if item.get_closest_marker("order"): + early_tests.append(item) + else: + other_tests.append(item) + + # Sort early tests by their order marker if present + early_tests.sort( + key=lambda x: ( + getattr(x.get_closest_marker("order"), "args", [999])[0] + if x.get_closest_marker("order") + else 999 + ) + ) + + # Reorder: early tests first, then others + items[:] = early_tests + other_tests diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/obbject/__init__.py b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/obbject/__init__.py new file mode 100644 index 00000000000..580db5b5da7 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/obbject/__init__.py @@ -0,0 +1 @@ +"""OBBject Extensions module.""" diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/obbject/{{cookiecutter.obbject_name}}/__init__.py b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/obbject/{{cookiecutter.obbject_name}}/__init__.py new file mode 100644 index 00000000000..603a7a23384 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/obbject/{{cookiecutter.obbject_name}}/__init__.py @@ -0,0 +1,83 @@ +"""{{ cookiecutter.package_name }} OBBject Extension - {{ cookiecutter.obbject_name }}""" + +# pylint: disable=W0613,R0903 + +import threading +import time + +from openbb_core.app.model.extension import Extension +from openbb_core.app.model.obbject import OBBject + +# Extensions are registered as OBBject accessors. +# It can be a class, or it can be a callable method. +ext = Extension( + name="to_string", + description="An OBBject extension that converts the results to a string representation.", +) + +# If it is a function, no parameters will be accepted. +# The function will execute like a property method. +# The accessor is called when the namespace is entered. +@ext.obbject_accessor +def to_string(obbject, **kwargs) -> str: + """OBBject accessor providing a "to_string" method.""" + return obbject.model_dump_json(exclude_none=True, exclude_unset=True, include="results") + +# We ignore this OpenBBWarning: Skipping '{{ cookiecutter.obbject_name }}', name already in user. + +class_ext = Extension( + name="{{ cookiecutter.obbject_name }}", + description="An OBBject extension with namespace." +) + +@class_ext.obbject_accessor +class OBBjectExtension: + """OBBject Extension Template.""" + + def __init__(self, obbject: OBBject): + """Initialize the extension.""" + self._obbject = obbject + + def hello_world(self, **kwargs): + """Say hello from the OBBject extension.""" + print(f"Hello from the OBBject instance! \n\n{repr(self._obbject)}") # noqa + + +nonblocking_plugin = Extension( + name="nonblocking_plugin", + description="An on-command-output plugin simulating an extensive task performed in a separate thread.", + on_command_output=True, # Must be set as True + command_output_paths=["/{{cookiecutter.router_name}}/candles"], + immutable=True, # Set to `True` for parallel processing. + results_only=False, # Use this as a flag to return only the "results" portion of the OBBject. +) + + +def _expensive_operation_worker(serialized_obbject: dict): + """Simulate a long-running task without blocking the caller.""" + working_copy = OBBject(**serialized_obbject) + print("\nThis is the deserialized OBBject in the non-blocking thread.") + print(working_copy.__repr__()) + for i in range(10): + print(str(i) + " seconds remaining...") + time.sleep(1) + print("Expensive operation is now complete.") + + +@nonblocking_plugin.obbject_accessor +def empty_plugin_function(obbject): # This can also be an async function. + """Simulated on_commnd_output function that executes an expensive task + in a non-blocking thread.""" + print( + "Serializing the obbject and passing to a new thread.\n" + f"Command executed: {obbject.extra['metadata']}\n" + ) + print( + "Simulating an expensive task that is non-blocking and allows the function to return." + ) + threading.Thread( + target=_expensive_operation_worker, + args=(obbject.model_dump(),), + name="empty-plugin-expensive-operation", + daemon=False, + ).start() diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/__init__.py b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/__init__.py new file mode 100644 index 00000000000..3ceb62905c8 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/__init__.py @@ -0,0 +1 @@ +"""Providers Module""" diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/__init__.py b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/__init__.py new file mode 100644 index 00000000000..6b924bfa713 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/__init__.py @@ -0,0 +1,22 @@ +"""{{ cookiecutter.package_name }} OpenBB Platform Provider.""" + +from openbb_core.provider.abstract.provider import Provider +from {{cookiecutter.package_name}}.providers.{{cookiecutter.provider_name}}.models.example import ExampleFetcher +from {{cookiecutter.package_name}}.providers.{{cookiecutter.provider_name}}.models.ohlc_example import {{cookiecutter.provider_name.replace('_', ' ').title().replace(' ', '')}}EquityHistoricalFetcher + + + +{{cookiecutter.provider_name}}_provider = Provider( + name="{{cookiecutter.provider_name}}", + description="Data provider for {{cookiecutter.project_name}}.", + # Only add 'credentials' if they are needed. + # For multiple login details, list them all here. + # credentials=["api_key"], + website="https://{{cookiecutter.project_tag}}.com", + # Here, we list out the fetchers showing what our provider can get. + # The dictionary key is the fetcher's name, used in the `../routers/router.py`. + fetcher_dict={ + "EquityHistorical": {{cookiecutter.provider_name.replace('_', ' ').title().replace(' ', '')}}EquityHistoricalFetcher, + "Example": ExampleFetcher, + } +) diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/models/__init__.py b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/models/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/models/example.py b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/models/example.py new file mode 100644 index 00000000000..937c46e375c --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/models/example.py @@ -0,0 +1,121 @@ +"""Example Data Integration. + +The OpenBB Platform gives developers easy tools for integration. + +To use it, developers should: +1. Define the request/query parameters. +2. Define the resulting data schema. +3. Define how to fetch raw data. + +First 2 steps make sure developers really get to know their data. +This is called the "Know Your Data" principle. + +Note: The format of the QueryParams and Data is defined by a pydantic model that can +be entirely custom, or inherit from the OpenBB standardized models. + +This file shows an example of how to integrate data from a provider. +""" +# pylint: disable=unused-argument +from typing import Any, Optional + +from openbb_core.provider.abstract.data import Data +from openbb_core.provider.abstract.fetcher import Fetcher +from openbb_core.provider.abstract.query_params import QueryParams +from pydantic import Field + + +class ExampleQueryParams(QueryParams): + """Example provider query. + + This is the definition of our query parameters that are specific to this provider. + We use this class to create our own parameters that will provided as input to the + command. + """ + + symbol: str = Field(description="Symbol to query.") + + +class ExampleData(Data): + """Sample provider data. + + The fields are displayed as-is in the output of the command. In this case, its the + Open, High, Low, Close and Volume data. + """ + + o: float = Field(description="Open price.") + h: float = Field(description="High price.") + l: float = Field(description="Low price.") + c: float = Field(description="Close price.") + v: float = Field(description="Volume.") + d: str = Field(description="Date") + + +class ExampleFetcher( + Fetcher[ + ExampleQueryParams, + list[ExampleData], + ] +): + """Example Fetcher class. + + This class is responsible for the actual data retrieval. + """ + + @staticmethod + def transform_query(params: dict[str, Any]) -> ExampleQueryParams: + """Define example transform_query. + + Here we can pre-process the query parameters and add any extra parameters that + will be used inside the extract_data method. + """ + return ExampleQueryParams(**params) + + @staticmethod + def extract_data( + query: ExampleQueryParams, + credentials: dict[str, str] | None, + **kwargs: Any, + ) -> list[dict]: + """Define example extract_data. + + Here we make the actual request to the data provider and receive the raw data. + If you said your Provider class needs credentials you can get them here. + """ + api_key = ( + credentials.get("{{cookiecutter.package_name}}_api_key") + if credentials + else "" + ) + + # Here we mock an example_response for brevity. + example_response = [ + { + "o": 2, + "h": 5, + "l": 1, + "c": 4, + "v": 5, + "d": "August 23, 2023", + }, + { + "o": 4, + "h": 7, + "l": 3, + "c": 6, + "v": 10, + "d": "August 24, 2023", + }, + ] + + return example_response + + @staticmethod + def transform_data( + query: ExampleQueryParams, data: list[dict], **kwargs: Any + ) -> list[ExampleData]: + """Define example transform_data. + + Right now, we're converting the data to fit our desired format. + You can apply other transformations to it here. + """ + return [ExampleData(**d) for d in data] diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/models/ohlc_example.py b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/models/ohlc_example.py new file mode 100644 index 00000000000..c60bea76cd4 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/models/ohlc_example.py @@ -0,0 +1,127 @@ +"""Example Data Integration With Standard Model. + +This file shows an example of how to integrate this provider with ends available to other providers. +""" + +# pylint: disable=unused-argument +from typing import Any + +from openbb_core.provider.abstract.fetcher import Fetcher +from openbb_core.provider.standard_models.equity_historical import ( + EquityHistoricalData, + EquityHistoricalQueryParams, +) +from pydantic import Field, field_validator + + +class {{cookiecutter.provider_name.replace('_', ' ').capitalize().replace(' ', '')}}EquityHistoricalQueryParams(EquityHistoricalQueryParams): + """Example provider query. + + The standard model here comes with parameters for symbol, start_date, and end_date. + """ + + custom_param: str | None = Field( + default=None, description="Some optional parameter" + ) + + +class {{cookiecutter.provider_name.replace('_', ' ').capitalize().replace(' ', '')}}EquityHistoricalData(EquityHistoricalData): + """Sample provider data. + + The standard model has these fields, + so we use __alias_dict__ to map them. + We only need to add fields not in the inherited model, or to override. + """ + + __alias_dict__ = { + "date": "d", + "open": "o", + "high": "h", + "low": "l", + "close": "c", + "volume": "v", + "custom_field": "f", + } + custom_field: str | None = Field(default=None, description="Some optional field") + + @field_validator("custom_field", mode="before", check_fields=False) + @classmethod + def _validate_custom_field(cls, v): + """Validate the custom field.""" + return v if v else "Data validator replaced None." + + +class {{cookiecutter.provider_name.replace('_', ' ').capitalize().replace(' ', '')}}EquityHistoricalFetcher( + Fetcher[ + {{cookiecutter.provider_name.replace('_', ' ').capitalize().replace(' ', '')}}EquityHistoricalQueryParams, + list[{{cookiecutter.provider_name.replace('_', ' ').capitalize().replace(' ', '')}}EquityHistoricalData], + ] +): + """Example Fetcher class. + + This class is responsible for the actual data retrieval. + """ + + @staticmethod + def transform_query(params: dict[str, Any]) -> {{cookiecutter.provider_name.replace('_', ' ').capitalize().replace(' ', '')}}EquityHistoricalQueryParams: + """Define example transform_query. + + Here we can pre-process the query parameters and add any extra parameters that + will be used inside the extract_data method. + """ + return {{cookiecutter.provider_name.replace('_', ' ').capitalize().replace(' ', '')}}EquityHistoricalQueryParams(**params) + + # Note the use of async here. Make the Fetcher async with this small change. + @staticmethod + async def aextract_data( + query: {{cookiecutter.provider_name.replace('_', ' ').capitalize().replace(' ', '')}}EquityHistoricalQueryParams, + credentials: dict[str, str] | None, + **kwargs: Any, + ) -> list[dict]: + """Define example extract_data. + + Here we make the actual request to the data provider and receive the raw data. + If you said your Provider class needs credentials you can get them here. + """ + api_key = ( + credentials.get("{{ cookiecutter.provider_name }}_api_key") if credentials else "" + ) + + # Here we mock an example_response for brevity. + # Show model validation by only returning one row of custom_field + # Show model validation by only returning one row of custom_field + example_response = [ + { + "o": 2, + "h": 5, + "l": 1, + "c": 4, + "v": 5, + "d": "August 23, 2023", + "f": query.custom_param, + }, + { + "o": 4, + "h": 7, + "l": 3, + "c": 6, + "v": 10, + "d": "August 24, 2023", + "f": None, + }, + ] + + return example_response + + @staticmethod + def transform_data( + query: {{cookiecutter.provider_name.replace('_', ' ').capitalize().replace(' ', '')}}EquityHistoricalQueryParams, + data: list[dict], + **kwargs: Any + ) -> list[{{cookiecutter.provider_name.replace('_', ' ').capitalize().replace(' ', '')}}EquityHistoricalData]: + """Define example transform_data. + + Right now, we're converting the data to fit our desired format. + You can apply other transformations to it here. + """ + return [{{cookiecutter.provider_name.replace('_', ' ').capitalize().replace(' ', '')}}EquityHistoricalData.model_validate(d) for d in data] diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/utils/__init__.py b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/utils/__init__.py new file mode 100644 index 00000000000..149f2c27681 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/utils/__init__.py @@ -0,0 +1 @@ +"""{{ cookiecutter.provider_name}} utilities module.""" diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/utils/helpers.py b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/utils/helpers.py new file mode 100644 index 00000000000..0b3b7e7a726 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/utils/helpers.py @@ -0,0 +1 @@ +"""{{ cookiecutter.provider_name}} helper functions.""" diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/routers/__init__.py b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/routers/__init__.py new file mode 100644 index 00000000000..fbf279fcd61 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/routers/__init__.py @@ -0,0 +1 @@ +"""{{ cookiecutter.project_name}} routers module.""" diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/routers/depends.py b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/routers/depends.py new file mode 100644 index 00000000000..8dcd9afedf9 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/routers/depends.py @@ -0,0 +1,11 @@ +"""Router dependency injections.""" + +# pylint: disable=R0903 + +from typing import Annotated + +import requests +from fastapi import Depends +from openbb_core.provider.utils.helpers import get_requests_session + +Session = Annotated[requests.Session, Depends(get_requests_session)] diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/routers/{{cookiecutter.router_name}}.py b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/routers/{{cookiecutter.router_name}}.py new file mode 100644 index 00000000000..6857db26820 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/routers/{{cookiecutter.router_name}}.py @@ -0,0 +1,78 @@ +"""{{cookiecutter.router_name}} router command example.""" + +# pylint: disable=unused-argument + +from openbb_core.app.model.command_context import CommandContext +from openbb_core.app.model.example import APIEx, PythonEx +from openbb_core.app.model.obbject import OBBject +from openbb_core.app.provider_interface import ExtraParams, ProviderChoices, StandardParams +from openbb_core.app.query import Query +from openbb_core.app.router import Router +from pydantic import BaseModel + +# Example dependency injection yielding a configured requests.Session object. +from {{cookiecutter.package_name}}.routers.depends import Session + +# The prefix extension's prefix is determined by the `pyproject.toml` EntryPoint assignment. +# Assign a prefix only if this is a sub-router. +router = Router(prefix="") + + +@router.command( + methods=["GET"], + examples=[ + PythonEx( + description="Here is an example for using this endpoint.", + code=[ + "obb.{{ cookiecutter.router_name }}.get_example(symbol='AAPL')", + ] + ) + ] +) +async def get_example(session: Session, symbol: str = "AAPL") -> OBBject[dict]: + """Get options data.""" + url = f"https://www.cboe.com/education/tools/trade-optimizer/symbol-info?symbol={symbol}" + response = session.get(url) + response.raise_for_status() + data = response.json() + + return OBBject(results=data["details"]) + + +@router.command(methods=["POST"]) +async def post_example( + data: BaseModel, # These are body parameters. + flag: bool = False, # These are query parameters. +) -> OBBject[dict]: + """Calculate mid and spread.""" + + bid = getattr(data, "bid_col", 0) + ask = getattr(data, "ask_col", 0) + mid = (bid + ask) / 2 + spread = ask - bid + + return OBBject(results={"mid": mid, "spread": spread, "flag": flag}) + + +@router.command(model="Example") +async def model_example( + cc: CommandContext, + provider_choices: ProviderChoices, + standard_params: StandardParams, + extra_params: ExtraParams, +) -> OBBject[BaseModel]: + """Example Data.""" + return await OBBject.from_query(Query(**locals())) + + +# If you had another provider installed that mapped to this model - i.e, `openbb-fmp` +# they will be added to this endpoint. +@router.command(model="EquityHistorical") +async def candles( + cc: CommandContext, + provider_choices: ProviderChoices, + standard_params: StandardParams, + extra_params: ExtraParams, +) -> OBBject: # results type is inferred from Fetcher annotations. + """Example Data.""" + return await OBBject.from_query(Query(**locals())) diff --git a/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/routers/{{cookiecutter.router_name}}_views.py b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/routers/{{cookiecutter.router_name}}_views.py new file mode 100644 index 00000000000..25cc545e5a4 --- /dev/null +++ b/cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/{{cookiecutter.package_name}}/routers/{{cookiecutter.router_name}}_views.py @@ -0,0 +1,41 @@ +"""Views for the {{ cookiecutter.router_name }} Extension.""" + +# flake8: noqa: PLR0912 +# pylint: disable=import-outside-toplevel,too-few-public-methods + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from openbb_charting.core.openbb_figure import OpenBBFigure # `openbb-charting` is Optional for the user. + + +# You can make charts that are returned when you map a function +# route in lower_snake_case to this class using static methods. +# You can return whatever format you like, but it must be JSON-serializable. +# The returned tuple object gets added to the response object from the application. +# The charting extension itself is accessible under the `charting` namespace. +# While the finished chart is under the `chart` object of the OBBject response output. +# The application will check if the user has `openbb-charting` installed on run. +# If not, the views are not added to the application. + + +class {{cookiecutter.router_name.replace("_", " ").title().replace(" ", "")}}Views: + """{{ cookiecutter.router_name }} Views.""" + + @staticmethod + def {{cookiecutter.router_name}}_candles(**kwargs) -> tuple["OpenBBFigure", dict[str, Any]]: + """Create a chart that will return to the API as a JSON-encoded string.""" + # Keep imports here so they are imported only at function run. + from openbb_charting.core.openbb_figure import OpenBBFigure + + data = kwargs["obbject_item"] # This is where the data will always be. + + print(data) + + fig = OpenBBFigure() + + fig.add_bar(x=[d.date for d in data], y=[d.high for d in data]) + content = fig.show(external=True).to_plotly_json() + # fig should be the binary Python object of the chart + # content should be a JSON-serialized version ready for the frontend to render. + return fig, content diff --git a/cookiecutter/pyproject.toml b/cookiecutter/pyproject.toml new file mode 100644 index 00000000000..ec355ad8aa3 --- /dev/null +++ b/cookiecutter/pyproject.toml @@ -0,0 +1,24 @@ +[tool.poetry] +name = "openbb-cookiecutter" +version = "0.4.0" +description = "Extensions template for the OpenBB Python Package." +license = "AGPL-3.0-only" +authors = ["OpenBB Team "] +packages = [{ include = "openbb_cookiecutter" }] +readme = "README.md" +homepage = "https://openbb.co" +repository = "https://github.com/OpenBB-finance/openbb-cookiecutter" + +[tool.poetry.dependencies] +python = ">=3.10,<3.14" +cookiecutter = "^2.6.0" + +[tool.poetry.scripts] +openbb-cookiecutter = "openbb_cookiecutter.cli:main" + +[tool.poetry.plugins."cookiecutter.templates"] +openbb = "openbb_cookiecutter" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/openbb_platform/core/openbb_core/app/command_runner.py b/openbb_platform/core/openbb_core/app/command_runner.py index 2c3c2ad487b..7bd70fe4657 100644 --- a/openbb_platform/core/openbb_core/app/command_runner.py +++ b/openbb_platform/core/openbb_core/app/command_runner.py @@ -1,7 +1,6 @@ """Command runner module.""" # pylint: disable=R0903 - from collections.abc import Callable from copy import deepcopy from dataclasses import asdict, is_dataclass @@ -15,6 +14,7 @@ from warnings import catch_warnings, showwarning, warn from openbb_core.app.extension_loader import ExtensionLoader from openbb_core.app.model.abstract.error import OpenBBError from openbb_core.app.model.abstract.warning import OpenBBWarning, cast_warning +from openbb_core.app.model.extension import CachedAccessor from openbb_core.app.model.metadata import Metadata from openbb_core.app.model.obbject import OBBject from openbb_core.app.provider_interface import ExtraParams @@ -448,13 +448,13 @@ class StaticCommandRunner: raise OpenBBError(e) from e warn(str(e), OpenBBWarning) - try: - cls._trigger_command_output_callbacks(route, obbject) - - except Exception as e: - if Env().DEBUG_MODE: - raise OpenBBError(e) from e - warn(str(e), OpenBBWarning) + if isinstance(obbject, OBBject): + try: + cls._trigger_command_output_callbacks(route, obbject) + except Exception as e: + if Env().DEBUG_MODE: + raise OpenBBError(e) from e + warn(str(e), OpenBBWarning) return obbject @@ -463,7 +463,8 @@ class StaticCommandRunner: """Trigger command output callbacks for extensions.""" loader = ExtensionLoader() callbacks = loader.on_command_output_callbacks - results_only = False + if not callbacks: + return # For each extension registered for all routes or the specific route, # we call its accessor on the OBBject. @@ -473,53 +474,93 @@ class StaticCommandRunner: # mutates the OBBject so we can pass this information to the interface. # We also set the _results_only attribute to True if any extension # indicates that only results should be returned. - if "*" in callbacks: - for ext in callbacks["*"]: - if ext.results_only is True: - results_only = True - if ext.immutable is True: - if hasattr(obbject, ext.name): - obbject_copy = deepcopy(obbject) - accessor = getattr(obbject_copy, ext.name) - if iscoroutinefunction(accessor): - run_async(accessor) - elif callable(accessor): - accessor() - elif ext.immutable is False: - if ext.results_only is True: - results_only = True - if hasattr(obbject, ext.name): - accessor = getattr(obbject, ext.name) - if iscoroutinefunction(accessor): - run_async(accessor) - elif callable(accessor): - accessor() - setattr(obbject, "_extension_modified", True) + results_only = False + executed_keys: set[str] = set() + ordered_extensions: list = [] + all_on_command_output_exts: list = [] - if route in callbacks: - for ext in callbacks[route]: + def _extension_key(ext) -> str: + if key := getattr(ext, "identifier", None): + return str(key) + if path := getattr(ext, "import_path", None): + return f"{path}:{getattr(ext, 'name', id(ext))}" + return str(getattr(ext, "name", id(ext))) + + def _clone_for_immutable(source: OBBject) -> OBBject | None: + try: + new_source = source.model_copy() + new_source = OBBject.model_validate(source.model_dump()) + return source.model_validate(new_source) + except Exception as e: + warn( + "Skipped immutable callback because the OBBject " + f"could not be duplicated. {e}", + OpenBBWarning, + ) + return None + + for ext_list in callbacks.values(): + all_on_command_output_exts.extend(ext_list) + + for ext in callbacks.get("*", []): + key = _extension_key(ext) + if key not in executed_keys: + executed_keys.add(key) + ordered_extensions.append(ext) + + for ext in callbacks.get(route, []): + key = _extension_key(ext) + if key not in executed_keys: + executed_keys.add(key) + ordered_extensions.append(ext) + + try: + for ext in ordered_extensions: if ext.results_only is True: results_only = True - if ext.immutable is True: - if hasattr(obbject, ext.name): - obbject_copy = deepcopy(obbject) - accessor = getattr(obbject_copy, ext.name) - if iscoroutinefunction(accessor): - run_async(accessor) - elif callable(accessor): - accessor() - elif ext.immutable is False and hasattr(obbject, ext.name): - accessor = getattr(obbject, ext.name) - if iscoroutinefunction(accessor): - run_async(accessor) - elif callable(accessor): - accessor() - setattr(obbject, "_extension_modified", True) + if ext.command_output_paths and route not in ext.command_output_paths: + continue - if results_only is True: - setattr(obbject, "_results_only", True) - setattr(obbject, "_extension_modified", True) + accessors: set = getattr(type(obbject), "accessors", set()) + if ext.name not in accessors: + continue + + descriptor = type(obbject).__dict__.get(ext.name) + if not isinstance(descriptor, CachedAccessor): + continue + + factory = descriptor._accessor # type: ignore # pylint: disable=W0212 + + target = _clone_for_immutable(obbject) if ext.immutable else obbject + + if target is None: + continue + + if iscoroutinefunction(factory): + run_async(factory, target) + else: + result = factory(target) + if callable(result): + result() + + if ext.immutable is False: + object.__setattr__(obbject, "_extension_modified", True) + + if results_only is True: + object.__setattr__(obbject, "_results_only", True) + object.__setattr__(obbject, "_extension_modified", True) + + except Exception as e: + raise OpenBBError(e) from e + + for ext in all_on_command_output_exts: + if ext.name in type(obbject).__dict__: + object.__setattr__( + obbject, + ext.name, + "Accessor is not callable outside of function execution.", + ) class CommandRunner: diff --git a/openbb_platform/core/openbb_core/app/model/credentials.py b/openbb_platform/core/openbb_core/app/model/credentials.py index d7ba9236978..af37a3fc80f 100644 --- a/openbb_platform/core/openbb_core/app/model/credentials.py +++ b/openbb_platform/core/openbb_core/app/model/credentials.py @@ -164,6 +164,7 @@ class CredentialsLoader: **self.format_credentials(additional), # type: ignore ) model._env_defaults = env_overrides # type: ignore # pylint: disable=W0212 + model.origins = self.credentials return model diff --git a/openbb_platform/core/tests/app/test_command_runner.py b/openbb_platform/core/tests/app/test_command_runner.py index fb7940a8f4d..494f4430d36 100644 --- a/openbb_platform/core/tests/app/test_command_runner.py +++ b/openbb_platform/core/tests/app/test_command_runner.py @@ -16,7 +16,7 @@ from openbb_core.app.command_runner import ( ) from openbb_core.app.model.abstract.warning import OpenBBWarning from openbb_core.app.model.command_context import CommandContext -from openbb_core.app.model.extension import Extension +from openbb_core.app.model.extension import CachedAccessor, Extension from openbb_core.app.model.obbject import OBBject from openbb_core.app.model.system_settings import SystemSettings from openbb_core.app.model.user_settings import UserSettings @@ -495,7 +495,13 @@ def test_extension_mutable_modifies_original_and_sets_extension_modified_and_rou if isinstance(getattr(self, "results", None), list): self.results.append("modified_by_mut") - monkeypatch.setattr(OBBject, ext.name, mut_accessor, raising=False) + monkeypatch.setattr( + "openbb_core.app.model.obbject.OBBject.accessors", + OBBject.accessors | {ext.name}, + ) + monkeypatch.setattr( + OBBject, ext.name, CachedAccessor(ext.name, mut_accessor), raising=False + ) # register the extension only for "mock/route" fake_loader = SimpleNamespace(on_command_output_callbacks={"mock/route": [ext]}) @@ -535,7 +541,13 @@ def test_results_only_flag_sets_attribute_and_accessor_runs(monkeypatch): def ro_accessor(self): called["hit"] = True - monkeypatch.setattr(OBBject, ext.name, ro_accessor, raising=False) + monkeypatch.setattr( + "openbb_core.app.model.obbject.OBBject.accessors", + OBBject.accessors | {ext.name}, + ) + monkeypatch.setattr( + OBBject, ext.name, CachedAccessor(ext.name, ro_accessor), raising=False + ) fake_loader = SimpleNamespace(on_command_output_callbacks={"*": [ext]}) monkeypatch.setattr( diff --git a/openbb_platform/obbject_extensions/charting/openbb_charting/core/openbb_figure.py b/openbb_platform/obbject_extensions/charting/openbb_charting/core/openbb_figure.py index 5e0d5aa9d9e..96cae692db7 100644 --- a/openbb_platform/obbject_extensions/charting/openbb_charting/core/openbb_figure.py +++ b/openbb_platform/obbject_extensions/charting/openbb_charting/core/openbb_figure.py @@ -87,7 +87,9 @@ class OpenBBFigure(go.Figure): super().__init__() if fig: - self.__dict__ = fig.__dict__ + self.__dict__ = ( + go.Figure(fig).__dict__ if isinstance(fig, dict) else fig.__dict__ + ) self._charting_settings: ChartingSettings | None = kwargs.pop( "charting_settings", None diff --git a/ruff.toml b/ruff.toml index 5d507040b53..6a38e8d1d30 100644 --- a/ruff.toml +++ b/ruff.toml @@ -5,6 +5,7 @@ exclude = [ "^openbb_platform/platform/core/openbb_core/app/static/package/.*", "^openbb_platform/core/openbb/package/.*", + "^cookiecutter/*", ] line-length = 122