refactor cookiecutter

This commit is contained in:
Danglewood
2026-09-25 11:40:26 -07:00
parent 5f4dda273b
commit ab707f2fe8
45 changed files with 3473 additions and 1632 deletions

View File

@@ -222,6 +222,25 @@ def test_translator_handles_basemodel_param_flattens_fields():
assert any("data" in opt and "field2" in opt for opt in optstrings)
def test_translator_basemodel_param_skips_variadic_signature_parameters():
"""A model allowing extra fields flattens its fields but not its ``**extra_data``."""
from pydantic import ConfigDict
class ExtraData(BaseModel):
model_config = ConfigDict(extra="allow")
field1: str = "a"
def f(data: ExtraData) -> dict:
"""f."""
return {}
t = ArgparseTranslator(f)
optstrings = {opt for action in t.parser._actions for opt in action.option_strings}
assert any("data" in opt and "field1" in opt for opt in optstrings)
assert not any("extra_data" in opt for opt in optstrings)
def test_translator_required_vs_optional_param_routing():
"""Required params land in the 'required arguments' group; optional in standard."""

View File

@@ -135,7 +135,7 @@ class TestShowFigure:
):
show_figure(FIGURE, "t")
open_browser.assert_called_once_with((tmp_path / "chart.html").as_uri())
assert "Plotly.newPlot" in (tmp_path / "chart.html").read_text()
assert "Plotly.newPlot" in (tmp_path / "chart.html").read_text(encoding="utf-8")
class TestStripThemeColors:

View File

@@ -1,5 +0,0 @@
include README.md
include LICENSE
recursive-include openbb_cookiecutter/template *
recursive-exclude openbb_cookiecutter/template *.pyc
recursive-exclude openbb_cookiecutter/template __pycache__

View File

