mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-27 04:31:13 +00:00
[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:
1
.codespell.skip
vendored
1
.codespell.skip
vendored
@@ -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
1
.coveragerc
vendored
@@ -4,4 +4,5 @@ omit =
|
||||
tests/*
|
||||
**/tests/**
|
||||
**/package/**
|
||||
cookiecutter/**
|
||||
source = .
|
||||
|
||||
8
.pylintrc
vendored
8
.pylintrc
vendored
@@ -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
5
cookiecutter/MANIFEST.in
vendored
Normal 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
72
cookiecutter/README.md
vendored
Normal 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
8
cookiecutter/cookiecutter.json
vendored
Normal 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' %}"
|
||||
}
|
||||
10
cookiecutter/openbb_cookiecutter/__init__.py
Normal file
10
cookiecutter/openbb_cookiecutter/__init__.py
Normal 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"
|
||||
76
cookiecutter/openbb_cookiecutter/cli.py
Normal file
76
cookiecutter/openbb_cookiecutter/cli.py
Normal 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())
|
||||
11
cookiecutter/openbb_cookiecutter/template/cookiecutter.json
vendored
Normal file
11
cookiecutter/openbb_cookiecutter/template/cookiecutter.json
vendored
Normal 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' %}"
|
||||
}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""OpenBB Platform Extension pre-generation script."""
|
||||
|
||||
BANNER = """
|
||||
|
||||
|
||||
One of us ❤️
|
||||
|
||||
~~~~~~~~~~~~~~~
|
||||
|
||||
|
||||
___ ____ ____
|
||||
/ _ \\ _ __ ___ _ __ | __ )| __ )
|
||||
| | | | '_ \\ / _ \\ '_ \\| _ \\| _
|
||||
| |_| | |_) | __/ | | | |_) | |_) |
|
||||
\\___/| .__/ \\___|_| |_|____/|____/
|
||||
|_|
|
||||
@@@
|
||||
@@@
|
||||
@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@
|
||||
@@@ @@@ @@@ @@@
|
||||
@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@
|
||||
@@@ @@@
|
||||
%%%%%%%%%%%%%%%%%@@@ @@@%%%%%%%%%%%%%%%%%
|
||||
@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@
|
||||
@@@ @@@ @@@ @@@
|
||||
@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@
|
||||
|
||||
Investment research for everyone, anywhere.
|
||||
"""
|
||||
|
||||
|
||||
print(BANNER)
|
||||
7
cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/.coveragerc
vendored
Normal file
7
cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/.coveragerc
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
[coverage:run]
|
||||
omit =
|
||||
env/*
|
||||
tests/*
|
||||
**/tests/**
|
||||
**/package/**
|
||||
source = .
|
||||
170
cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/.gitignore
vendored
Normal file
170
cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/.gitignore
vendored
Normal 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
|
||||
148
cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/.secrets.baseline
vendored
Normal file
148
cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/.secrets.baseline
vendored
Normal 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"
|
||||
}
|
||||
29
cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/README.md
vendored
Normal file
29
cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/README.md
vendored
Normal 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).
|
||||
34
cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/pyproject.toml
vendored
Normal file
34
cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/pyproject.toml
vendored
Normal 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"
|
||||
7
cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/pytest.ini
vendored
Normal file
7
cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/pytest.ini
vendored
Normal 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
|
||||
48
cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/ruff.toml
vendored
Normal file
48
cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/ruff.toml
vendored
Normal 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"
|
||||
0
cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/tests/.gitkeep
vendored
Normal file
0
cookiecutter/openbb_cookiecutter/template/{{cookiecutter.project_tag}}/tests/.gitkeep
vendored
Normal 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
|
||||
@@ -0,0 +1 @@
|
||||
"""OBBject Extensions module."""
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Providers Module"""
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -0,0 +1 @@
|
||||
"""{{ cookiecutter.provider_name}} utilities module."""
|
||||
@@ -0,0 +1 @@
|
||||
"""{{ cookiecutter.provider_name}} helper functions."""
|
||||
@@ -0,0 +1 @@
|
||||
"""{{ cookiecutter.project_name}} routers module."""
|
||||
@@ -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)]
|
||||
@@ -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()))
|
||||
@@ -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
24
cookiecutter/pyproject.toml
vendored
Normal 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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user