[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 <aptekarev@gmail.com>
Co-authored-by: deeleeramone <>
This commit is contained in:
Danglewood
2025-11-07 10:51:18 -08:00
committed by GitHub
parent ae86e04e57
commit b46bfadba7
39 changed files with 1359 additions and 60 deletions

1
.codespell.skip vendored
View File

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

1
.coveragerc vendored
View File

@@ -4,4 +4,5 @@ omit =
tests/*
**/tests/**
**/package/**
cookiecutter/**
source = .

8
.pylintrc vendored
View File

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

5
cookiecutter/MANIFEST.in vendored Normal file
View File

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

72
cookiecutter/README.md vendored Normal file
View File

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

8
cookiecutter/cookiecutter.json vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
"""OpenBB Platform Extension pre-generation script."""
BANNER = """
One of us ❤️
~~~~~~~~~~~~~~~
___ ____ ____
/ _ \\ _ __ ___ _ __ | __ )| __ )
| | | | '_ \\ / _ \\ '_ \\| _ \\| _
| |_| | |_) | __/ | | | |_) | |_) |
\\___/| .__/ \\___|_| |_|____/|____/
|_|
@@@
@@@
@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@
@@@ @@@ @@@ @@@
@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@
@@@ @@@
%%%%%%%%%%%%%%%%%@@@ @@@%%%%%%%%%%%%%%%%%
@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@
@@@ @@@ @@@ @@@
@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@
Investment research for everyone, anywhere.
"""
print(BANNER)

View File

@@ -0,0 +1,7 @@
[coverage:run]
omit =
env/*
tests/*
**/tests/**
**/package/**
source = .

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
"""{{ cookiecutter.project_name}} routers module."""

View File

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

View File

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

View File

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

24
cookiecutter/pyproject.toml vendored Normal file
View File

@@ -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 <hello@openbb.co>"]
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"

View File

@@ -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,9 +448,9 @@ class StaticCommandRunner:
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
@@ -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
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:
setattr(obbject, "_results_only", True)
setattr(obbject, "_extension_modified", 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:

View File

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

View File

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

View File

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

1
ruff.toml vendored
View File

@@ -5,6 +5,7 @@
exclude = [
"^openbb_platform/platform/core/openbb_core/app/static/package/.*",
"^openbb_platform/core/openbb/package/.*",
"^cookiecutter/*",
]
line-length = 122