@@ -1,72 +1,73 @@
# OpenBB ODP Extensions Cookiecutter
# OpenBB Extensions Cookiecutter
[Cookiecutter](https://cookiecutter.readthedocs.io/en/1.7.2/) is a command-line utility that creates projects from templates.
A [Cookiecutter](https://cookiecutter.readthedocs.io/) template that generates a new OpenBB Platform extension as an installable Python project.
This extension is a simple template for setting up new OpenBB Python Package extensions and projects.
## Extension types
## Template Structure
Choose one or more types, or `all`:
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.
| Type | Generates |
|------|-----------|
| `router` | Commands under `obb.<router_name>` |
| `provider` | A data provider with an example model and an `EquityHistorical` standard-model implementation |
| `charting` | Chart views for the router's commands, used with `openbb-charting` |
| `obbject` | Accessors added to every command result |
| `on_command_output` | A plugin that runs after a command returns |
- 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.
The generated project follows the same conventions as the extensions in this repository: hatchling packaging, entry points in `[project.entry-points]`, a `dev` dependency group, ruff and ty configuration in `pyproject.toml`, and tests for every generated module.
## Usage
1. Install in a Python environment from PyPI with:
Run it with [uv](https://docs.astral.sh/uv/) without installing:
```
pip install openbb-cookiecutter
```
Alternatively, with `uvx`:
```
```bash
uvx openbb-cookiecutter
```
2. Navigate the current working directory to the desired output location and run:
Or install it and run the command:
```
```bash
pip install openbb-cookiecutter
openbb-cookiecutter
```
Enter values or press `enter` to continue with the default.
Enter a value at each prompt or press enter to accept the default. Pass the types directly to skip that prompt:
3. Create a new Python environment for the project.
4. Navigate into the generated folder and install with:
```
pip install -e .
```bash
openbb-cookiecutter -e router provider
```
5. Python static files will be generated on first import, or trigger with `openbb-build`.
Generate without prompts, into another directory:
6. Import the Python package or start the API and use like any other OpenBB application.
```bash
openbb-cookiecutter --no-input -o ./extensions -e all
```
7. Modify the business logic and get started building!
Override any template value with `--extra-context KEY=VALUE`, for example `--extra-context provider_name=my_source`.
See the developer documentation [here](https://docs.openbb.co/python/developer).
The provider and the OBBject extension each register a credentials namespace under their name, so they need different names.
## Contributing
## Working on the generated project
We welcome contributions to this template! Please feel free to open an issue or submit a pull request with your improvements.
The generated `README.md` covers setup. In short, from the project directory:
```bash
uv sync
uv run openbb-build
uv run pytest
```
## Developing the template
From this directory:
```bash
uv sync
uv run pytest
```
The tests generate a project for each extension type and check its files, its entry points, and that it passes ruff and ty.
## 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)
Questions about the template or OpenBB: `support@openbb.co`. Partnerships: `hello@openbb.co`. Social links: [openbb.co/links](https://openbb.co/links).

View File

@@ -1,9 +0,0 @@
{
"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(' ', '_') }}",
"extension_types": "router",
"_template": "{% now 'utc', '%Y%m%d%H%M%S' %}"
}

View File

@@ -1,10 +1,20 @@
"""OpenBB Cookiecutter Template."""
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
__version__ = "0.4.0"
try:
__version__ = version("openbb-cookiecutter")
except PackageNotFoundError:
__version__ = "0.0.0"
def get_template_path() -> Path:
"""Return the path to the cookiecutter template directory."""
"""Return the path to the cookiecutter template directory.
Returns
-------
Path
The directory holding ``cookiecutter.json`` and the project template.
"""
return Path(__file__).parent / "template"

View File

@@ -1,7 +1,5 @@
"""CLI for OpenBB Cookiecutter template."""
# pylint: disable=W0718
import argparse
import sys
@@ -21,6 +19,23 @@ VALID_EXTENSION_TYPES = [
def _parse_extension_types(value: str) -> list[str]:
"""Parse a comma-separated extension type list.
Parameters
----------
value : str
Comma-separated extension types.
Returns
-------
list[str]
The selected extension types.
Raises
------
ValueError
If a type is not valid or none is given.
"""
types = [t.strip() for t in value.split(",") if t.strip()]
invalid = [t for t in types if t not in VALID_EXTENSION_TYPES]
if invalid:
@@ -34,6 +49,18 @@ def _parse_extension_types(value: str) -> list[str]:
def _prompt_context(preset_extension_types: list[str] | None = None) -> dict:
"""Prompt for the template context.
Parameters
----------
preset_extension_types : list[str] | None
Extension types given on the command line, which skip that prompt.
Returns
-------
dict
The cookiecutter context.
"""
context = {}
context["full_name"] = Prompt.ask(" full_name", default="Hello World")
@@ -75,9 +102,9 @@ def _prompt_context(preset_extension_types: list[str] | None = None) -> dict:
context["router_name"] = "template"
if is_all or "obbject" in types or "on_command_output" in types:
context["obbject_name"] = Prompt.ask(" obbject_name", default="template")
context["obbject_name"] = Prompt.ask(" obbject_name", default="template_ext")
else:
context["obbject_name"] = "template"
context["obbject_name"] = "template_ext"
return context
@@ -85,11 +112,15 @@ def _prompt_context(preset_extension_types: list[str] | None = None) -> dict:
def main(argv: list | None = None) -> int:
"""Run the OpenBB cookiecutter template.
Args:
argv: Command line arguments (defaults to sys.argv[1:])
Parameters
----------
argv : list | None
Command line arguments, defaulting to ``sys.argv[1:]``.
Returns:
Exit code (0 for success, non-zero for error)
Returns
-------
int
The exit code, 0 on success.
"""
parser = argparse.ArgumentParser(
description="Generate an OpenBB Platform extension from template"
@@ -120,8 +151,9 @@ def main(argv: list | None = None) -> int:
nargs="+",
choices=VALID_EXTENSION_TYPES,
default=None,
help="Extension types to include (default: all). "
"Choices: router, provider, obbject, on_command_output, charting, all",
help="Extension types to include. Prompted when omitted, and 'all' with "
"--no-input. Choices: router, provider, obbject, on_command_output, "
"charting, all",
)
args = parser.parse_args(argv)
@@ -155,7 +187,7 @@ def main(argv: list | None = None) -> int:
)
return 0
except Exception as e:
print(f"Error: {e}", file=sys.stderr) # noqa
print(f"Error: {e}", file=sys.stderr)
return 1

View File

@@ -7,6 +7,6 @@
"extension_types": "all",
"provider_name": "template",
"router_name": "template",
"obbject_name": "template",
"obbject_name": "template_ext",
"_template": "{% now 'utc', '%Y%m%d%H%M%S' %}"
}

View File

@@ -1,100 +1,115 @@
"""OpenBB Platform Extension post-generation script."""
"""OpenBB Platform extension post-generation script."""
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
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 ""
PROVIDER_NAME = "{{ cookiecutter.provider_name }}"
ROUTER_NAME = "{{ cookiecutter.router_name }}"
OBBJECT_NAME = "{{ cookiecutter.obbject_name }}"
EXTENSION_TYPES_RAW = "{{ cookiecutter.extension_types }}"
ALL_TYPES = {"router", "provider", "obbject", "on_command_output", "charting"}
LOCAL_ARTIFACTS = (".ruff_cache", ".pytest_cache", ".ipynb_checkpoints", "__pycache__", ".DS_Store")
def parse_extension_types(raw: str) -> set[str]:
"""Return the selected extension types, expanding ``all``.
Parameters
----------
raw : str
Comma-separated extension types.
Returns
-------
set[str]
The selected extension types.
"""
types = {t.strip().lower() for t in raw.split(",") if t.strip()}
if "all" in types:
return {"router", "provider", "obbject", "on_command_output", "charting"}
return types
return set(ALL_TYPES) if "all" in types else types
def remove_path(path: str):
if os.path.isdir(path):
def remove_path(path: Path) -> None:
"""Remove a generated file or directory if it exists.
Parameters
----------
path : Path
The file or directory to remove.
"""
if path.is_dir():
shutil.rmtree(path)
elif os.path.isfile(path):
os.remove(path)
elif path.exists():
path.unlink()
EXTENSION_TYPES = parse_extension_types(EXTENSION_TYPES_RAW)
def require_module_name(name: str, label: str) -> None:
"""Exit when a name is not a valid Python module name.
if not re.match(MODULE_REGEX, MODULE_NAME):
print(f"ERROR: {MODULE_NAME} is not a valid Python package name.")
Parameters
----------
name : str
The name to check.
label : str
What the name is used for, shown in the error.
"""
if not re.match(MODULE_REGEX, name):
print(f"ERROR: {label} '{name}' must be a lower snake_case Python name.")
sys.exit(1)
types = parse_extension_types(EXTENSION_TYPES_RAW)
has_router = "router" in types
has_charting = "charting" in types
has_provider = "provider" in types
has_obbject = "obbject" in types
has_on_command_output = "on_command_output" in types
require_module_name(MODULE_NAME, "package_name")
if has_provider:
require_module_name(PROVIDER_NAME, "provider_name")
if has_router or has_charting:
require_module_name(ROUTER_NAME, "router_name")
if has_obbject or has_on_command_output:
require_module_name(OBBJECT_NAME, "obbject_name")
if has_provider and has_obbject and PROVIDER_NAME == OBBJECT_NAME:
print(
f"ERROR: provider_name and obbject_name are both '{PROVIDER_NAME}'; "
"they share the credentials namespace, so use different names."
)
sys.exit(1)
has_router = "router" in EXTENSION_TYPES
has_charting = "charting" in EXTENSION_TYPES
has_provider = "provider" in EXTENSION_TYPES
has_obbject = "obbject" in EXTENSION_TYPES
has_on_command_output = "on_command_output" in EXTENSION_TYPES
if has_provider and PROVIDER_NAME and not re.match(MODULE_REGEX, PROVIDER_NAME):
print(f"ERROR: {PROVIDER_NAME} should be in lower snakecase.")
sys.exit(1)
if (
(has_router or has_charting)
and ROUTER_NAME
and not re.match(MODULE_REGEX, ROUTER_NAME)
):
print(f"ERROR: {ROUTER_NAME} should be in lower snakecase.")
sys.exit(1)
if (
(has_obbject or has_on_command_output)
and OBBJECT_NAME
and not re.match(MODULE_REGEX, OBBJECT_NAME)
):
print(f"ERROR: {OBBJECT_NAME} should be in lower snakecase.")
sys.exit(1)
routers_dir = os.path.join(MODULE_NAME, "routers")
providers_dir = os.path.join(MODULE_NAME, "providers")
obbject_dir = os.path.join(MODULE_NAME, "obbject")
package = Path(MODULE_NAME)
routers = package / "routers"
obbject = package / "obbject"
tests = Path("tests")
if not has_router:
remove_path(os.path.join(routers_dir, ROUTER_NAME + ".py"))
remove_path(os.path.join(routers_dir, "depends.py"))
remove_path(routers / f"{ROUTER_NAME}.py")
remove_path(tests / "test_router.py")
if not has_charting:
remove_path(os.path.join(routers_dir, ROUTER_NAME + "_views.py"))
remove_path(routers / f"{ROUTER_NAME}_views.py")
remove_path(tests / "test_views.py")
if not has_router and not has_charting:
remove_path(routers_dir)
remove_path(routers)
if not has_provider:
remove_path(providers_dir)
remove_path(package / "providers")
remove_path(tests / "test_provider.py")
if not has_obbject:
remove_path(tests / "test_obbject.py")
if not has_on_command_output:
remove_path(obbject / OBBJECT_NAME / "on_command_output.py")
remove_path(tests / "test_on_command_output.py")
if not has_obbject and not has_on_command_output:
remove_path(obbject_dir)
remove_path(obbject)
if (has_obbject or has_on_command_output) and not has_router:
try:
from importlib.metadata import entry_points
for artifact in LOCAL_ARTIFACTS:
for path in sorted(Path().rglob(artifact), reverse=True):
remove_path(path)
eps = entry_points()
core_eps = (
eps.select(group="openbb_core_extension")
if hasattr(eps, "select")
else eps.get("openbb_core_extension", [])
)
if not list(core_eps):
print(
"\n WARNING: No 'openbb_core_extension' entry points found in the environment."
"\n The 'obbject' and 'on_command_output' extension types require at least"
"\n one router extension to be installed in order to function.\n"
)
except Exception:
pass
if ruff := shutil.which("ruff"):
subprocess.run([ruff, "format", "--quiet", "--no-cache", "."], check=False)

View File

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

View File

@@ -1,170 +1,15 @@
# 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
.venv/
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/
.pytest_cache/
.ruff_cache/
.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
htmlcov/
.ipynb_checkpoints/
.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

@@ -1,148 +0,0 @@
{
"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

@@ -1,41 +1,64 @@
# OpenBB ODP Extensions Cookiecutter Template
{%- set types = cookiecutter.extension_types.split(',') | map('trim') | list -%}
{%- set is_all = 'all' in types -%}
{%- set has_router = is_all or 'router' in types -%}
{%- set has_provider = is_all or 'provider' in types -%}
{%- set has_charting = is_all or 'charting' in types -%}
{%- set has_obbject = is_all or 'obbject' in types -%}
{%- set has_on_command_output = is_all or 'on_command_output' in types -%}
# {{ cookiecutter.project_name }}
## Introduction
An extension for the OpenBB Platform, generated with `openbb-cookiecutter`.
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
## Contents
{% if has_router %}
- `{{ cookiecutter.package_name }}/routers/{{ cookiecutter.router_name }}.py`: commands under `obb.{{ cookiecutter.router_name }}`.
{%- endif %}
{%- if has_provider %}
- `{{ cookiecutter.package_name }}/providers/{{ cookiecutter.provider_name }}/`: the `{{ cookiecutter.provider_name }}` provider, with an `Example` model and an `EquityHistorical` standard-model implementation.
{%- endif %}
{%- if has_charting %}
- `{{ cookiecutter.package_name }}/routers/{{ cookiecutter.router_name }}_views.py`: charts for `{{ cookiecutter.router_name }}` commands, used when `openbb-charting` is installed.
{%- endif %}
{%- if has_obbject %}
- `{{ cookiecutter.package_name }}/obbject/{{ cookiecutter.obbject_name }}/__init__.py`: the `to_string` and `{{ cookiecutter.obbject_name }}` accessors on every command result.
{%- endif %}
{%- if has_on_command_output %}
- `{{ cookiecutter.package_name }}/obbject/{{ cookiecutter.obbject_name }}/on_command_output.py`: a plugin that runs after `/{{ cookiecutter.router_name }}/candles`.
{%- endif %}
{%- if has_charting and not has_router %}
With it you can:
The views are named after `{{ cookiecutter.router_name }}` routes, so pair them with a router extension that uses that name.
{%- endif %}
- Create a new extension
- Build custom commands
- Interact with the standardization framework
- Build custom services and applications on top of the framework
## Development
## Getting Started
Create an environment with [uv](https://docs.astral.sh/uv/) and install the package in editable mode with its development dependencies:
We recommend you check out the files in the following order:
{% set types = cookiecutter.extension_types.split(',') | map('trim') | list %}
{% if 'router' in types or 'all' in types %}
* `{{cookiecutter.package_name}}/routers/{{cookiecutter.router_name}}.py`
{% endif %}
{% if 'provider' in types or 'all' in types %}
* `{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/models/example.py`
* `{{cookiecutter.package_name}}/providers/{{cookiecutter.provider_name}}/__init__.py`
{% endif %}
{% if 'obbject' in types or 'on_command_output' in types or 'all' in types %}
* `{{cookiecutter.package_name}}/obbject/{{cookiecutter.obbject_name}}/__init__.py`
{% endif %}
{% if 'charting' in types or 'all' in types %}
* `{{cookiecutter.package_name}}/routers/{{cookiecutter.router_name}}_views.py`
{% endif %}
{% if 'charting' in types and 'router' not in types and 'all' not in types %}
```bash
uv sync
```
> **Note:** You selected charting without a router. The views file references `{{cookiecutter.router_name}}` naming conventions. You will need to pair this with an existing router extension that uses the same name.
{% endif %}
Build the `openbb` package so the new commands appear under `obb`:
Check out the developer [documentation](https://docs.openbb.co/python/developer) for more information on getting started making OpenBB extensions.
```bash
uv run openbb-build
```
{%- if has_on_command_output %}
---
On-command-output plugins run on every matching result, so OpenBB loads them only when explicitly allowed:
🦋 Made with [openbb cookiecutter](https://github.com/openbb-finance/OpenBB/cookiecutter).
```bash
export OPENBB_ALLOW_ON_COMMAND_OUTPUT=true
```
{%- endif %}
Lint, type check, and test:
```bash
uv run ruff format --check .
uv run ruff check .
uv run ty check {{ cookiecutter.package_name }}
uv run pytest
```
Serve the commands over the REST API with `uv run openbb-api`.

View File

@@ -1,43 +1,102 @@
[tool.poetry]
{%- set types = cookiecutter.extension_types.split(',') | map('trim') | list -%}
{%- set is_all = 'all' in types -%}
{%- set has_router = is_all or 'router' in types -%}
{%- set has_provider = is_all or 'provider' in types -%}
{%- set has_charting = is_all or 'charting' in types -%}
{%- set has_obbject = is_all or 'obbject' in types -%}
{%- set has_on_command_output = is_all or 'on_command_output' in types -%}
{%- set views_class = cookiecutter.router_name.replace('_', ' ').title().replace(' ', '') -%}
[project]
name = "{{ cookiecutter.project_tag }}"
version = "0.0.1"
description = "{{ cookiecutter.project_name }}"
authors = ["{{ cookiecutter.full_name }} <{{ cookiecutter.email }}>"]
authors = [{ name = "{{ cookiecutter.full_name }}", email = "{{ cookiecutter.email }}" }]
license = { text = "Apache-2.0" }
readme = "README.md"
license = "Apache-2.0"
packages = [{ include = "{{ cookiecutter.package_name }}" }]
[tool.poetry.dependencies]
python = ">=3.10,<4"
openbb-core = "*"
openbb-platform-api = "*"
[tool.poetry.group.dev.dependencies]
openbb-devtools = { version = "*" }
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
{% set types = cookiecutter.extension_types.split(',') | map('trim') | list %}
{% if 'router' in types or 'all' in types %}
[tool.poetry.plugins."openbb_core_extension"]
requires-python = ">=3.10,<4"
dependencies = ["openbb-core[pandas]>=2.0.0"]
{% if has_charting %}
[project.optional-dependencies]
charting = ["openbb-charting>=3.0.0"]
{% endif %}
{%- if has_router %}
[project.entry-points."openbb_core_extension"]
{{ cookiecutter.router_name }} = "{{ cookiecutter.package_name }}.routers.{{ cookiecutter.router_name }}:router"
{% endif %}
{% if 'charting' in types or 'all' in types %}
[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"
{%- if has_charting %}
[project.entry-points."openbb_charting_extension"]
{{ cookiecutter.router_name }} = "{{ cookiecutter.package_name }}.routers.{{ cookiecutter.router_name }}_views:{{ views_class }}Views"
{% endif %}
{% if 'provider' in types or 'all' in types %}
[tool.poetry.plugins."openbb_provider_extension"]
{%- if has_provider %}
[project.entry-points."openbb_provider_extension"]
{{ cookiecutter.provider_name }} = "{{ cookiecutter.package_name }}.providers.{{ cookiecutter.provider_name }}:{{ cookiecutter.provider_name }}_provider"
{% endif %}
{% if 'obbject' in types or 'on_command_output' in types or 'all' in types %}
[tool.poetry.plugins."openbb_obbject_extension"]
{% if 'obbject' in types or 'all' in types %}
{%- if has_obbject or has_on_command_output %}
[project.entry-points."openbb_obbject_extension"]
{%- if has_obbject %}
to_string = "{{ cookiecutter.package_name }}.obbject.{{ cookiecutter.obbject_name }}:ext"
{{ cookiecutter.obbject_name }} = "{{ cookiecutter.package_name }}.obbject.{{ cookiecutter.obbject_name }}:class_ext"
{%- endif %}
{%- if has_on_command_output %}
nonblocking_plugin = "{{ cookiecutter.package_name }}.obbject.{{ cookiecutter.obbject_name }}.on_command_output:nonblocking_plugin"
{%- endif %}
{% endif %}
{% if 'on_command_output' in types or 'all' in types %}
nonblocking_plugin = "{{ cookiecutter.package_name }}.obbject.{{ cookiecutter.obbject_name }}:nonblocking_plugin"
{% endif %}
{% endif %}
[dependency-groups]
dev = [
"openbb-devtools>=2.0.1",
"openbb-platform-api>=2.0.0",
{%- if has_charting %}
"openbb-charting>=3.0.0",
{%- endif %}
]
[build-system]
requires = ["hatchling>=1.25"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["{{ cookiecutter.package_name }}"]
[tool.ruff]
line-length = 88
target-version = "py310"
fix = true
[tool.ruff.lint]
select = ["D", "E", "W", "F", "S", "UP", "I", "PLC", "PLE", "PLR", "PLW", "SIM", "T20"]
ignore = [
"PLR2004", "PLR0913", "PLR0915", "PLR0917", "PLC0415",
"E402", "E501",
"D202", "D203", "D205", "D206", "D210", "D212", "D213", "D214",
"D215", "D300", "D400", "D404", "D405", "D406", "D407", "D408",
"D409", "D410", "D411", "D413", "D415", "D416", "D417",
]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101", "D"]
"**/__init__.py" = ["F401"]
[tool.ruff.lint.isort]
combine-as-imports = true
force-wrap-aliases = true
[tool.ruff.lint.pydocstyle]
convention = "numpy"
[tool.ty]
[tool.ty.environment]
python-version = "3.10"
[tool.ty.src]
include = ["{{ cookiecutter.package_name }}"]
exclude = ["tests"]
[tool.ty.rules]
unresolved-import = "warn"
[tool.coverage.report]
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:", "raise NotImplementedError", "@overload"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers"

View File

@@ -1,7 +0,0 @@
[pytest]
addopts = -p no:warnings
markers =
linux: tests that are not stable on Windows
integration: OpenBB Platform integration test marker
testpaths =
tests

View File

@@ -1,48 +0,0 @@
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 @@
"""{{ cookiecutter.project_name }} tests."""

View File

@@ -1,42 +1,5 @@
"""Root configuration for pytest."""
# flake8: noqa: S101
# pylint: disable=unused-argument,unused-import
"""Shared test configuration."""
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
os.environ.setdefault("OPENBB_ALLOW_ON_COMMAND_OUTPUT", "true")

View File

@@ -0,0 +1,25 @@
"""Tests for the {{ cookiecutter.obbject_name }} OBBject extensions."""
import json
from openbb_core.app.model.obbject import OBBject
import {{ cookiecutter.package_name }}.obbject.{{ cookiecutter.obbject_name }} # noqa: F401
class TestToString:
"""``obbject.to_string`` serializes the results."""
def test_value(self):
obbject = OBBject(results=[{"a": 1}])
assert json.loads(obbject.to_string) == {"results": [{"a": 1}]}
class TestNamespace:
"""``obbject.{{ cookiecutter.obbject_name }}`` exposes the extension methods."""
def test_hello_world(self):
obbject = OBBject(results=[{"a": 1}], provider="demo")
assert obbject.{{ cookiecutter.obbject_name }}.hello_world() == (
"Hello from the OBBject returned by demo!"
)

View File

@@ -0,0 +1,24 @@
"""Tests for the on-command-output plugin."""
import logging
from openbb_core.app.model.obbject import OBBject
from {{ cookiecutter.package_name }}.obbject.{{ cookiecutter.obbject_name }} import on_command_output
class TestNonblockingPlugin:
"""The plugin processes the output on a background thread."""
def test_configuration(self):
plugin = on_command_output.nonblocking_plugin
assert plugin.on_command_output is True
assert plugin.command_output_paths == ["/{{ cookiecutter.router_name }}/candles"]
def test_processes_in_background(self, caplog):
caplog.set_level(logging.INFO, logger=on_command_output.__name__)
obbject = OBBject(results=[{"a": 1}, {"a": 2}], provider="demo")
worker = obbject.nonblocking_plugin
worker.join(timeout=5)
assert not worker.is_alive()
assert "Processed 2 rows from demo." in caplog.text

View File

@@ -0,0 +1,72 @@
{%- set provider_class = cookiecutter.provider_name.replace('_', ' ').title().replace(' ', '') -%}
"""Tests for the {{ cookiecutter.provider_name }} provider."""
import asyncio
from {{ cookiecutter.package_name }}.providers.{{ cookiecutter.provider_name }} import (
{{ cookiecutter.provider_name }}_provider,
)
from {{ cookiecutter.package_name }}.providers.{{ cookiecutter.provider_name }}.models.equity_historical import (
{{ provider_class }}EquityHistoricalFetcher,
)
from {{ cookiecutter.package_name }}.providers.{{ cookiecutter.provider_name }}.models.example import (
ExampleFetcher,
)
class TestProvider:
"""The provider registers every fetcher."""
def test_fetcher_dict(self):
assert {{ cookiecutter.provider_name }}_provider.fetcher_dict == {
"EquityHistorical": {{ provider_class }}EquityHistoricalFetcher,
"Example": ExampleFetcher,
}
class TestExampleFetcher:
"""The example fetcher maps the raw columns onto the data model."""
def test_fetcher(self):
ExampleFetcher.test({"symbol": "AAPL"})
def test_rows(self):
rows = asyncio.run(ExampleFetcher.fetch_data({"symbol": "AAPL"}))
assert [row.model_dump() for row in rows] == [
{
"symbol": "AAPL",
"date": "2023-08-23",
"open": 2.0,
"high": 5.0,
"low": 1.0,
"close": 4.0,
"volume": 5.0,
},
{
"symbol": "AAPL",
"date": "2023-08-24",
"open": 4.0,
"high": 7.0,
"low": 3.0,
"close": 6.0,
"volume": 10.0,
},
]
class TestEquityHistoricalFetcher:
"""The standard-model fetcher fills the custom field."""
def test_fetcher(self):
{{ provider_class }}EquityHistoricalFetcher.test({"symbol": "AAPL"})
def test_custom_field(self):
rows = asyncio.run(
{{ provider_class }}EquityHistoricalFetcher.fetch_data(
{"symbol": "AAPL", "custom_param": "x"}
)
)
assert [row.custom_field for row in rows] == [
"x",
"Data validator replaced None.",
]

View File

@@ -0,0 +1,72 @@
{%- set types = cookiecutter.extension_types.split(',') | map('trim') | list -%}
{%- set has_provider = 'provider' in types or 'all' in types -%}
"""Tests for the {{ cookiecutter.router_name }} router."""
import asyncio
{%- if has_provider %}
from unittest.mock import AsyncMock, patch
{%- endif %}
import pytest
from openbb_core.app.model.abstract.error import OpenBBError
from {{ cookiecutter.package_name }}.routers import {{ cookiecutter.router_name }} as module
class TestGetExample:
"""``get_example`` returns the symbol details from the response."""
def test_rejects_non_object_response(self, monkeypatch):
async def fake_request(url):
return [{"details": {}}]
monkeypatch.setattr(module, "amake_request", fake_request)
with pytest.raises(OpenBBError):
asyncio.run(module.get_example(symbol="AAPL"))
def test_results(self, monkeypatch):
requested: list[str] = []
async def fake_request(url):
requested.append(url)
return {"details": {"symbol": "AAPL"}}
monkeypatch.setattr(module, "amake_request", fake_request)
result = asyncio.run(module.get_example(symbol="AAPL"))
assert result.results == {"symbol": "AAPL"}
assert requested == [f"{module.SYMBOL_INFO_URL}?symbol=AAPL"]
class TestPostExample:
"""``post_example`` measures the quote."""
def test_results(self):
result = asyncio.run(
module.post_example(module.QuoteQueryParams(bid=1.0, ask=3.0, flag=True))
)
assert result.results == {"mid": 2.0, "spread": 2.0, "flag": True}
{%- if has_provider %}
class TestModelCommands:
"""Model commands delegate to ``OBBject.from_query``."""
@pytest.mark.parametrize("command", [module.model_example, module.candles])
def test_delegates_to_query(self, command):
sentinel = object()
with (
patch.object(module, "OBBject") as obbject,
patch.object(module, "Query") as query,
):
obbject.from_query = AsyncMock(return_value=sentinel)
result = asyncio.run(
command(
cc=None,
provider_choices=None,
standard_params=None,
extra_params=None,
)
)
assert result is sentinel
query.assert_called_once()
{%- endif %}

View File

@@ -0,0 +1,26 @@
{%- set views_class = cookiecutter.router_name.replace('_', ' ').title().replace(' ', '') -%}
"""Tests for the {{ cookiecutter.router_name }} charting views."""
from datetime import date
from types import SimpleNamespace
import pytest
pytest.importorskip("openbb_charting")
from {{ cookiecutter.package_name }}.routers.{{ cookiecutter.router_name }}_views import (
{{ views_class }}Views,
)
class TestCandlesView:
"""The candles view charts the daily highs."""
def test_figure(self):
rows = [
SimpleNamespace(date=date(2023, 8, 23), high=5.0),
SimpleNamespace(date=date(2023, 8, 24), high=7.0),
]
fig, content = {{ views_class }}Views.{{ cookiecutter.router_name }}_candles(obbject_item=rows)
assert list(fig.data[0].y) == [5.0, 7.0]
assert content["data"][0]["type"] == "bar"

View File

@@ -1 +1 @@
"""OBBject Extensions module."""
"""OBBject extensions."""

View File

@@ -1,86 +1,63 @@
"""{{ cookiecutter.package_name }} OBBject Extension - {{ cookiecutter.obbject_name }}"""
{% set types = cookiecutter.extension_types.split(',') | map('trim') | list %}
{% set has_obbject = 'obbject' in types or 'all' in types %}
{% set has_on_command_output = 'on_command_output' in types or 'all' in types %}
# pylint: disable=W0613,R0903
{% if has_on_command_output %}
import threading
import time
{% endif %}
{%- set types = cookiecutter.extension_types.split(',') | map('trim') | list -%}
{%- set has_obbject = 'obbject' in types or 'all' in types -%}
"""{{ cookiecutter.obbject_name }} OBBject extensions."""
{%- if has_obbject %}
from openbb_core.app.model.extension import Extension
from openbb_core.app.model.obbject import OBBject
{% if has_obbject %}
ext = Extension(
name="to_string",
description="An OBBject extension that converts the results to a string representation.",
description="Serialize the results of an OBBject to a JSON string.",
)
@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")
def to_string(obbject: OBBject) -> str:
"""Return the results as a JSON string, read as ``obbject.to_string``.
Parameters
----------
obbject : OBBject
The command output.
Returns
-------
str
The results serialized as JSON.
"""
return obbject.model_dump_json(
exclude_none=True, exclude_unset=True, include={"results"}
)
class_ext = Extension(
name="{{ cookiecutter.obbject_name }}",
description="An OBBject extension with namespace."
description="A namespace of methods on every OBBject.",
)
@class_ext.obbject_accessor
class OBBjectExtension:
"""OBBject Extension Template."""
"""Methods available as ``obbject.{{ cookiecutter.obbject_name }}``."""
def __init__(self, obbject: OBBject):
"""Initialize the extension."""
"""Bind the extension to a command output.
Parameters
----------
obbject : OBBject
The command output.
"""
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
{% endif %}
{% if has_on_command_output %}
def hello_world(self) -> str:
"""Return a greeting that names the command's provider.
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,
command_output_paths=["/{{cookiecutter.router_name}}/candles"],
immutable=True,
results_only=False,
)
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):
"""Simulated on_command_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()
{% endif %}
Returns
-------
str
The greeting.
"""
return f"Hello from the OBBject returned by {self._obbject.provider}!"
{%- endif %}

View File

@@ -0,0 +1,56 @@
"""On-command-output plugin that processes results in a background thread."""
import logging
import threading
from openbb_core.app.model.extension import Extension
from openbb_core.app.model.obbject import OBBject
logger = logging.getLogger(__name__)
nonblocking_plugin = Extension(
name="nonblocking_plugin",
description="Hand each /{{ cookiecutter.router_name }}/candles result to a background thread.",
on_command_output=True,
command_output_paths=["/{{ cookiecutter.router_name }}/candles"],
immutable=True,
results_only=False,
)
def process_in_background(serialized_obbject: dict) -> None:
"""Rebuild the command output and process it off the request path.
Parameters
----------
serialized_obbject : dict
The command output dumped with ``model_dump``.
"""
obbject = OBBject(**serialized_obbject)
logger.info(
"Processed %s rows from %s.", len(obbject.results or []), obbject.provider
)
@nonblocking_plugin.obbject_accessor
def start_background_processing(obbject: OBBject) -> threading.Thread:
"""Start processing the command output without blocking the response.
Parameters
----------
obbject : OBBject
The command output.
Returns
-------
threading.Thread
The started worker thread.
"""
worker = threading.Thread(
target=process_in_background,
args=(obbject.model_dump(),),
name="nonblocking-plugin",
daemon=True,
)
worker.start()
return worker

View File

@@ -1,22 +1,21 @@
"""{{ cookiecutter.package_name }} OpenBB Platform Provider."""
{%- set provider_class = cookiecutter.provider_name.replace('_', ' ').title().replace(' ', '') -%}
"""{{ cookiecutter.project_name }} 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,
}
from {{ cookiecutter.package_name }}.providers.{{ cookiecutter.provider_name }}.models.equity_historical import (
{{ provider_class }}EquityHistoricalFetcher,
)
from {{ cookiecutter.package_name }}.providers.{{ cookiecutter.provider_name }}.models.example import (
ExampleFetcher,
)
{{ cookiecutter.provider_name }}_provider = Provider(
name="{{ cookiecutter.provider_name }}",
description="Data provider for {{ cookiecutter.project_name }}.",
website="https://{{ cookiecutter.project_tag }}.com",
fetcher_dict={
"EquityHistorical": {{ provider_class }}EquityHistoricalFetcher,
"Example": ExampleFetcher,
},
)

View File

@@ -0,0 +1,137 @@
{%- set provider_class = cookiecutter.provider_name.replace('_', ' ').title().replace(' ', '') -%}
"""{{ provider_class }} Equity Historical model built on the standard model."""
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 {{ provider_class }}EquityHistoricalQueryParams(EquityHistoricalQueryParams):
"""{{ provider_class }} Equity Historical query parameters."""
custom_param: str | None = Field(
default=None, description="An optional provider-specific parameter."
)
class {{ provider_class }}EquityHistoricalData(EquityHistoricalData):
"""{{ provider_class }} Equity Historical data."""
__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="An optional provider-specific field."
)
@field_validator("custom_field", mode="before", check_fields=False)
@classmethod
def _default_custom_field(cls, v: str | None) -> str:
"""Replace a missing custom field with a default value."""
return v or "Data validator replaced None."
class {{ provider_class }}EquityHistoricalFetcher(
Fetcher[
{{ provider_class }}EquityHistoricalQueryParams,
list[{{ provider_class }}EquityHistoricalData],
]
):
"""{{ provider_class }} Equity Historical fetcher."""
@staticmethod
def transform_query(
params: dict[str, Any],
) -> {{ provider_class }}EquityHistoricalQueryParams:
"""Transform the query parameters.
Parameters
----------
params : dict[str, Any]
The raw query parameters.
Returns
-------
{{ provider_class }}EquityHistoricalQueryParams
The validated query.
"""
return {{ provider_class }}EquityHistoricalQueryParams(**params)
@staticmethod
async def aextract_data(
query: {{ provider_class }}EquityHistoricalQueryParams,
credentials: dict[str, str] | None,
**kwargs: Any,
) -> list[dict]:
"""Return the raw rows for the query.
Parameters
----------
query : {{ provider_class }}EquityHistoricalQueryParams
The validated query.
credentials : dict[str, str] | None
The provider credentials.
**kwargs : Any
Additional keyword arguments.
Returns
-------
list[dict]
The raw rows.
"""
return [
{
"d": "2023-08-23",
"o": 2,
"h": 5,
"l": 1,
"c": 4,
"v": 5,
"f": query.custom_param,
},
{
"d": "2023-08-24",
"o": 4,
"h": 7,
"l": 3,
"c": 6,
"v": 10,
"f": None,
},
]
@staticmethod
def transform_data(
query: {{ provider_class }}EquityHistoricalQueryParams,
data: list[dict],
**kwargs: Any,
) -> list[{{ provider_class }}EquityHistoricalData]:
"""Validate the raw rows into the data model.
Parameters
----------
query : {{ provider_class }}EquityHistoricalQueryParams
The validated query.
data : list[dict]
The raw rows.
**kwargs : Any
Additional keyword arguments.
Returns
-------
list[{{ provider_class }}EquityHistoricalData]
The validated rows.
"""
return [{{ provider_class }}EquityHistoricalData.model_validate(row) for row in data]

View File

@@ -1,22 +1,6 @@
"""Example Data Integration.
"""Example model with custom query parameters and data."""
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 typing import Any
from openbb_core.provider.abstract.data import Data
from openbb_core.provider.abstract.fetcher import Fetcher
@@ -25,97 +9,114 @@ 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.
"""
"""Example query parameters."""
symbol: str = Field(description="Symbol to query.")
class ExampleData(Data):
"""Sample provider data.
"""Example 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.
"""
__alias_dict__ = {
"date": "d",
"open": "o",
"high": "h",
"low": "l",
"close": "c",
"volume": "v",
}
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")
symbol: str = Field(description="Symbol of the row.")
date: str = Field(description="Date of the row.")
open: float = Field(description="Open price.")
high: float = Field(description="High price.")
low: float = Field(description="Low price.")
close: float = Field(description="Close price.")
volume: float = Field(description="Volume.")
class ExampleFetcher(
Fetcher[
ExampleQueryParams,
list[ExampleData],
]
):
"""Example Fetcher class.
This class is responsible for the actual data retrieval.
"""
class ExampleFetcher(Fetcher[ExampleQueryParams, list[ExampleData]]):
"""Example fetcher."""
@staticmethod
def transform_query(params: dict[str, Any]) -> ExampleQueryParams:
"""Define example transform_query.
"""Transform the query parameters.
Here we can pre-process the query parameters and add any extra parameters that
will be used inside the extract_data method.
Parameters
----------
params : dict[str, Any]
The raw query parameters.
Returns
-------
ExampleQueryParams
The validated query.
"""
return ExampleQueryParams(**params)
@staticmethod
def extract_data(
async def aextract_data(
query: ExampleQueryParams,
credentials: dict[str, str] | None,
**kwargs: Any,
) -> list[dict]:
"""Define example extract_data.
"""Return the raw rows for the query.
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.
Parameters
----------
query : ExampleQueryParams
The validated query.
credentials : dict[str, str] | None
The provider credentials.
**kwargs : Any
Additional keyword arguments.
Returns
-------
list[dict]
The raw rows.
"""
api_key = (
credentials.get("{{cookiecutter.package_name}}_api_key")
if credentials
else ""
)
# Here we mock an example_response for brevity.
example_response = [
return [
{
"symbol": query.symbol,
"d": "2023-08-23",
"o": 2,
"h": 5,
"l": 1,
"c": 4,
"v": 5,
"d": "August 23, 2023",
},
{
"symbol": query.symbol,
"d": "2023-08-24",
"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
query: ExampleQueryParams,
data: list[dict],
**kwargs: Any,
) -> list[ExampleData]:
"""Define example transform_data.
"""Validate the raw rows into the data model.
Right now, we're converting the data to fit our desired format.
You can apply other transformations to it here.
Parameters
----------
query : ExampleQueryParams
The validated query.
data : list[dict]
The raw rows.
**kwargs : Any
Additional keyword arguments.
Returns
-------
list[ExampleData]
The validated rows.
"""
return [ExampleData(**d) for d in data]
return [ExampleData.model_validate(row) for row in data]

View File

@@ -1,127 +0,0 @@
"""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('_', ' ').title().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('_', ' ').title().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('_', ' ').title().replace(' ', '')}}EquityHistoricalFetcher(
Fetcher[
{{cookiecutter.provider_name.replace('_', ' ').title().replace(' ', '')}}EquityHistoricalQueryParams,
list[{{cookiecutter.provider_name.replace('_', ' ').title().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('_', ' ').title().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('_', ' ').title().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('_', ' ').title().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('_', ' ').title().replace(' ', '')}}EquityHistoricalQueryParams,
data: list[dict],
**kwargs: Any
) -> list[{{cookiecutter.provider_name.replace('_', ' ').title().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('_', ' ').title().replace(' ', '')}}EquityHistoricalData.model_validate(d) for d in data]

View File

@@ -1 +1 @@
"""{{ cookiecutter.provider_name}} utilities module."""
"""{{ cookiecutter.provider_name }} utilities."""

View File

@@ -1 +1 @@
"""{{ cookiecutter.provider_name}} helper functions."""
"""{{ cookiecutter.provider_name }} helper functions."""

View File

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

View File

@@ -1,11 +0,0 @@
"""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

@@ -1,80 +1,93 @@
"""{{cookiecutter.router_name}} router command example."""
{% set types = cookiecutter.extension_types.split(',') | map('trim') | list %}
{% set has_provider = 'provider' in types or 'all' in types %}
# pylint: disable=unused-argument
{% if has_provider %}
{%- set types = cookiecutter.extension_types.split(',') | map('trim') | list -%}
{%- set has_provider = 'provider' in types or 'all' in types -%}
"""{{ cookiecutter.router_name }} router."""
from typing import Any
from openbb_core.app.model.abstract.error import OpenBBError
{% if has_provider -%}
from openbb_core.app.model.command_context import CommandContext
{% endif %}
from openbb_core.app.model.example import APIEx, PythonEx
{% endif -%}
from openbb_core.app.model.example import APIEx
from openbb_core.app.model.obbject import OBBject
{% if has_provider %}
from openbb_core.app.provider_interface import ExtraParams, ProviderChoices, StandardParams
{% if has_provider -%}
from openbb_core.app.provider_interface import (
ExtraParams,
ProviderChoices,
StandardParams,
)
from openbb_core.app.query import Query
{% endif %}
{% endif -%}
from openbb_core.app.router import Router
from pydantic import BaseModel
from openbb_core.provider.abstract.query_params import QueryParams
from openbb_core.provider.utils.helpers import amake_request
from pydantic import Field
from {{cookiecutter.package_name}}.routers.depends import Session
router = Router(prefix="", description="{{ cookiecutter.project_name }} commands.")
router = Router(prefix="")
SYMBOL_INFO_URL = "https://www.cboe.com/education/tools/trade-optimizer/symbol-info"
class QuoteQueryParams(QueryParams):
"""Bid and ask prices to measure."""
bid: float = Field(description="Bid price.")
ask: float = Field(description="Ask price.")
flag: bool = Field(default=False, description="A flag echoed in the results.")
@router.command(
methods=["GET"],
examples=[
PythonEx(
description="Here is an example for using this endpoint.",
code=[
"obb.{{ cookiecutter.router_name }}.get_example(symbol='AAPL')",
]
)
]
examples=[APIEx(parameters={"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"])
async def get_example(symbol: str = "AAPL") -> OBBject[dict[str, Any]]:
"""Get the Cboe symbol details for a ticker."""
response = await amake_request(f"{SYMBOL_INFO_URL}?symbol={symbol}")
if not isinstance(response, dict):
raise OpenBBError(f"Unexpected response for {symbol}: {response}")
return OBBject(results=response["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})
{% if has_provider %}
@router.command(
methods=["POST"],
examples=[APIEx(parameters={"bid": 1.0, "ask": 3.0})],
)
async def post_example(params: QuoteQueryParams) -> OBBject[dict[str, Any]]:
"""Calculate the mid price and spread of a quote."""
return OBBject(
results={
"mid": (params.bid + params.ask) / 2,
"spread": params.ask - params.bid,
"flag": params.flag,
}
)
{%- if has_provider %}
@router.command(model="Example")
@router.command(
model="Example",
examples=[APIEx(parameters={"symbol": "AAPL", "provider": "{{ cookiecutter.provider_name }}"})],
)
async def model_example(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject[BaseModel]:
"""Example Data."""
) -> OBBject:
"""Get example OHLCV data from a provider."""
return await OBBject.from_query(Query(**locals()))
@router.command(model="EquityHistorical")
@router.command(
model="EquityHistorical",
examples=[APIEx(parameters={"symbol": "AAPL", "provider": "{{ cookiecutter.provider_name }}"})],
)
async def candles(
cc: CommandContext,
provider_choices: ProviderChoices,
standard_params: StandardParams,
extra_params: ExtraParams,
) -> OBBject:
"""Example Data."""
"""Get historical equity prices from a provider."""
return await OBBject.from_query(Query(**locals()))
{% endif %}
{%- endif %}

View File

@@ -1,41 +1,35 @@
"""Views for the {{ cookiecutter.router_name }} Extension."""
# flake8: noqa: PLR0912
# pylint: disable=import-outside-toplevel,too-few-public-methods
{%- set views_class = cookiecutter.router_name.replace('_', ' ').title().replace(' ', '') -%}
"""Charting views for the {{ cookiecutter.router_name }} router."""
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from openbb_charting.core.openbb_figure import OpenBBFigure # `openbb-charting` is Optional for the user.
from openbb_charting.core.openbb_figure import OpenBBFigure
# 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."""
class {{ views_class }}Views:
"""Charts for {{ cookiecutter.router_name }} commands, one static method per route."""
@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.
def {{ cookiecutter.router_name }}_candles(
**kwargs: Any,
) -> tuple["OpenBBFigure", dict[str, Any]]:
"""Chart the daily high prices returned by ``/{{ cookiecutter.router_name }}/candles``.
Parameters
----------
**kwargs : Any
The charting context, with the command's rows in ``obbject_item``.
Returns
-------
tuple[OpenBBFigure, dict[str, Any]]
The figure and its JSON-serializable content.
"""
from openbb_charting.core.openbb_figure import OpenBBFigure
data = kwargs["obbject_item"] # This is where the data will always be.
print(data)
rows = kwargs["obbject_item"]
fig = OpenBBFigure()
fig.add_bar(x=[d.date for d in data], y=[d.high for d in data])
fig.add_bar(x=[row.date for row in rows], y=[row.high for row in rows])
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

633
cookiecutter/poetry.lock generated vendored
View File

@@ -1,633 +0,0 @@
# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
[[package]]
name = "arrow"
version = "1.4.0"
description = "Better dates & times for Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205"},
{file = "arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7"},
]
[package.dependencies]
python-dateutil = ">=2.7.0"
tzdata = {version = "*", markers = "python_version >= \"3.9\""}
[package.extras]
doc = ["doc8", "sphinx (>=7.0.0)", "sphinx-autobuild", "sphinx-autodoc-typehints", "sphinx_rtd_theme (>=1.3.0)"]
test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock", "pytz (==2025.2)", "simplejson (==3.*)"]
[[package]]
name = "binaryornot"
version = "0.6.0"
description = "Ultra-lightweight pure Python package to check if a file is binary or text."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "binaryornot-0.6.0-py3-none-any.whl", hash = "sha256:900adfd5e1b821255ba7e63139b0396b14c88b9286e74e03b6f51e0200331337"},
{file = "binaryornot-0.6.0.tar.gz", hash = "sha256:cc8d57cfa71d74ff8c28a7726734d53a851d02fad9e3a5581fb807f989f702f0"},
]
[[package]]
name = "certifi"
version = "2026.5.20"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"},
{file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"},
]
[[package]]
name = "charset-normalizer"
version = "3.4.7"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"},
{file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"},
{file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"},
{file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"},
{file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"},
{file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"},
{file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"},
{file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"},
{file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"},
{file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"},
{file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"},
{file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"},
{file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"},
{file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"},
{file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"},
{file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"},
{file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"},
{file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"},
{file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"},
{file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"},
{file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"},
{file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"},
{file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"},
{file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"},
{file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"},
{file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"},
{file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"},
{file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"},
{file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"},
{file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"},
{file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"},
]
[[package]]
name = "click"
version = "8.4.1"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2"},
{file = "click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main"]
markers = "platform_system == \"Windows\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "cookiecutter"
version = "2.7.1"
description = "A command-line utility that creates projects from project templates, e.g. creating a Python package project from a Python package project template."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "cookiecutter-2.7.1-py3-none-any.whl", hash = "sha256:cee50defc1eaa7ad0071ee9b9893b746c1b3201b66bf4d3686d0f127c8ed6cf9"},
{file = "cookiecutter-2.7.1.tar.gz", hash = "sha256:ca7bb7bc8c6ff441fbf53921b5537668000e38d56e28d763a1b73975c66c6138"},
]
[package.dependencies]
arrow = "*"
binaryornot = ">=0.4.4"
click = ">=7.0,<9.0.0"
Jinja2 = ">=2.7,<4.0.0"
python-slugify = ">=4.0.0"
pyyaml = ">=5.3.1"
requests = ">=2.23.0"
rich = "*"
[[package]]
name = "idna"
version = "3.17"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c"},
{file = "idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f"},
]
[package.extras]
all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "jinja2"
version = "3.1.6"
description = "A very fast and expressive template engine."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
{file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
]
[package.dependencies]
MarkupSafe = ">=2.0"
[package.extras]
i18n = ["Babel (>=2.7)"]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
description = "Python port of markdown-it. Markdown parsing, done right!"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"},
{file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"},
]
[package.dependencies]
mdurl = ">=0.1,<1.0"
[package.extras]
benchmarking = ["psutil", "pytest", "pytest-benchmark"]
compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"]
linkify = ["linkify-it-py (>=1,<3)"]
plugins = ["mdit-py-plugins (>=0.5.0)"]
profiling = ["gprof2dot"]
rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"]
testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout", "requests"]
[[package]]
name = "markupsafe"
version = "3.0.3"
description = "Safely add untrusted strings to HTML/XML markup."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"},
{file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"},
{file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"},
{file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"},
{file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"},
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"},
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"},
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"},
{file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"},
{file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"},
{file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"},
{file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"},
{file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"},
{file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"},
{file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"},
{file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"},
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"},
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"},
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"},
{file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"},
{file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"},
{file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"},
{file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"},
{file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"},
{file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"},
{file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"},
{file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"},
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"},
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"},
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"},
{file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"},
{file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"},
{file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"},
{file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"},
{file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"},
{file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"},
{file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"},
{file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"},
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"},
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"},
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"},
{file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"},
{file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"},
{file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"},
{file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"},
{file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"},
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"},
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"},
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"},
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"},
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"},
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"},
{file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"},
{file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"},
{file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"},
{file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"},
{file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"},
{file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"},
{file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"},
{file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"},
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"},
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"},
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"},
{file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"},
{file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"},
{file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"},
{file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"},
{file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"},
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"},
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"},
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"},
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"},
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"},
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"},
{file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"},
{file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"},
{file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"},
{file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"},
{file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"},
{file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"},
{file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"},
{file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"},
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"},
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"},
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"},
{file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"},
{file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"},
{file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"},
{file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"},
]
[[package]]
name = "mdurl"
version = "0.1.2"
description = "Markdown URL utilities"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
]
[[package]]
name = "pygments"
version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
description = "Extensions to the standard Python datetime module"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
groups = ["main"]
files = [
{file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
{file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
]
[package.dependencies]
six = ">=1.5"
[[package]]
name = "python-slugify"
version = "8.0.4"
description = "A Python slugify application that also handles Unicode"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856"},
{file = "python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8"},
]
[package.dependencies]
text-unidecode = ">=1.3"
[package.extras]
unidecode = ["Unidecode (>=1.1.1)"]
[[package]]
name = "pyyaml"
version = "6.0.3"
description = "YAML parser and emitter for Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"},
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"},
{file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"},
{file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"},
{file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"},
{file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"},
{file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"},
{file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"},
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"},
{file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"},
{file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"},
{file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"},
{file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"},
{file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"},
{file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"},
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"},
{file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"},
{file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"},
{file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"},
{file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"},
{file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"},
{file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"},
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"},
{file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"},
{file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"},
{file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"},
{file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"},
{file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"},
{file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"},
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"},
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"},
{file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"},
{file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"},
{file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"},
{file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"},
{file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"},
{file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"},
{file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"},
{file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"},
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"},
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"},
{file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"},
{file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"},
{file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"},
{file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"},
{file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"},
{file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"},
{file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"},
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"},
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"},
{file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"},
{file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"},
{file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"},
{file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"},
{file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"},
{file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"},
{file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"},
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"},
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"},
{file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"},
{file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"},
{file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"},
{file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"},
{file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"},
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
]
[[package]]
name = "requests"
version = "2.34.2"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"},
{file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"},
]
[package.dependencies]
certifi = ">=2023.5.7"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.26,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
[[package]]
name = "rich"
version = "15.0.0"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false
python-versions = ">=3.9.0"
groups = ["main"]
files = [
{file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"},
{file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"},
]
[package.dependencies]
markdown-it-py = ">=2.2.0"
pygments = ">=2.13.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "six"
version = "1.17.0"
description = "Python 2 and 3 compatibility utilities"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
groups = ["main"]
files = [
{file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"},
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
]
[[package]]
name = "text-unidecode"
version = "1.3"
description = "The most basic Text::Unidecode port"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"},
{file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"},
]
[[package]]
name = "tzdata"
version = "2026.2"
description = "Provider of IANA time zone data"
optional = false
python-versions = ">=2"
groups = ["main"]
files = [
{file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"},
{file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"},
]
[[package]]
name = "urllib3"
version = "2.7.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"},
{file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"},
]
[package.extras]
brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<4"
content-hash = "91682122cf39ed4225e7166c2fdcf2f47ab587d2084d2f0ce4fa89dfb824acdf"

View File

@@ -1,24 +1,88 @@
[tool.poetry]
[project]
name = "openbb-cookiecutter"
version = "0.6.0"
description = "Extensions template for the OpenBB Python Package."
license = "Apache-2.0"
authors = ["OpenBB Team <hello@openbb.co>"]
packages = [{ include = "openbb_cookiecutter" }]
authors = [{ name = "OpenBB Team", email = "hello@openbb.co" }]
license = { text = "Apache-2.0" }
readme = "README.md"
homepage = "https://openbb.co"
repository = "https://github.com/OpenBB-finance/openbb-cookiecutter"
requires-python = ">=3.10,<4"
dependencies = [
"cookiecutter>=2.6.0",
"rich>=13.0.0",
]
[tool.poetry.dependencies]
python = ">=3.10,<4"
cookiecutter = "^2.6.0"
[tool.poetry.scripts]
[project.scripts]
openbb-cookiecutter = "openbb_cookiecutter.cli:main"
[tool.poetry.plugins."cookiecutter.templates"]
[project.entry-points."cookiecutter.templates"]
openbb = "openbb_cookiecutter"
[project.urls]
Homepage = "https://openbb.co"
Repository = "https://github.com/OpenBB-finance/OpenBB"
[dependency-groups]
dev = ["openbb-devtools>=2.0.1"]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
requires = ["hatchling>=1.25"]
build-backend = "hatchling.build"
[tool.hatch.build]
exclude = [
"**/__pycache__",
"**/*.pyc",
"**/.ruff_cache",
"**/.pytest_cache",
"**/.ipynb_checkpoints",
"**/.DS_Store",
]
[tool.hatch.build.targets.wheel]
packages = ["openbb_cookiecutter"]
[tool.hatch.build.targets.sdist]
include = ["openbb_cookiecutter", "tests", "README.md", "pyproject.toml"]
[tool.uv.sources]
openbb-devtools = { path = "../openbb_platform/extensions/devtools", editable = true }
[tool.ruff]
line-length = 88
target-version = "py310"
fix = true
extend-exclude = ["openbb_cookiecutter/template"]
[tool.ruff.lint]
select = ["D", "E", "W", "F", "S", "UP", "I", "PLC", "PLE", "PLR", "PLW", "SIM", "T20"]
ignore = [
"PLR2004", "PLR0913", "PLR0915", "PLR0917", "PLC0415",
"E402", "E501",
"D202", "D203", "D205", "D206", "D210", "D212", "D213", "D214",
"D215", "D300", "D400", "D404", "D405", "D406", "D407", "D408",
"D409", "D410", "D411", "D413", "D415", "D416", "D417",
]
[tool.ruff.lint.per-file-ignores]
"openbb_cookiecutter/cli.py" = ["T201"]
"tests/**" = ["S101", "S603", "S607", "D"]
"**/__init__.py" = ["F401"]
[tool.ruff.lint.isort]
combine-as-imports = true
force-wrap-aliases = true
[tool.ruff.lint.pydocstyle]
convention = "numpy"
[tool.ty]
[tool.ty.environment]
python-version = "3.10"
[tool.ty.src]
include = ["openbb_cookiecutter"]
exclude = ["openbb_cookiecutter/template", "tests"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers"

View File

@@ -0,0 +1 @@
"""openbb-cookiecutter tests."""

View File

@@ -0,0 +1,50 @@
"""Tests for the openbb-cookiecutter command line."""
from pathlib import Path
import pytest
from openbb_cookiecutter import cli
class TestParseExtensionTypes:
"""Extension types are validated."""
def test_valid(self):
assert cli._parse_extension_types("router, provider") == ["router", "provider"]
@pytest.mark.parametrize("value", ["", "router,bogus"])
def test_invalid(self, value):
with pytest.raises(ValueError):
cli._parse_extension_types(value)
class TestMain:
"""The command generates a project."""
def test_no_input(self, tmp_path):
code = cli.main(["--no-input", "-o", str(tmp_path), "-e", "router", "provider"])
project = tmp_path / "extension-template"
assert code == 0
assert (project / "extension_template/routers/template.py").exists()
assert (project / "extension_template/providers/template").is_dir()
assert not (project / "extension_template/obbject").exists()
def test_extra_context_requires_key_value(self, tmp_path, capsys):
code = cli.main(["--no-input", "-o", str(tmp_path), "--extra-context", "bad"])
assert code == 1
assert "KEY=VALUE" in capsys.readouterr().out
def test_generation_error_returns_nonzero(self, tmp_path, capsys):
code = cli.main(
[
"--no-input",
"-o",
str(tmp_path),
"--extra-context",
"package_name=1-bad",
]
)
assert code == 1
assert "Error:" in capsys.readouterr().err
assert not Path(tmp_path / "extension-template").exists()

View File

@@ -0,0 +1,213 @@
"""Tests for the project template."""
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
from cookiecutter.exceptions import FailedHookException
from cookiecutter.main import cookiecutter
from openbb_cookiecutter import get_template_path
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
PACKAGE = "extension_template"
def generate(tmp_path: Path, extension_types: str, **context: str) -> Path:
return Path(
cookiecutter(
str(get_template_path()),
output_dir=str(tmp_path),
no_input=True,
extra_context={"extension_types": extension_types, **context},
)
)
def files(project: Path) -> set[str]:
return {
p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file()
}
def entry_points(project: Path) -> dict:
return tomllib.loads((project / "pyproject.toml").read_text(encoding="utf-8"))[
"project"
].get("entry-points", {})
class TestGeneratedFiles:
"""Each extension type generates only its own modules and tests."""
@pytest.mark.parametrize(
("extension_types", "expected"),
[
(
"router",
{
f"{PACKAGE}/routers/template.py",
"tests/test_router.py",
},
),
(
"provider",
{
f"{PACKAGE}/providers/template/models/example.py",
f"{PACKAGE}/providers/template/models/equity_historical.py",
"tests/test_provider.py",
},
),
(
"obbject",
{
f"{PACKAGE}/obbject/template_ext/__init__.py",
"tests/test_obbject.py",
},
),
(
"on_command_output",
{
f"{PACKAGE}/obbject/template_ext/on_command_output.py",
"tests/test_on_command_output.py",
},
),
(
"charting",
{
f"{PACKAGE}/routers/template_views.py",
"tests/test_views.py",
},
),
],
)
def test_single_type(self, tmp_path, extension_types, expected):
generated = files(generate(tmp_path, extension_types))
optional = {
f"{PACKAGE}/routers/template.py",
f"{PACKAGE}/routers/template_views.py",
f"{PACKAGE}/providers/template/models/example.py",
f"{PACKAGE}/obbject/template_ext/on_command_output.py",
"tests/test_router.py",
"tests/test_views.py",
"tests/test_provider.py",
"tests/test_obbject.py",
"tests/test_on_command_output.py",
}
assert expected <= generated
assert not (optional - expected) & generated
def test_all_types(self, tmp_path):
generated = files(generate(tmp_path, "all"))
assert {
"pyproject.toml",
"README.md",
".gitignore",
f"{PACKAGE}/routers/template.py",
f"{PACKAGE}/routers/template_views.py",
f"{PACKAGE}/providers/template/__init__.py",
f"{PACKAGE}/obbject/template_ext/__init__.py",
f"{PACKAGE}/obbject/template_ext/on_command_output.py",
"tests/test_router.py",
"tests/test_views.py",
"tests/test_provider.py",
"tests/test_obbject.py",
"tests/test_on_command_output.py",
} <= generated
def test_local_artifacts_are_not_copied(self, tmp_path):
generated = files(generate(tmp_path, "all"))
artifacts = (".ruff_cache", ".ipynb_checkpoints", "__pycache__", ".DS_Store")
assert not [f for f in generated if any(a in f for a in artifacts)]
class TestPyproject:
"""The generated pyproject declares V5 packaging and entry points."""
def test_build_and_dependencies(self, tmp_path):
pyproject = tomllib.loads(
(generate(tmp_path, "all") / "pyproject.toml").read_text(encoding="utf-8")
)
assert pyproject["build-system"]["build-backend"] == "hatchling.build"
assert pyproject["project"]["dependencies"] == ["openbb-core[pandas]>=2.0.0"]
assert pyproject["project"]["optional-dependencies"] == {
"charting": ["openbb-charting>=3.0.0"]
}
assert "openbb-devtools>=2.0.1" in pyproject["dependency-groups"]["dev"]
def test_entry_points_for_all_types(self, tmp_path):
assert entry_points(generate(tmp_path, "all")) == {
"openbb_core_extension": {
"template": f"{PACKAGE}.routers.template:router",
},
"openbb_charting_extension": {
"template": f"{PACKAGE}.routers.template_views:TemplateViews",
},
"openbb_provider_extension": {
"template": f"{PACKAGE}.providers.template:template_provider",
},
"openbb_obbject_extension": {
"to_string": f"{PACKAGE}.obbject.template_ext:ext",
"template_ext": f"{PACKAGE}.obbject.template_ext:class_ext",
"nonblocking_plugin": (
f"{PACKAGE}.obbject.template_ext.on_command_output:nonblocking_plugin"
),
},
}
def test_entry_points_for_router_only(self, tmp_path):
assert entry_points(generate(tmp_path, "router")) == {
"openbb_core_extension": {
"template": f"{PACKAGE}.routers.template:router",
},
}
class TestHooks:
"""The post-generation hook validates names."""
def test_rejects_invalid_package_name(self, tmp_path):
with pytest.raises(FailedHookException):
generate(tmp_path, "router", package_name="1-bad")
def test_rejects_shared_provider_and_obbject_name(self, tmp_path):
with pytest.raises(FailedHookException):
generate(tmp_path, "all", obbject_name="template")
@pytest.mark.skipif(shutil.which("ruff") is None, reason="ruff is not installed")
class TestGeneratedCodeQuality:
"""Generated projects pass their own lint and type checks."""
@pytest.mark.parametrize(
"extension_types",
["router", "provider", "obbject", "on_command_output", "charting", "all"],
)
def test_ruff(self, tmp_path, extension_types):
project = generate(tmp_path, extension_types)
for command in (["format", "--check"], ["check", "--no-fix"]):
result = subprocess.run(
["ruff", *command, "--no-cache", "."],
cwd=project,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stdout + result.stderr
@pytest.mark.skipif(shutil.which("ty") is None, reason="ty is not installed")
def test_ty(self, tmp_path):
project = generate(tmp_path, "all")
result = subprocess.run(
["ty", "check", "--python", sys.executable, PACKAGE],
cwd=project,
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stdout + result.stderr

2143
cookiecutter/uv.lock generated vendored Normal file

File diff suppressed because it is too large Load Diff