diff --git a/.codespell.ignore b/.codespell.ignore
index e2e88f75993..b43619522b4 100644
--- a/.codespell.ignore
+++ b/.codespell.ignore
@@ -1,5 +1,6 @@
ake
alle
+alph
ba
brin
buda
diff --git a/.github/workflows/lint-openbb-cli.yml b/.github/workflows/lint-openbb-cli.yml
new file mode 100644
index 00000000000..f0c1a49aa0b
--- /dev/null
+++ b/.github/workflows/lint-openbb-cli.yml
@@ -0,0 +1,74 @@
+name: ๐งน openbb-cli linting
+
+env:
+ PIP_DEFAULT_TIMEOUT: 100
+
+on:
+ pull_request:
+ types: [opened, synchronize, edited]
+ paths:
+ - 'cli/**'
+ - '.github/workflows/lint-openbb-cli.yml'
+ merge_group:
+ types: [checks_requested]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ lint:
+ name: Lint openbb-cli
+ runs-on: ubuntu-latest
+ env:
+ UV_CACHE_DIR: ${{ github.workspace }}/.uv-cache
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ github.event.pull_request.head.ref || github.ref }}
+ repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
+ fetch-depth: 20
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Fetch base branch
+ run: git fetch --no-tags --depth=20 origin ${{ github.base_ref }}
+
+ - name: Setup Python 3.10
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.10"
+ architecture: x64
+
+ - name: Cache uv (built wheels)
+ uses: actions/cache@v4
+ with:
+ path: ${{ github.workspace }}/.uv-cache
+ key: uv-${{ runner.os }}-py3.10-lint-${{ hashFiles('cli/pyproject.toml', 'cli/uv.lock', 'openbb_platform/core/pyproject.toml', 'openbb_platform/obbject_extensions/charting/pyproject.toml', 'openbb_platform/extensions/devtools/pyproject.toml') }}
+ restore-keys: |
+ uv-${{ runner.os }}-py3.10-lint-
+
+ - name: Get changed CLI files for PR
+ if: github.event_name == 'pull_request'
+ run: |
+ echo "diff_files=$(git diff --diff-filter=d --name-only origin/${{ github.base_ref }}...${HEAD_REF} | grep -E '^cli/.*\.py$' | grep -v 'integration' | grep -v 'tests' | xargs)" >> $GITHUB_ENV
+ env:
+ HEAD_REF: ${{ github.head_ref }}
+
+ - name: Install openbb-cli
+ working-directory: cli
+ run: |
+ python -m pip install --upgrade pip uv
+ python -m uv pip install --system -e ".[charting]" --group dev
+
+ - name: Run ruff format / ruff check / ty on changed CLI files
+ run: |
+ if [ -n "${{ env.diff_files }}" ]; then
+ ruff format --check ${{ env.diff_files }}
+ ruff check ${{ env.diff_files }}
+ ty check ${{ env.diff_files }}
+ else
+ echo "No Python files changed in cli"
+ fi
diff --git a/.github/workflows/general-linting.yml b/.github/workflows/lint-openbb-platform.yml
similarity index 89%
rename from .github/workflows/general-linting.yml
rename to .github/workflows/lint-openbb-platform.yml
index afaf5ebade0..4affc68d0be 100644
--- a/.github/workflows/general-linting.yml
+++ b/.github/workflows/lint-openbb-platform.yml
@@ -6,15 +6,12 @@ env:
on:
pull_request:
types: [opened, synchronize, edited]
- # push:
- # branches:
- # - "feature/*"
- # - "hotfix/*"
- # - "release/*"
+ paths:
+ - 'openbb_platform/**'
+ - '.github/workflows/general-linting.yml'
merge_group:
types: [checks_requested]
-# Cancel previous runs that are not yet completed.
concurrency:
group: ${{ github.event_name }}-${{ github.repository }}-${{ github.ref }}
cancel-in-progress: true
@@ -47,7 +44,7 @@ jobs:
if: github.event_name == 'pull_request'
run: |
# "Checking PR diff"
- echo "diff_files=$(git diff --diff-filter=d --name-only origin/${{ github.base_ref }}...${HEAD_REF} | grep -E '^(openbb_platform|cli)/.*\.py$' | grep -v 'openbb_platform/core/openbb/package' | grep -v 'integration' | grep -v 'tests' | xargs)" >> $GITHUB_ENV
+ echo "diff_files=$(git diff --diff-filter=d --name-only origin/${{ github.base_ref }}...${HEAD_REF} | grep -E '^openbb_platform/.*\.py$' | grep -v 'openbb_platform/core/openbb/package' | grep -v 'integration' | grep -v 'tests' | xargs)" >> $GITHUB_ENV
echo $diff_files
env:
@@ -81,13 +78,13 @@ jobs:
done
- run: codespell --ignore-words=.codespell.ignore --skip="$(tr '\n' ',' < .codespell.skip | sed 's/,$//')" --quiet-level=2
- run: |
- # Run linters for openbb_platform | cli
+ # Run linters for openbb_platform
if [ -n "${{ env.diff_files }}" ]; then
ruff format --check ${{ env.diff_files }}
ruff check ${{ env.diff_files }}
ty check ${{ env.diff_files }}
else
- echo "No Python files changed in openbb_platform | cli"
+ echo "No Python files changed in openbb_platform"
fi
markdown-link-check:
diff --git a/.github/workflows/test-unit-cli.yml b/.github/workflows/test-unit-cli.yml
index dd81b60a4a4..e77cc8795ee 100644
--- a/.github/workflows/test-unit-cli.yml
+++ b/.github/workflows/test-unit-cli.yml
@@ -1,48 +1,121 @@
-name: ๐ฅ๏ธ Unit test CLI
+name: ๐ฅ๏ธ openbb-cli
on:
pull_request:
branches:
- develop
+ - v5
paths:
- 'cli/**'
+ - '.github/workflows/test-unit-cli.yml'
+ push:
+ branches:
+ - develop
+ - v5
+ paths:
+ - 'cli/**'
+ - '.github/workflows/test-unit-cli.yml'
+ merge_group:
+ types: [checks_requested]
+ workflow_dispatch:
concurrency:
- group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
- cancel-in-progress: true
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+defaults:
+ run:
+ shell: bash
+ working-directory: cli
jobs:
- unit_tests:
- runs-on: ubuntu-latest
+ test:
+ name: Test (${{ matrix.os }} / Python ${{ matrix.python_version }})
+ runs-on: ${{ matrix.os }}
+ env:
+ OPENBB_ALLOW_ON_COMMAND_OUTPUT: "True"
+ OPENBB_ALLOW_MUTABLE_EXTENSIONS: "True"
+ UV_CACHE_DIR: ${{ github.workspace }}/.uv-cache
permissions:
contents: read
strategy:
- fail-fast: false
-
- matrix:
- python_version:
- ["3.10", "3.11", "3.12", "3.13", "3.14"]
+ fail-fast: false
+ matrix:
+ include:
+ # Linux: full sweep across every supported Python.
+ - { os: ubuntu-latest, python_version: "3.10" }
+ - { os: ubuntu-latest, python_version: "3.11" }
+ - { os: ubuntu-latest, python_version: "3.12" }
+ - { os: ubuntu-latest, python_version: "3.13" }
+ - { os: ubuntu-latest, python_version: "3.14" }
+ # macOS and Windows: min and max supported.
+ - { os: macos-latest, python_version: "3.10" }
+ - { os: macos-latest, python_version: "3.14" }
+ - { os: windows-latest, python_version: "3.10" }
+ - { os: windows-latest, python_version: "3.14" }
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
- ref: ${{ github.event.pull_request.head.sha }}
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+
- name: Install Python ${{ matrix.python_version }}
uses: actions/setup-python@v6
with:
- python-version: ${{ matrix.python_version }}
- allow-prereleases: true
- cache: "pip"
+ python-version: ${{ matrix.python_version }}
+ allow-prereleases: true
+ cache: "pip"
- - name: Cache pip packages
- uses: actions/cache@v5
+ - name: Cache uv (built wheels โ scipy/numpy/pandas source builds)
+ uses: actions/cache@v4
with:
- path: ~/.cache/pip
- key: ${{ runner.os }}-pip-${{ matrix.python_version }}-${{ hashFiles('cli/poetry.lock') }}
+ path: ${{ github.workspace }}/.uv-cache
+ key: uv-${{ runner.os }}-py${{ matrix.python_version }}-${{ hashFiles('cli/pyproject.toml', 'cli/uv.lock', 'openbb_platform/core/pyproject.toml', 'openbb_platform/obbject_extensions/charting/pyproject.toml', 'openbb_platform/extensions/devtools/pyproject.toml') }}
restore-keys: |
- ${{ runner.os }}-pip-
+ uv-${{ runner.os }}-py${{ matrix.python_version }}-
- - name: Run tests
+ - name: Install gfortran + OpenBLAS (macOS, prerelease Python โ scipy source build)
+ if: runner.os == 'macOS' && matrix.python_version == '3.15'
+ shell: bash
+ working-directory: .
run: |
- pip install nox
- nox -f .github/scripts/noxfile.py -s unit_test_cli --python ${{ matrix.python_version }}
+ set -euo pipefail
+ brew install gcc openblas pkg-config
+ GFORTRAN_BIN=$(ls /opt/homebrew/opt/gcc/bin/gfortran-* | tail -1)
+ GFORTRAN_DIR=$(dirname "$GFORTRAN_BIN")
+ ln -sf "$GFORTRAN_BIN" "$GFORTRAN_DIR/gfortran"
+ echo "$GFORTRAN_DIR" >> "$GITHUB_PATH"
+ "$GFORTRAN_DIR/gfortran" --version
+ OPENBLAS_PC="$(brew --prefix openblas)/lib/pkgconfig"
+ echo "PKG_CONFIG_PATH=$OPENBLAS_PC" >> "$GITHUB_ENV"
+ PKG_CONFIG_PATH="$OPENBLAS_PC" pkg-config --modversion openblas
+
+ - name: Install gfortran (Linux, prerelease Python โ scipy source build)
+ if: runner.os == 'Linux' && matrix.python_version == '3.15'
+ shell: bash
+ working-directory: .
+ run: |
+ set -euo pipefail
+ sudo apt-get update -y
+ sudo apt-get install -y gfortran libopenblas-dev liblapack-dev
+ gfortran --version
+
+ - name: Install openbb-cli
+ run: |
+ python -m pip install --upgrade pip uv
+ python -m uv pip install --system -e ".[charting]" --group dev
+
+ - name: Run unit tests
+ run: |
+ python -m pytest tests \
+ --cov=openbb_cli \
+ --cov-config=.coveragerc \
+ --cov-report=term-missing \
+ --cov-report=xml:coverage-unit.xml
+
+ - name: Upload unit-test coverage artifact
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: openbb-cli-coverage-unit-${{ matrix.os }}-py${{ matrix.python_version }}
+ path: cli/coverage-unit.xml
diff --git a/cli/.coveragerc b/cli/.coveragerc
new file mode 100644
index 00000000000..6193410a38e
--- /dev/null
+++ b/cli/.coveragerc
@@ -0,0 +1,25 @@
+[run]
+branch = False
+parallel = True
+source = openbb_cli
+data_file = .coverage
+omit =
+ openbb_cli/cli.py
+ openbb_cli/assets/**
+ tests/*
+ integration/*
+
+[paths]
+source =
+ openbb_cli
+ */site-packages/openbb_cli
+ */cli/openbb_cli
+
+[report]
+show_missing = True
+skip_covered = False
+exclude_lines =
+ pragma: no cover
+ if TYPE_CHECKING:
+ if __name__ == .__main__.:
+ raise NotImplementedError
diff --git a/cli/README.md b/cli/README.md
index 2415d1fb0dc..a01bee63f5e 100644
--- a/cli/README.md
+++ b/cli/README.md
@@ -1,41 +1,353 @@
-# OpenBB Platform CLI
+
+
+
+
+
-[](https://pepy.tech/project/openbb)
-[](https://github.com/OpenBB-finance/OpenBB)
-
-| OpenBB is committed to build the future of investment research by focusing on an open source infrastructure accessible to everyone, everywhere. |
-| :---------------------------------------------------------------------------------------------------------------------------------------------: |
-|  |
-| Check our website at [openbb.co](www.openbb.co) |
+# ODP Command-Line Interface
## Overview
-The OpenBB Platform CLI is a command line interface that wraps [OpenBB Platform](https://docs.openbb.co/platform).
+`openbb-cli` is a command-line interface for the [OpenBB Platform](https://docs.openbb.co/platform) and any other OpenAPI 3.x server. It runs in three modes:
-It offers a convenient way to interact with the OpenBB Platform and its extensions, as well as automated data collection via OpenBB Routine Scripts.
+* **Non-TTY one-shot** (default) โ `openbb [--key value]` dispatches one command, prints a JSON line, exits. The form CI tools and agents reach for.
+* **Batch** โ `openbb --batch` reads NDJSON requests from stdin, fans them out concurrently, writes NDJSON responses to stdout.
+* **Interactive REPL** โ `openbb -i` drops into a rich-output prompt with auto-completion, menu navigation, and routine playback.
-Find the most complete documentation, examples, and usage guides for the OpenBB Platform CLI [here](https://docs.openbb.co/cli).
+Backends are pluggable: dispatch through an in-process `obb` namespace, an `openbb-platform-api` HTTP server, or any OpenAPI 3.x server (e.g. `https://api.congress.gov`) without touching code.
+
+The full user docs live at **[docs.openbb.co/odp/cli](https://docs.openbb.co/odp/cli)**.
+
+## Backends
+
+Four sources for the command surface. Three are OpenBB-aware; the fourth is the generic OpenAPI 3.x fallback.
+
+| Source | Flag | Cold start | When to use |
+|--------|------|------------|-------------|
+| **In-process `obb`** | _(default)_ | slow (`import openbb` ~2s) | Local script with `openbb` already installed; no separate process to manage. |
+| **OpenBB Platform server** | `--server URL` | fast after first fetch | Long-running shared server (`openbb-platform-api`) โ many CLI invocations or many users amortize the import cost. |
+| **OpenBB `.spec` file** | `--spec PATH` | fastest (~50ms) | Ship the spec next to scripts/agents; skips OpenAPI fetch + parse on every call. Generate with `openbb --generate-spec --server URL -o file.spec`. |
+| **Arbitrary OpenAPI 3.x server** | `--server URL` (non-OpenBB host) | depends on host | Any external API with an OpenAPI document โ Congress.gov, NY Fed, USDA, NWS, your own FastAPI service. |
+
+The first three speak the same OpenBB Platform surface (the `OBBject` envelope, providers, the `obb.reference` menu tree). The fourth treats whatever the upstream publishes as the truth โ same dispatcher, same parser, same auth, just no OpenBB-specific affordances.
+
+### What's different about OpenBB upstreams
+
+Multi-provider Pydantic models and the `OBBject` envelope are OpenBB Platform conventions, not OpenAPI ones. The CLI detects an OpenBB endpoint by the presence of a `provider` discriminator parameter on the operation and turns on extra behavior:
+
+| Feature | OpenBB upstream | Generic OpenAPI upstream |
+|---------|-----------------|--------------------------|
+| Response envelope | `OBBject` โ `id`, `results`, `provider`, `warnings`, `chart`, `extra` | Whatever the server returns |
+| `--describe COMMAND` | Groups parameters + result schema **per provider** | Flat `{parameters, output_schema}` |
+| `--describe COMMAND:PROVIDER` | Returns just that provider's slice | Suffix ignored |
+| Per-provider argparse narrowing | `--provider X` filters accepted flags; passing a flag from another provider errors at parse time | Every declared flag accepted |
+| Help text per provider | `(provider: โฆ)` annotations stripped; only sections relevant to the chosen provider | Description shown verbatim |
+| REPL menu tree | Mirrors `obb.reference` (router descriptions, command groupings) | Built from URL path prefixes |
+| Boolean flags | `--flag` / `--no-flag` toggle (OpenBB exposes `default: true` booleans) | Same โ applies to any spec |
+
+Everything else โ `--list-commands`, `--describe COMMAND`, `--batch`, headers/query auth, spec generation, REPL playback โ works identically against both kinds of upstream.
## Installation
-The command below provides access to all the available OpenBB extensions behind the OpenBB Platform, find the complete list [here](https://my.openbb.co/app/platform/extensions).
-
```bash
pip install openbb-cli
```
-> Note: Find the most complete installation hints and tips [here](https://docs.openbb.co/cli/installation).
-
-After the installation is complete, you can deploy the OpenBB Platform CLI by running the following command:
+Optional extras:
```bash
-openbb
+pip install "openbb-cli[charting]" # Plotly + openbb-charting
+pip install "openbb-cli[interactive]" # PyWry browser-based tables
+pip install "openbb-cli[all]" # everything
```
-Which should result in the following output:
+## Quick start
-
+### One-shot dispatch
+
+```bash
+# Against the local in-process obb namespace (the historical default)
+openbb economy.gdp --provider oecd --limit 5
+
+# Against any OpenAPI server โ auto-detects spec layout, $ref-resolves params,
+# auto-extracts HTML-embedded specs (e.g. Congress.gov)
+openbb --server http://127.0.0.1:6900 equity.price.historical --symbol AAPL --provider fmp
+openbb --server https://api.congress.gov bill --limit 5
+```
+
+### Spec files for instant cold-start
+
+```bash
+# Generate once; ship alongside the script that invokes the CLI
+openbb --generate-spec --server https://api.congress.gov --output congress.spec
+
+# Subsequent calls skip the OpenAPI fetch + parse on every invocation
+openbb --spec congress.spec law --congress 119 --limit 5
+```
+
+### Generate an installable OpenBB extension from a spec
+
+A `.spec` file is enough to dispatch commands directly. Going one step further, `--generate-extension` turns that spec into a full installable OpenBB Platform extension package โ `Provider(...)` + `Fetcher` classes + a router that mirrors the upstream's namespace tree โ that registers with `openbb-build` like any first-party extension. After install, every command shows up on the typed `obb.*` surface (auto-completion, `obb.reference`, `OBBject` envelopes, the works).
+
+End-to-end with the NY Fed Markets API:
+
+```bash
+# 1. Snapshot the OpenAPI surface as a spec file. NY Fed publishes its
+# spec at a non-default path, so point --openapi-path at the YAML.
+openbb --generate-spec \
+ --server https://markets.newyorkfed.org \
+ --openapi-path /static/docs/markets-api.yml \
+ --output nyfed.spec
+# wrote 55 commands to nyfed.spec
+
+# 2. Generate a complete extension project from that spec
+openbb --generate-extension \
+ --spec nyfed.spec \
+ --provider-name nyfed \
+ --output ./openbb-nyfed
+# wrote project to ./openbb-nyfed/openbb-nyfed
+# providers (1): nyfed
+# routers (10): ambs, fxs, guidesheets, marketshare, pd, rates, rp, seclending, soma, tsy
+# fetchers: 55 GET (across all providers)
+# POST: 0 local-compute commands
+# install: pip install -e ./openbb-nyfed/openbb-nyfed
+# build: openbb-build
+
+# 3. Install + register with the OpenBB Platform
+pip install -e ./openbb-nyfed/openbb-nyfed
+openbb-build
+
+# 4. The new namespaces are live on the typed obb surface
+python -c "from openbb import obb; print(obb.nyfed.rates.all.latest().to_df())"
+```
+
+(`--openapi-path` is only needed when the upstream doesn't expose `/openapi.json` directly. For servers that do โ most FastAPI deployments, Congress.gov via the embedded-spec scraper โ drop the flag.)
+
+What `--generate-extension` produces:
+
+```
+openbb-nyfed/
+โโโ pyproject.toml # PEP 621 / Hatchling โ every provider + router declared as entry points
+โโโ README.md
+โโโ openbb_nyfed/
+ โโโ providers/
+ โ โโโ nyfed/
+ โ โโโ __init__.py # nyfed_provider = Provider(name="nyfed", fetcher_dict={...}, credentials=[...])
+ โ โโโ models/.py # one Fetcher class per command (QueryParams + Data + Fetcher)
+ โโโ routers/
+ โโโ rates.py # @router.command(model="RatesAllLatest") โ typed signature
+ โโโ ambs.py
+ โโโ ... # one router per top-level namespace; sub-routers nest via include_router(prefix="/sub")
+```
+
+Every flag is optional except `--spec`:
+
+| Flag | Default | What it controls |
+|------|---------|------------------|
+| `--output PATH` | _required_ | Project root directory written to disk. |
+| `--provider-name NAME` | derived from `--output` basename | Snake-case provider identifier โ drives credential lookup keys (`credentials.get(f"{provider}_api_key")`). |
+| `--project-name NAME` | `openbb-` | PyPI distribution name in `pyproject.toml`. Use dashes. |
+| `--package-name NAME` | `openbb_` | Snake-case Python package name. |
+| `--router-name NAME` | `` | Top-level router identifier. |
+
+Bring-your-own-key APIs: parameter names that look like credentials (`api_key`, `apikey`, `app_token`, `X-API-Key`, `Authorization`, `client_secret`, `bearer_token`, โฆ) are auto-promoted to the provider's `credentials=[...]` list. After install, `openbb` reads them through the standard user-settings flow (`OBB_USER_SETTINGS` / `~/.openbb_platform/user_settings.json`) โ no per-call flag needed.
+
+### Multi-spec โ combine APIs under one CLI
+
+Repeat `--spec NAME=PATH` to mount more than one spec at once. Each spec's commands get prefixed with its namespace, and each backend keeps its own `base_url`, headers, and query params:
+
+```bash
+openbb \
+ --spec congress=congress.spec \
+ --spec nyfed=nyfed.spec \
+ congress.bill --limit 5
+
+openbb \
+ --spec congress=congress.spec \
+ --spec nyfed=nyfed.spec \
+ nyfed.markets.ambs --operation rates
+```
+
+Scope auth per namespace by prefixing the flag value with `:`:
+
+```bash
+openbb \
+ --spec congress=congress.spec \
+ --spec usda=usda.spec \
+ -Q congress:api_key="$CONGRESS_KEY" \
+ -Q usda:api_key="$USDA_KEY" \
+ -H usda:Authorization="Bearer $USDA_BEARER" \
+ congress.bill --limit 5
+```
+
+Tokens without a namespace prefix (`-H Authorization=...`, `-Q api_key=...`) apply to every backend; namespace-scoped tokens override on conflict for that one backend only.
+
+The same shape works in TOML, which is the better fit when scripts reach for the same set repeatedly:
+
+```toml
+[specs.congress]
+path = "/path/congress.spec"
+[specs.congress.query]
+api_key = "..."
+
+[specs.usda]
+path = "/path/usda.spec"
+[specs.usda.headers]
+Authorization = "Bearer ..."
+[specs.usda.query]
+api_key = "..."
+```
+
+`--list-commands` aggregates across every namespace; `--describe NAMESPACE.command[:provider]` resolves to the right backend automatically.
+
+A single unnamed `--spec PATH` keeps the flat (unprefixed) command surface โ backward-compatible with the historical single-spec form.
+
+### Auth
+
+Pick whichever the upstream API uses:
+
+```bash
+# Headers (Authorization, X-API-Key, ...)
+openbb --server URL -H "Authorization: Bearer xxx" -H "X-Tenant: acme" cmd
+
+# Query-string params (e.g. ?api_key=... like Congress.gov, USDA, NWS)
+openbb --server URL -Q api_key=xxx cmd
+# or via env โ OPENBB_HTTP_QUERY_ auto-injected
+OPENBB_HTTP_QUERY_API_KEY=xxx openbb --server URL cmd
+```
+
+#### Auth hooks (RBAC, token refresh, dynamic credentials)
+
+Static headers and query params cover the common case. For RBAC, expiring tokens, or per-user credentials sourced from a vault, register an importable callable as an auth hook. Configured in TOML by `module:attribute` path โ global, or per `[specs.]`:
+
+```toml
+auth-hook = "myapp.auth:default_hook" # applies to every backend
+
+[specs.congress]
+path = "/path/congress.spec"
+auth-hook = "myapp.auth:congress_hook" # overrides the global for this one
+
+[specs.internal]
+path = "/path/internal.spec"
+auth-hook = "myapp.auth:rbac_hook"
+```
+
+The hook receives an `AuthContext` (namespace, command, params, method) and returns an `AuthDecision`:
+
+```python
+# myapp/auth.py
+from openbb_cli.auth import AuthContext, AuthDecision
+from myapp.identity import current_user, get_token
+
+def rbac_hook(ctx: AuthContext) -> AuthDecision:
+ user = current_user()
+ if not user.can_access(ctx.namespace, ctx.command):
+ return AuthDecision(allow=False, deny_reason=f"{user.role} cannot call {ctx.command}")
+ return AuthDecision(headers={"Authorization": f"Bearer {get_token(user)}"})
+```
+
+Hooks may be sync or async; coroutines are awaited. Returned headers and query params merge on top of the dispatcher's static auth (hook wins on conflict). `allow=False` short-circuits the dispatch with an `AccessDenied` error response โ no network call is made.
+
+Introspection is gated by the same hook: `--list-commands` invokes it for every command and silently drops denied entries from the listing, and `--describe COMMAND` returns `AccessDenied` when the hook denies. RBAC implementations that hide endpoints therefore hide them everywhere โ discovery, schema, and dispatch all see the same surface.
+
+### Batch
+
+NDJSON request/response over stdin/stdout, concurrent dispatch:
+
+```bash
+cat <<'EOF' | openbb --spec congress.spec --batch
+{"id":"a","command":"bill","params":{"limit":5,"format":"json"}}
+{"id":"b","command":"law","params":{"congress":119,"format":"json"}}
+{"id":"c","command":"__commands__"}
+EOF
+```
+
+### Introspection
+
+```bash
+openbb --spec congress.spec --list-commands # every command + short description
+openbb --spec congress.spec --describe bill # full schema (params + response)
+```
+
+The same calls work as reserved commands in batch mode (`__commands__`, `__schema__`).
+
+### Interactive REPL
+
+```bash
+openbb -i # in-process obb backend
+openbb -i --spec congress.spec # spec-driven; no obb install required
+openbb -i --server URL # live OpenAPI fetch
+```
+
+The REPL adds menu navigation (`bill`, `..`, `home`, `exit`), tab/auto-completion against parser flags, `--help` per command, the OBBject registry, and routine playback (`exe --file ...`).
+
+## Configuration
+
+Layered, all optional. Resolution order (lowest โ highest priority):
+
+1. Built-in defaults
+2. `[tool.openbb-cli]` in nearest ancestor `pyproject.toml`
+3. `~/.openbb_platform/openbb.toml` (user-global; same dir as `user_settings.json`)
+4. `./openbb.toml` (project-local, walks up from CWD)
+5. `--config PATH` (or `OPENBB_CLI_CONFIG`)
+6. `~/.openbb_platform/.env` and `--env-file PATH`
+7. `OPENBB_*` shell exports
+8. CLI flags
+
+Bootstrap a documented template:
+
+```bash
+openbb --print-config-template > ~/.openbb_platform/openbb.toml
+```
+
+Inspect what's currently active across all layers:
+
+```bash
+openbb --show-config
+```
+
+Schema:
+
+```toml
+# Backend / dispatch โ pick one of these forms
+server = "https://api.congress.gov" # live OpenAPI fetch
+spec = "/path/to/api.spec" # single spec (flat surface)
+batch-concurrency = 16
+
+# Multi-spec: each table mounts under its own namespace
+[specs.congress]
+path = "/path/congress.spec"
+[specs.congress.query]
+api_key = "..."
+
+[specs.usda]
+path = "/path/usda.spec"
+[specs.usda.headers]
+Authorization = "Bearer ..."
+[specs.usda.query]
+api_key = "..."
+
+# REPL display preferences (top-level shortcuts)
+output-mode = "rich" # rich | json | tsv | html
+flair = ":fox_face"
+timezone = "America/New_York"
+rich-style = "dark"
+
+[headers] # global โ applied to every backend
+Authorization = "Bearer ..."
+
+[query] # global โ applied to every backend
+api_key = "..."
+
+[settings]
+# Every other Settings field โ same surface as the /settings/ REPL menu
+allowed-number-of-rows = 50
+use-prompt-toolkit = true
+toolbar-hint = false
+```
## Documentation
-View the user documentation for this package [here](https://docs.openbb.co/cli)
+Full user documentation: **[docs.openbb.co/odp/cli](https://docs.openbb.co/odp/cli)**
+
+API reference: [docs.openbb.co/platform](https://docs.openbb.co/platform)
diff --git a/cli/conftest.py b/cli/conftest.py
new file mode 100644
index 00000000000..faca3bdd98d
--- /dev/null
+++ b/cli/conftest.py
@@ -0,0 +1,276 @@
+"""Session-scoped fixture that builds & installs a synthetic OpenBB extension.
+
+Every test under ``cli/`` (both ``cli/tests/`` and ``cli/integration/``) runs
+against the same generated static-package surface. The fixture writes a
+self-contained extension distribution to disk, ``pip install``s it into the
+current interpreter, and runs ``openbb-build`` so the static package under
+``openbb_platform/core/openbb/package/`` is regenerated with the test
+extension's commands baked in.
+
+Modeled directly on ``openbb_platform/core/integration/conftest.py``; reuses
+the same snapshot-and-restore approach so the production package dir is
+returned to its pre-test state.
+
+Because the static package is wired via entry points discovered at
+*interpreter start*, tests that import ``openbb`` after the build must do so
+in a fresh ``python`` subprocess. The ``run_in_obb`` helper does exactly
+that.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import json
+import os
+import shutil
+import subprocess
+import sys
+import textwrap
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+CORE_DIR = REPO_ROOT / "openbb_platform" / "core"
+OPENBB_DIR = CORE_DIR / "openbb"
+PACKAGE_DIR = OPENBB_DIR / "package"
+ASSETS_DIR = OPENBB_DIR / "assets"
+
+EXT_NAME = "openbb-cli-fake"
+EXT_PKG = "openbb_cli_fake"
+
+
+PYPROJECT = """\
+[project]
+name = "{ext_name}"
+version = "0.0.1"
+requires-python = ">=3.10,<4"
+dependencies = ["openbb-core"]
+
+[project.entry-points."openbb_core_extension"]
+cli_test = "{pkg}.router:router"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["{pkg}"]
+"""
+
+INIT_PY = '''\
+"""Synthetic extension exposing deterministic commands for openbb-cli tests."""
+'''
+
+ROUTER_PY = '''\
+"""Router for the openbb-cli synthetic test extension.
+
+Exposes a small surface under ``obb.cli_test.*`` so the CLI's dispatcher,
+controller, and output-adapter tests can resolve real commands through the
+production ``obb`` namespace โ same code path as installed extensions.
+"""
+
+from typing import Any
+
+from openbb_core.app.model.obbject import OBBject
+from openbb_core.app.router import Router
+
+router = Router(prefix="", description="openbb-cli synthetic test extension.")
+
+
+@router.command(methods=["GET"])
+async def echo(value: str = "hi") -> OBBject[dict[str, Any]]:
+ """Return the input wrapped in an OBBject โ sync-style payload."""
+ return OBBject(results={"echo": value})
+
+
+@router.command(methods=["GET"])
+async def quote(symbol: str) -> OBBject[dict[str, Any]]:
+ """Return a deterministic quote-shaped payload, async-style."""
+ return OBBject(results={"symbol": symbol, "async": True})
+
+
+@router.command(methods=["GET"])
+async def bomb() -> OBBject[dict[str, Any]]:
+ """Raise to exercise dispatcher error isolation."""
+ raise RuntimeError("boom")
+
+
+@router.command(methods=["GET"])
+async def rows(n: int = 3) -> OBBject[list[dict[str, Any]]]:
+ """Return a deterministic list payload โ exercises tabular output adapters."""
+ return OBBject(results=[{"i": i, "v": i * 2} for i in range(n)])
+'''
+
+
+def _snapshot_dir(src: Path, dst: Path) -> None:
+ if src.exists():
+ shutil.copytree(src, dst, dirs_exist_ok=True)
+
+
+def _restore_dir(src: Path, dst: Path) -> None:
+ shutil.rmtree(dst, ignore_errors=True)
+ if src.exists():
+ shutil.copytree(src, dst)
+
+
+def _pip(*args: str) -> None:
+ subprocess.run( # noqa: S603
+ [sys.executable, "-m", "pip", *args],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+
+
+def _subprocess_env(extra: dict[str, str] | None = None) -> dict[str, str]:
+ """Build a subprocess env that opts into coverage.py auto-startup if available.
+
+ Strips ``COV_CORE_*`` so the child does not try to bootstrap via
+ ``pytest_cov.embed.init()`` (would race with ``coverage.process_startup``
+ and write into the parent's locked data file).
+ """
+ env = {k: v for k, v in os.environ.items() if not k.startswith("COV_CORE_")}
+ rcfile = Path(__file__).resolve().parent / ".coveragerc"
+ if rcfile.exists():
+ env["COVERAGE_PROCESS_START"] = str(rcfile)
+ if extra:
+ env.update(extra)
+ return env
+
+
+def _run_openbb_build() -> None:
+ """Trigger ``openbb-build`` via auto-build on first import in a fresh subprocess.
+
+ A child process is required because ``ExtensionLoader``/``RouterLoader``
+ cache entry points at module load time; the parent process cannot
+ discover the freshly-installed distribution without a restart.
+ """
+ env = _subprocess_env({"OPENBB_AUTO_BUILD": "true"})
+ subprocess.run( # noqa: S603
+ [sys.executable, "-c", "import openbb"],
+ check=True,
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+
+
+@pytest.fixture(scope="session", autouse=True)
+def cli_fake_extension_installed(tmp_path_factory):
+ """Build, install, openbb-build, yield, then uninstall and restore.
+
+ Auto-used at session scope so every test in ``cli/`` runs against the
+ same generated static package surface. The synthetic extension exposes
+ ``obb.cli_test.{echo,quote,bomb,rows}`` for dispatcher / output / controller
+ tests to resolve through the real plugin-discovery path.
+ """
+ work = tmp_path_factory.mktemp("cli_fake_ext_dist")
+ pkg_dir = work / EXT_PKG
+ pkg_dir.mkdir()
+ (pkg_dir / "__init__.py").write_text(INIT_PY, encoding="utf-8")
+ (pkg_dir / "router.py").write_text(ROUTER_PY, encoding="utf-8")
+ (work / "pyproject.toml").write_text(
+ PYPROJECT.format(ext_name=EXT_NAME, pkg=EXT_PKG), encoding="utf-8"
+ )
+
+ snapshot_root = tmp_path_factory.mktemp("openbb_snapshot")
+ pkg_snapshot = snapshot_root / "package"
+ assets_snapshot = snapshot_root / "assets"
+ _snapshot_dir(PACKAGE_DIR, pkg_snapshot)
+ _snapshot_dir(ASSETS_DIR, assets_snapshot)
+
+ try:
+ _pip("install", "--no-deps", "-e", str(work))
+ if str(work) not in sys.path:
+ sys.path.insert(0, str(work))
+ _run_openbb_build()
+
+ ext_pkg_module = PACKAGE_DIR / "cli_test.py"
+ if not ext_pkg_module.exists():
+ pytest.fail(
+ "openbb-build did not emit cli_test.py; "
+ f"contents of {PACKAGE_DIR}: "
+ f"{sorted(p.name for p in PACKAGE_DIR.glob('*.py'))}"
+ )
+
+ yield {
+ "ext_name": EXT_NAME,
+ "ext_pkg": EXT_PKG,
+ "package_dir": PACKAGE_DIR,
+ "assets_dir": ASSETS_DIR,
+ "work_dir": work,
+ }
+ finally:
+ with contextlib.suppress(subprocess.CalledProcessError):
+ _pip("uninstall", "-y", EXT_NAME)
+ with contextlib.suppress(ValueError):
+ sys.path.remove(str(work))
+ for mod_name in [m for m in sys.modules if m.startswith(EXT_PKG)]:
+ del sys.modules[mod_name]
+ _restore_dir(pkg_snapshot, PACKAGE_DIR)
+ _restore_dir(assets_snapshot, ASSETS_DIR)
+ _combine_partial_coverage()
+
+
+def _combine_partial_coverage() -> None:
+ """Merge ``.coverage..pid.`` partials and erase them.
+
+ ``parallel = True`` in ``.coveragerc`` makes every subprocess (the
+ ``_run_openbb_build`` build call and every ``run_in_obb`` snippet) write a
+ uniquely-named partial. Without ``coverage combine`` they accumulate at
+ the repo root across runs. Run combine if any partial exists; otherwise
+ no-op so suites that don't use the subprocess path stay clean.
+ """
+ here = Path(__file__).resolve().parent
+ if not list(here.glob(".coverage.*")):
+ return
+ with contextlib.suppress(subprocess.CalledProcessError, FileNotFoundError):
+ subprocess.run( # noqa: S603
+ [sys.executable, "-m", "coverage", "combine"],
+ check=False,
+ capture_output=True,
+ text=True,
+ cwd=str(here),
+ )
+ for partial in here.glob(".coverage.*"):
+ with contextlib.suppress(OSError):
+ partial.unlink()
+
+
+@pytest.fixture(scope="session")
+def run_in_obb(cli_fake_extension_installed): # noqa: ARG001
+ """Run an inline snippet under a fresh subprocess that imports ``openbb``.
+
+ Returns a callable ``run(snippet: str) -> dict``. The snippet must assign
+ a JSON-serializable value to ``RESULT``; the wrapper ``json.dumps`` it to
+ stdout and the helper parses it back into Python.
+
+ Use this for any test that needs to resolve commands via the real
+ generated static ``obb`` namespace โ entry-point caching makes
+ in-process resolution unreliable after a fresh build.
+ """
+
+ def _run(snippet: str) -> dict:
+ wrapper = textwrap.dedent("""\
+ import json, sys
+ from openbb import obb # noqa: F401
+ {snippet}
+ sys.stdout.write(json.dumps(RESULT, default=str))
+ """).format(snippet=textwrap.dedent(snippet).strip())
+ proc = subprocess.run( # noqa: S603
+ [sys.executable, "-c", wrapper],
+ check=False,
+ capture_output=True,
+ text=True,
+ env=_subprocess_env({"OPENBB_AUTO_BUILD": "false"}),
+ )
+ if proc.returncode != 0:
+ raise AssertionError(
+ f"obb subprocess failed (rc={proc.returncode})\n"
+ f"--- stdout ---\n{proc.stdout}\n"
+ f"--- stderr ---\n{proc.stderr}"
+ )
+ return json.loads(proc.stdout)
+
+ return _run
diff --git a/cli/integration/test_commands.py b/cli/integration/test_commands.py
index 517acd28eaa..be695e51f25 100644
--- a/cli/integration/test_commands.py
+++ b/cli/integration/test_commands.py
@@ -1,29 +1,38 @@
-import io
+"""Integration tests for CLI commands.
+
+The default mode is non-TTY: ``main()`` parses argv, dispatches one command,
+emits a JSON line, and exits. These tests cover that one-shot path against
+real provider commands โ they require API credentials and are gated by the
+``integration`` marker so they don't run in default CI sweeps.
+"""
+
+import shlex
import pytest
+
from openbb_cli.cli import main
@pytest.mark.parametrize(
- "input_values",
+ "command",
[
- "/equity/price/historical --symbol aapl --provider fmp",
- "/equity/price/historical --symbol msft --provider yfinance",
- "/equity/price/historical --symbol goog --provider polygon",
- "/crypto/price/historical --symbol btc --provider fmp",
- "/currency/price/historical --symbol eur --provider fmp",
- "/derivatives/futures/historical --symbol cl --provider fmp",
- "/etf/price/historical --symbol spy --provider fmp",
- "/economy",
+ "equity.price.historical --symbol aapl --provider fmp",
+ "equity.price.historical --symbol msft --provider yfinance",
+ "equity.price.historical --symbol goog --provider polygon",
+ "crypto.price.historical --symbol btc --provider fmp",
+ "currency.price.historical --symbol eur --provider fmp",
+ "derivatives.futures.historical --symbol cl --provider fmp",
+ "etf.price.historical --symbol spy --provider fmp",
],
)
@pytest.mark.integration
-def test_launch_with_cli_input(monkeypatch, input_values):
- """Test launching the CLI and providing input via stdin with multiple parameters."""
- stdin = io.StringIO(input_values)
- monkeypatch.setattr("sys.stdin", stdin)
+def test_launch_one_shot(capsys, command):
+ """``main(argv)`` dispatches the command and exits without raising.
- try:
- main()
- except Exception as e:
- pytest.fail(f"Main function raised an exception: {e}")
+ The exit code may be non-zero (e.g. missing API key) โ we only assert
+ that the entry point returns cleanly and emits a JSON line on stdout.
+ """
+ rc = main(shlex.split(command))
+ captured = capsys.readouterr()
+ assert isinstance(rc, int)
+ assert captured.out, "expected a JSON response line on stdout"
diff --git a/cli/integration/test_integration_base_controller.py b/cli/integration/test_integration_base_controller.py
index 84099ee1e84..1f69fbe27e6 100644
--- a/cli/integration/test_integration_base_controller.py
+++ b/cli/integration/test_integration_base_controller.py
@@ -3,11 +3,10 @@
from unittest.mock import Mock, patch
import pytest
+
from openbb_cli.controllers.base_controller import BaseController
from openbb_cli.session import Session
-# pylint: disable=unused-variable, redefined-outer-name
-
class DummyController(BaseController):
"""Test controller for the BaseController."""
@@ -40,11 +39,11 @@ def test_check_path_valid(base_controller):
def test_check_path_invalid(base_controller):
"""Test that check_path raises an error for an invalid path."""
with pytest.raises(ValueError):
- base_controller.PATH = "invalid_path" # Missing leading '/'
+ base_controller.PATH = "invalid_path"
base_controller.check_path()
with pytest.raises(ValueError):
- base_controller.PATH = "/invalid_path" # Missing trailing '/'
+ base_controller.PATH = "/invalid_path"
base_controller.check_path()
@@ -52,10 +51,10 @@ def test_check_path_invalid(base_controller):
def test_parse_input(base_controller):
"""Test the parse_input method."""
input_str = "/equity/price/help"
- expected_output = ["equity", "price", "help"]
- assert (
- base_controller.parse_input(input_str) == expected_output
- ), "Input parsing failed"
+ expected_output = ["home", "equity", "price", "help"]
+ assert base_controller.parse_input(input_str) == expected_output, (
+ "Input parsing failed"
+ )
@pytest.mark.integration
@@ -84,6 +83,6 @@ def test_custom_reset(base_controller):
base_controller.custom_reset = Mock(return_value=["custom", "reset"])
base_controller.call_reset(None)
expected_queue = ["quit", "reset", "custom", "reset"]
- assert (
- base_controller.queue == expected_queue
- ), f"Expected queue to be {expected_queue}, but was {base_controller.queue}"
+ assert base_controller.queue == expected_queue, (
+ f"Expected queue to be {expected_queue}, but was {base_controller.queue}"
+ )
diff --git a/cli/integration/test_integration_base_platform_controller.py b/cli/integration/test_integration_base_platform_controller.py
index 2d645f14129..182037c5d17 100644
--- a/cli/integration/test_integration_base_platform_controller.py
+++ b/cli/integration/test_integration_base_platform_controller.py
@@ -3,13 +3,12 @@
from unittest.mock import MagicMock, Mock, patch
import pytest
+
from openbb_cli.controllers.base_platform_controller import (
PlatformController,
Session,
)
-# pylint: disable=protected-access, unused-variable, redefined-outer-name
-
@pytest.fixture
def platform_controller():
@@ -35,9 +34,9 @@ def platform_controller():
def test_platform_controller_initialization(platform_controller):
"""Test the initialization of the platform controller."""
expected_path = "/platform/test/"
- assert (
- expected_path == platform_controller.PATH
- ), "Controller path was not set correctly"
+ assert expected_path == platform_controller.PATH, (
+ "Controller path was not set correctly"
+ )
@pytest.mark.integration
@@ -51,9 +50,9 @@ def test_command_generation(platform_controller):
name=command_name, translator=platform_controller.translators[command_name]
)
command_method_name = f"call_{command_name}"
- assert hasattr(
- platform_controller, command_method_name
- ), "Command method was not created"
+ assert hasattr(platform_controller, command_method_name), (
+ "Command method was not created"
+ )
@patch(
diff --git a/cli/integration/test_integration_cli_controller.py b/cli/integration/test_integration_cli_controller.py
index 8b76a8499f3..e6bf033d924 100644
--- a/cli/integration/test_integration_cli_controller.py
+++ b/cli/integration/test_integration_cli_controller.py
@@ -9,9 +9,7 @@ def test_parse_input_valid_commands():
"""Test parse_input method."""
controller = CLIController()
input_string = "exe --file test.openbb"
- expected_output = [
- "exe --file test.openbb"
- ] # Adjust based on actual expected behavior
+ expected_output = ["exe --file test.openbb"]
assert controller.parse_input(input_string) == expected_output
@@ -21,6 +19,6 @@ def test_parse_input_invalid_commands():
input_string = "nonexistentcommand args"
expected_output = ["nonexistentcommand args"]
actual_output = controller.parse_input(input_string)
- assert (
- actual_output == expected_output
- ), f"Expected {expected_output}, got {actual_output}"
+ assert actual_output == expected_output, (
+ f"Expected {expected_output}, got {actual_output}"
+ )
diff --git a/cli/integration/test_integration_obbject_registry.py b/cli/integration/test_integration_obbject_registry.py
index 388bcb8f031..b9b591f7642 100644
--- a/cli/integration/test_integration_obbject_registry.py
+++ b/cli/integration/test_integration_obbject_registry.py
@@ -1,10 +1,10 @@
"""Test the obbject registry."""
-from openbb_cli.argparse_translator.obbject_registry import Registry
from openbb_core.app.model.obbject import OBBject
-# pylint: disable=unused-variable
-# ruff: noqa: disable=F841
+from openbb_cli.argparse_translator.obbject_registry import Registry
+
+# ruff: noqa: F841
def test_registry_operations():
@@ -20,35 +20,29 @@ def test_registry_operations():
id="3", results=True, extra={"register_key": "key3", "command": "cmd3"}
)
- # Add obbjects to the registry
assert registry.register(obbject1) is True
assert registry.register(obbject2) is True
- # Attempt to add the same object again
assert registry.register(obbject1) is False
- # Ensure the registry size is correct
assert len(registry.obbjects) == 2
- # Get by index
assert registry.get(0) == obbject2
assert registry.get(1) == obbject1
- # Get by key
assert registry.get("key1") == obbject1
assert registry.get("key2") == obbject2
- # Invalid index/key
assert registry.get(2) is None
assert registry.get("invalid_key") is None
- # Remove an object
registry.remove(0)
assert len(registry.obbjects) == 1
assert registry.get("key2") is None
- # Validate the 'all' property
all_obbjects = registry.all
- assert "command" in all_obbjects[0]
- assert all_obbjects[0]["command"] == "cmd1"
+ # ``command`` lives under ``extra`` โ that's where the OBBject keeps
+ # it and where the registry surfaces it (no synthetic top-level
+ # mirror that would duplicate ``extra.metadata.route`` etc.).
+ assert "command" in all_obbjects[0]["extra"]
+ assert all_obbjects[0]["extra"]["command"] == "cmd1"
- # Clean up by removing all objects
registry.remove()
assert len(registry.obbjects) == 0
assert registry.get("key1") is None
diff --git a/cli/openbb_cli/__init__.py b/cli/openbb_cli/__init__.py
index 542734dabf3..ccf5acb13bc 100644
--- a/cli/openbb_cli/__init__.py
+++ b/cli/openbb_cli/__init__.py
@@ -1 +1 @@
-"""Package init"""
+"""Package init."""
diff --git a/cli/openbb_cli/argparse_translator/__init__.py b/cli/openbb_cli/argparse_translator/__init__.py
index e69de29bb2d..5d89c6ac8fd 100644
--- a/cli/openbb_cli/argparse_translator/__init__.py
+++ b/cli/openbb_cli/argparse_translator/__init__.py
@@ -0,0 +1 @@
+"""Argparse translator package."""
diff --git a/cli/openbb_cli/argparse_translator/argparse_argument.py b/cli/openbb_cli/argparse_translator/argparse_argument.py
index 0969f704746..6b2e0b1a18f 100644
--- a/cli/openbb_cli/argparse_translator/argparse_argument.py
+++ b/cli/openbb_cli/argparse_translator/argparse_argument.py
@@ -23,7 +23,7 @@ class ArgparseArgumentModel(BaseModel):
nargs: Literal["+"] | None
choices: tuple | None
- @model_validator(mode="after") # type: ignore
+ @model_validator(mode="after") # ty: ignore[invalid-argument-type]
@classmethod
def validate_action(cls, values: "ArgparseArgumentModel"):
"""Validate the action based on the type."""
@@ -31,7 +31,7 @@ class ArgparseArgumentModel(BaseModel):
raise ValueError('If type is bool, action must be "store_true"')
return values
- @model_validator(mode="after") # type: ignore
+ @model_validator(mode="after") # ty: ignore[invalid-argument-type]
@classmethod
def remove_props_on_store_true(cls, values: "ArgparseArgumentModel"):
"""Remove type, nargs, and choices if action is store_true."""
@@ -41,12 +41,10 @@ class ArgparseArgumentModel(BaseModel):
values.choices = None
return values
- # override
def model_dump(self, **kwargs):
"""Override the model_dump method to remove empty choices."""
res = super().model_dump(**kwargs)
- # Check if choices is present and if it's an empty tuple remove it
if "choices" in res and not res["choices"]:
del res["choices"]
diff --git a/cli/openbb_cli/argparse_translator/argparse_class_processor.py b/cli/openbb_cli/argparse_translator/argparse_class_processor.py
index 8f0ae981335..e2e4f3cd48f 100644
--- a/cli/openbb_cli/argparse_translator/argparse_class_processor.py
+++ b/cli/openbb_cli/argparse_translator/argparse_class_processor.py
@@ -3,7 +3,6 @@
import inspect
from typing import Any
-# TODO: this needs to be done differently
from openbb_core.app.static.container import Container
from openbb_cli.argparse_translator.argparse_translator import ArgparseTranslator
@@ -15,7 +14,6 @@ from openbb_cli.argparse_translator.reference_processor import (
class ArgparseClassProcessor:
"""Process a target class to create ArgparseTranslators for its methods."""
- # reference variable used to create custom groups for the ArgpaseTranslators
_reference: dict[str, Any] = {}
def __init__(
@@ -24,8 +22,7 @@ class ArgparseClassProcessor:
add_help: bool = False,
reference: dict[str, Any] | None = None,
):
- """
- Initialize the ArgparseClassProcessor.
+ """Initialize the ArgparseClassProcessor.
Parameters
----------
@@ -49,8 +46,7 @@ class ArgparseClassProcessor:
@property
def translators(self) -> dict[str, ArgparseTranslator]:
- """
- Get the ArgparseTranslators associated with the target class.
+ """Get the ArgparseTranslators associated with the target class.
Returns
-------
@@ -61,8 +57,7 @@ class ArgparseClassProcessor:
@property
def paths(self) -> dict[str, str]:
- """
- Get the paths associated with the target class.
+ """Get the paths associated with the target class.
Returns
-------
@@ -78,7 +73,7 @@ class ArgparseClassProcessor:
if not reference:
return {}
rp = ReferenceToArgumentsProcessor(reference)
- return rp.custom_groups.get(route, {}) # type: ignore
+ return rp.custom_groups.get(route, {}) # ty: ignore[invalid-return-type]
@classmethod
def _process_class(
@@ -96,7 +91,7 @@ class ArgparseClassProcessor:
methods[f"{class_name}_{name}"] = ArgparseTranslator(
func=member,
add_help=add_help,
- custom_argument_groups=cls._custom_groups_from_reference( # type: ignore
+ custom_argument_groups=cls._custom_groups_from_reference( # ty: ignore[invalid-argument-type]
class_name=class_name, function_name=name
),
)
@@ -121,8 +116,7 @@ class ArgparseClassProcessor:
)
def get_translator(self, command: str) -> ArgparseTranslator:
- """
- Retrieve the ArgparseTranslator object associated with a specific menu and command.
+ """Retrieve the ArgparseTranslator associated with a command.
Parameters
----------
@@ -136,12 +130,10 @@ class ArgparseClassProcessor:
"""
return self._translators[command]
- def _build_paths(self, target: type, depth: int = 1):
+ def _build_paths(self, target: type):
+ """Record direct sub-namespaces only."""
for name, member in inspect.getmembers(target):
- if name.startswith("__") or name.startswith("_"):
+ if name.startswith("_"):
continue
- if inspect.ismethod(member):
- pass
- elif isinstance(member, Container):
- self._build_paths(target=getattr(target, name), depth=depth + 1)
- self._paths[f"{name}"] = "sub" * depth + "path"
+ if isinstance(member, Container):
+ self._paths[f"{name}"] = "subpath"
diff --git a/cli/openbb_cli/argparse_translator/argparse_translator.py b/cli/openbb_cli/argparse_translator/argparse_translator.py
index 77c244ed7cc..812aba22890 100644
--- a/cli/openbb_cli/argparse_translator/argparse_translator.py
+++ b/cli/openbb_cli/argparse_translator/argparse_translator.py
@@ -3,6 +3,7 @@
import argparse
import inspect
import re
+import types
from collections.abc import Callable
from copy import deepcopy
from typing import (
@@ -30,8 +31,6 @@ from openbb_cli.argparse_translator.utils import (
set_optional_choices,
)
-# pylint: disable=protected-access
-
SEP = "__"
@@ -44,12 +43,14 @@ class ArgparseTranslator:
custom_argument_groups: list[ArgparseArgumentGroupModel] | None = None,
add_help: bool | None = True,
):
- """
- Initialize the ArgparseTranslator.
+ """Initialize the ArgparseTranslator.
- Args:
- func (Callable): The function to translate into an argparse program.
- add_help (Optional[bool], optional): Whether to add the help argument. Defaults to False.
+ Parameters
+ ----------
+ func : Callable
+ The function to translate into an argparse program.
+ add_help : Optional[bool]
+ Whether to add the help argument.
"""
self.func = func
self.signature = inspect.signature(func)
@@ -57,8 +58,8 @@ class ArgparseTranslator:
self.provider_parameters: dict[str, list[str]] = {}
self._parser = argparse.ArgumentParser(
- prog=func.__name__,
- description=self._build_description(func.__doc__), # type: ignore
+ prog=func.__name__, # ty: ignore[unresolved-attribute]
+ description=self._build_description(func.__doc__), # ty: ignore[invalid-argument-type]
formatter_class=argparse.RawTextHelpFormatter,
add_help=add_help if add_help else False,
)
@@ -81,11 +82,9 @@ class ArgparseTranslator:
pattern = r"\(provider:\s*(.*?)\)"
providers = re.findall(pattern, input_string)
providers.extend(new_provider)
- # remove pattern from help and add with new providers
input_string = re.sub(pattern, "", input_string).strip()
return f"{input_string} (provider: {', '.join(providers)})"
- # check if the argument is already in use, if not, add it
if f"--{argument.name}" not in self._parser._option_string_actions:
kwargs = argument.model_dump(exclude={"name"}, exclude_none=True)
if "help" in kwargs:
@@ -97,51 +96,41 @@ class ArgparseTranslator:
else:
kwargs = argument.model_dump(exclude={"name"}, exclude_none=True)
model_choices = kwargs.get("choices", ()) or ()
- # extend choices
existing_choices = get_argument_choices(self._parser, argument.name)
choices = tuple(set(existing_choices + model_choices))
optional_choices = bool(existing_choices and not model_choices)
- # check if the argument is in the required arguments
if in_group(self._parser, argument.name, group_title="required arguments"):
for action in self._required._group_actions:
if action.dest == argument.name and choices:
- # update choices
action.choices = choices
set_optional_choices(action, optional_choices)
return
- # check if the argument is in the optional arguments
- if in_group(self._parser, argument.name, group_title="optional arguments"):
- for action in self._parser._actions:
+ if in_group( # pragma: no cover
+ self._parser, argument.name, group_title="optional arguments"
+ ):
+ for action in self._parser._actions: # pragma: no cover
if action.dest == argument.name:
- # update choices
if choices:
action.choices = choices
set_optional_choices(action, optional_choices)
if argument.name not in self.signature.parameters:
- # update help
action.help = ArgparseTranslator._escape_help(
_update_providers(action.help or "", [group.title])
)
- return
+ return # pragma: no cover
- # we need to check if the optional choices were set in other group
- # before we remove the argument from the group, otherwise we will lose info
if not optional_choices:
optional_choices = get_argument_optional_choices(
self._parser, argument.name
)
- # if the argument is in use, remove it from all groups
- # and return the groups that had the argument
groups_w_arg = remove_argument(self._parser, argument.name)
- groups_w_arg.append(group.title) # add current group
+ groups_w_arg.append(group.title)
- # add it to the optional arguments group instead
if choices:
- kwargs["choices"] = choices # update choices
- # add provider info to the help
+ kwargs["choices"] = choices
kwargs["help"] = ArgparseTranslator._escape_help(
_update_providers(argument.help or "", groups_w_arg)
)
@@ -155,12 +144,7 @@ class ArgparseTranslator:
@staticmethod
def _escape_help(text: str | None) -> str | None:
- """Escape percent signs in help strings for argparse.
-
- Python 3.14+ validates help strings at add_argument time using
- %-formatting. Bare '%' characters that aren't valid format
- specifiers (like '%(default)s') cause a ValueError.
- """
+ """Escape percent signs in help strings for argparse."""
if text is None:
return None
return text.replace("%", "%%")
@@ -171,29 +155,21 @@ class ArgparseTranslator:
if not func_doc:
return ""
- # Remove the openbb header if present
func_doc = re.sub(r"openbb\n\s+={3,}\n", "", func_doc, flags=re.DOTALL)
- # Senior Approach: The main description should only be the summary.
- # Sections like Parameters, Returns, and Examples are handled by argparse or are redundant.
for section in ["Parameters", "Returns", "Examples", "Raises"]:
pattern = rf"\n\s*{section}\n\s*-{{3,}}\n.*"
func_doc = re.sub(pattern, "", func_doc, flags=re.DOTALL | re.IGNORECASE)
- # Clean up any remaining type-style annotations in the summary
def clean_type_annotation(type_str: str) -> str:
"""Clean up type annotations for human readability."""
- # Handle pipe unions: int | str -> int or str
type_str = re.sub(r"\s*\|\s*", " or ", type_str)
- # Handle Annotated[type, ...] -> type
type_str = re.sub(r"Annotated\[\s*([^,\]]+).*?\]", r"\1", type_str)
- # Handle Union[A, B] -> A or B
type_str = re.sub(
r"Union\[\s*(.*?)\s*\]",
lambda m: m.group(1).replace(", ", " or "),
type_str,
)
- # Handle Optional[A] -> A or None
type_str = re.sub(r"Optional\[\s*(.*?)\s*\]", r"\1 or None", type_str)
return type_str.strip()
@@ -201,7 +177,6 @@ class ArgparseTranslator:
lines = func_doc.split("\n")
cleaned_lines = []
for line in lines:
- # If a line still looks like a parameter definition (e.g. "param : type"), clean it
if ":" in line and not line.strip().startswith("#"):
parts = line.split(":", 1)
param_name = parts[0]
@@ -229,11 +204,7 @@ class ArgparseTranslator:
if param_type is bool:
return "store_true"
- if origin is Union and bool in args:
- return "store_true"
-
- # Special case for Optional[bool] which is Union[bool, None]
- if origin is Union and bool in args and type(None) in args:
+ if origin in (Union, types.UnionType) and bool in args:
return "store_true"
return "store"
@@ -243,7 +214,7 @@ class ArgparseTranslator:
) -> tuple[type[Any], tuple[Any, ...]]:
"""Return the type and choices for the given parameter."""
- def get_base_type( # pylint: disable=R0911 # noqa:PLR0911
+ def get_base_type( # noqa: PLR0911
t: Any,
) -> type:
"""Recursively find the base type for argparse."""
@@ -254,24 +225,20 @@ class ArgparseTranslator:
non_none_args = [a for a in args if a is not type(None)]
if len(non_none_args) == 1:
return get_base_type(non_none_args[0])
- # For Union[A, B, C], check for bool first, then default to str
if bool in non_none_args:
return bool
- # If we have multiple types including str, prefer str as it's most flexible
if str in non_none_args:
return str
- # Otherwise, try to get the first concrete type
for arg in non_none_args:
if arg not in (type(None), Any):
return get_base_type(arg)
- return str
+ return str # pragma: no cover
if origin is Literal:
return type(args[0]) if args else str
if origin is list:
- return get_base_type(args[0]) if args else Any # type: ignore
+ return get_base_type(args[0]) if args else Any
if t is Any:
return str
- # Handle actual type objects (like datetime.date)
if isinstance(t, type):
return t
return str
@@ -364,22 +331,22 @@ class ArgparseTranslator:
param_type, choices = self._get_type_and_choices(param)
- # if the param is a custom type, we need to flatten it
if inspect.isclass(param_type) and issubclass(param_type, BaseModel):
- # update type hints with the custom type fields
type_hints = get_type_hints(param_type)
- # prefix the type hints keys with the param name
type_hints = {
f"{param.name}{SEP}{key}": value
for key, value in type_hints.items()
}
self.type_hints.update(type_hints)
- # create a signature from the custom type
sig = inspect.signature(param_type)
- # add help to the annotation
annotated_parameters: list[inspect.Parameter] = []
for child_param in sig.parameters.values():
+ if child_param.kind in (
+ inspect.Parameter.VAR_KEYWORD,
+ inspect.Parameter.VAR_POSITIONAL,
+ ):
+ continue
new_child_param = child_param.replace(
name=f"{param.name}{SEP}{child_param.name}",
annotation=Annotated[
@@ -394,22 +361,18 @@ class ArgparseTranslator:
)
annotated_parameters.append(new_child_param)
- # replacing with the annotated parameters
new_signature = inspect.Signature(
parameters=annotated_parameters,
return_annotation=sig.return_annotation,
)
self._generate_argparse_arguments(new_signature.parameters)
- # the custom type itself should not be added as an argument
continue
required = not self._param_is_default(param)
- # Get the appropriate action based on the parameter type
action = self._get_action_type(param)
- # For boolean parameters with action="store_true", we should not use any choices
if param_type is bool:
choices = ()
action = "store_true"
@@ -457,8 +420,6 @@ class ArgparseTranslator:
def _update_with_custom_types(self, kwargs: dict[str, Any]) -> dict[str, Any]:
"""Update the kwargs with the custom types."""
- # for each argument in the signature that is a custom type, we need to
- # update the kwargs with the custom type kwargs
for param in self.signature.parameters.values():
if param.name == "kwargs":
continue
@@ -473,15 +434,17 @@ class ArgparseTranslator:
self,
parsed_args: argparse.Namespace | None = None,
) -> Any:
- """
- Execute the original function with the parsed arguments.
+ """Execute the original function with the parsed arguments.
- Args:
- parsed_args (Optional[argparse.Namespace], optional): The parsed arguments. Defaults to None.
-
- Returns:
- Any: The return value of the original function.
+ Parameters
+ ----------
+ parsed_args : Optional[argparse.Namespace]
+ The parsed arguments.
+ Returns
+ -------
+ Any
+ The return value of the original function.
"""
kwargs = self._unflatten_args(vars(parsed_args))
kwargs = self._update_with_custom_types(kwargs)
@@ -493,7 +456,6 @@ class ArgparseTranslator:
for args in self.provider_parameters.values():
provider_args.extend(args)
- # remove kwargs not matching the signature, provider parameters, or are empty.
kwargs = {
key: value
for key, value in kwargs.items()
@@ -505,22 +467,24 @@ class ArgparseTranslator:
return self.func(**kwargs)
def parse_args_and_execute(self) -> Any:
- """
- Parse the arguments and executes the original function.
+ """Parse the arguments and execute the original function.
- Returns:
- Any: The return value of the original function.
+ Returns
+ -------
+ Any
+ The return value of the original function.
"""
parsed_args = self._parser.parse_args()
return self.execute_func(parsed_args)
def translate(self) -> Callable:
- """
- Wrap the original function with an argparse program.
+ """Wrap the original function with an argparse program.
- Returns:
- Callable: The original function wrapped with an argparse program.
+ Returns
+ -------
+ Callable
+ The original function wrapped with an argparse program.
"""
def wrapper_func():
diff --git a/cli/openbb_cli/argparse_translator/obbject_registry.py b/cli/openbb_cli/argparse_translator/obbject_registry.py
index 45aea6ea946..142ad60c748 100644
--- a/cli/openbb_cli/argparse_translator/obbject_registry.py
+++ b/cli/openbb_cli/argparse_translator/obbject_registry.py
@@ -1,6 +1,6 @@
"""Registry for OBBjects."""
-import json
+from typing import Any
from openbb_core.app.model.obbject import OBBject
@@ -22,7 +22,7 @@ class Registry:
if (
isinstance(obbject, OBBject)
and not self._contains_obbject(obbject.id, self._obbjects)
- and obbject.results
+ and obbject.results is not None
):
self._obbjects.append(obbject)
return True
@@ -46,11 +46,8 @@ class Registry:
def _get_by_index(self, idx: int) -> OBBject | None:
"""Return the obbject at index idx."""
- # the list should work as a stack
- # i.e., the last element needs to be accessed by idx=0 and so on
reversed_list = list(reversed(self._obbjects))
- # check if the index is out of bounds
if idx >= len(reversed_list):
return None
@@ -58,8 +55,6 @@ class Registry:
def remove(self, idx: int = -1):
"""Remove the obbject at index idx, default is the last element."""
- # the list should work as a stack
- # i.e., the last element needs to be accessed by idx=0 and so on
reversed_list = list(reversed(self._obbjects))
del reversed_list[idx]
self._obbjects = list(reversed(reversed_list))
@@ -67,49 +62,26 @@ class Registry:
@property
def all(self) -> dict[int, dict]:
"""Return all obbjects in the registry."""
-
- def _handle_standard_params(obbject: OBBject) -> str:
- """Handle standard params for obbjects."""
- standard_params_json = ""
- std_params = getattr(
- obbject, "_standard_params", {}
- ) # pylint: disable=protected-access
- if std_params:
- standard_params = {
- k: str(v)[:30] for k, v in std_params.items() if v and k != "data"
- }
- standard_params_json = json.dumps(standard_params)
-
- return standard_params_json
-
- def _handle_data_repr(obbject: OBBject) -> str:
- """Handle data representation for obbjects."""
- data_repr = ""
- if hasattr(obbject, "results") and obbject.results:
- data_schema = (
- obbject.results[0].model_json_schema()
- if obbject.results
- and isinstance(obbject.results, list)
- and hasattr(obbject.results[0], "model_json_schema")
- else ""
- )
- if data_schema and "title" in data_schema:
- data_repr = f"{data_schema['title']}" # type: ignore
- if data_schema and "description" in data_schema:
- data_repr += f" - {data_schema['description'].split('.')[0]}" # type: ignore
-
- return data_repr
-
- obbjects = {}
+ obbjects: dict[int, dict] = {}
for i, obbject in enumerate(list(reversed(self._obbjects))):
- obbjects[i] = {
- "route": obbject._route, # pylint: disable=protected-access
- "provider": obbject.provider,
- "standard params": _handle_standard_params(obbject),
- "data": _handle_data_repr(obbject),
- "command": obbject.extra.get("command", ""),
- "key": obbject.extra.get("register_key", ""),
- }
+ model_dump = getattr(obbject, "model_dump", None)
+ dump: dict[str, Any] | None = None
+ if callable(model_dump):
+ try:
+ candidate = model_dump(exclude={"results"})
+ except Exception: # noqa: BLE001
+ candidate = None
+ if isinstance(candidate, dict):
+ dump = candidate
+ if dump is None:
+ dump = {
+ "id": getattr(obbject, "id", None),
+ "provider": getattr(obbject, "provider", None),
+ "warnings": getattr(obbject, "warnings", None),
+ "chart": getattr(obbject, "chart", None),
+ "extra": getattr(obbject, "extra", {}) or {},
+ }
+ obbjects[i] = dump
return obbjects
diff --git a/cli/openbb_cli/argparse_translator/reference_processor.py b/cli/openbb_cli/argparse_translator/reference_processor.py
index 06e0c4811fd..7cd64de8ea9 100644
--- a/cli/openbb_cli/argparse_translator/reference_processor.py
+++ b/cli/openbb_cli/argparse_translator/reference_processor.py
@@ -27,26 +27,21 @@ class ReferenceToArgumentsProcessor:
@staticmethod
def _parse_type(type_string: str) -> type:
"""Parse the type from the string representation."""
- # Handle Optional[T] or T | None
if "Optional" in type_string or "|" in type_string:
- # Extract the inner type, defaulting to str if parsing fails
match = re.search(r"Optional\[(\w+)]|(\w+)\s*\|\s*None", type_string)
if match:
type_string = next(
(group for group in match.groups() if group is not None), "str"
)
- # Handle Literal types
if "Literal" in type_string:
- return str # Treat all Literal types as strings for simplicity
+ return str
- # Handle Annotated types by extracting the base type
if "Annotated" in type_string:
match = re.search(r"Annotated\[(\w+),", type_string)
if match:
type_string = match.group(1)
- # Map common string representations to actual types
type_map = {
"str": str,
"int": int,
@@ -69,14 +64,12 @@ class ReferenceToArgumentsProcessor:
if custom_choices:
return tuple(custom_choices)
- # Find all occurrences of Literal[...]
literal_matches = re.findall(r"Literal\[(.*?)\]", type_string)
if not literal_matches:
return None
all_choices: list = []
for match in literal_matches:
- # Split by comma and strip quotes and whitespace
choices = [c.strip().strip("'\"") for c in match.split(",") if c.strip()]
all_choices.extend(choices)
diff --git a/cli/openbb_cli/argparse_translator/utils.py b/cli/openbb_cli/argparse_translator/utils.py
index c15d4ac46f5..cb57d8e4886 100644
--- a/cli/openbb_cli/argparse_translator/utils.py
+++ b/cli/openbb_cli/argparse_translator/utils.py
@@ -5,11 +5,9 @@ from argparse import Action, ArgumentParser
def in_group(parser: ArgumentParser, argument_name: str, group_title: str) -> bool:
"""Check if an argument is in a group of an ArgumentParser."""
- for action_group in parser._action_groups: # pylint: disable=protected-access
+ for action_group in parser._action_groups:
if action_group.title == group_title:
- for (
- action
- ) in action_group._group_actions: # pylint: disable=protected-access
+ for action in action_group._group_actions:
opts = action.option_strings
if (opts and opts[0] == argument_name) or action.dest == argument_name:
return True
@@ -20,34 +18,27 @@ def remove_argument(parser: ArgumentParser, argument_name: str) -> list[str | No
"""Remove an argument from an ArgumentParser."""
groups_w_arg = []
- # remove the argument from the parser
- for action in parser._actions: # pylint: disable=protected-access
+ for action in parser._actions:
opts = action.option_strings
if (opts and opts[0] == argument_name) or action.dest == argument_name:
- parser._remove_action(action) # pylint: disable=protected-access
+ parser._remove_action(action)
break
- # remove from all groups
- for action_group in parser._action_groups: # pylint: disable=protected-access
- for action in action_group._group_actions: # pylint: disable=protected-access
+ for action_group in parser._action_groups:
+ for action in action_group._group_actions:
opts = action.option_strings
if (opts and opts[0] == argument_name) or action.dest == argument_name:
- action_group._group_actions.remove( # pylint: disable=protected-access
- action
- )
+ action_group._group_actions.remove(action)
groups_w_arg.append(action_group.title)
- # remove from _action_groups dict
- parser._option_string_actions.pop( # pylint: disable=protected-access
- f"--{argument_name}", None
- )
+ parser._option_string_actions.pop(f"--{argument_name}", None)
return groups_w_arg
def get_argument_choices(parser: ArgumentParser, argument_name: str) -> tuple:
"""Get the choices of an argument from an ArgumentParser."""
- for action in parser._actions: # pylint: disable=protected-access
+ for action in parser._actions:
opts = action.option_strings
if (opts and opts[0] == argument_name) or action.dest == argument_name:
return tuple(action.choices or ())
@@ -56,14 +47,14 @@ def get_argument_choices(parser: ArgumentParser, argument_name: str) -> tuple:
def get_argument_optional_choices(parser: ArgumentParser, argument_name: str) -> bool:
"""Get the optional_choices attribute of an argument from an ArgumentParser."""
- for action in parser._actions: # pylint: disable=protected-access
+ for action in parser._actions:
opts = action.option_strings
if (
(opts and opts[0] == argument_name)
or action.dest == argument_name
and hasattr(action, "optional_choices")
):
- return action.optional_choices # type: ignore[attr-defined] # this is a custom attribute
+ return action.optional_choices # ty: ignore[unresolved-attribute]
return False
diff --git a/cli/openbb_cli/auth.py b/cli/openbb_cli/auth.py
new file mode 100644
index 00000000000..3b54d619179
--- /dev/null
+++ b/cli/openbb_cli/auth.py
@@ -0,0 +1,51 @@
+"""Auth hooks for pluggable per-request authentication."""
+
+from __future__ import annotations
+
+import importlib
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, field
+from typing import Any, Union
+
+
+@dataclass(frozen=True)
+class AuthContext:
+ """Read-only context handed to an auth hook for one request."""
+
+ namespace: str | None
+ command: str
+ params: dict[str, Any] = field(default_factory=dict)
+ method: str = "post"
+
+
+@dataclass(frozen=True)
+class AuthDecision:
+ """Hook output: extra credentials to add and / or an RBAC deny."""
+
+ headers: dict[str, str] | None = None
+ query_params: dict[str, str] | None = None
+ allow: bool = True
+ deny_reason: str | None = None
+
+
+AuthHook = Callable[[AuthContext], Union["AuthDecision", Awaitable["AuthDecision"]]]
+
+
+def resolve_auth_hook(spec: str) -> AuthHook:
+ """Import an auth hook from its ``module.path:attribute`` spec."""
+ if not isinstance(spec, str) or ":" not in spec:
+ raise ValueError(
+ f"auth-hook must be of the form 'module.path:attribute'; got {spec!r}"
+ )
+ module_name, _, attr = spec.partition(":")
+ module_name = module_name.strip()
+ attr = attr.strip()
+ if not module_name or not attr:
+ raise ValueError(f"auth-hook spec missing module or attribute: {spec!r}")
+ module = importlib.import_module(module_name)
+ target = getattr(module, attr, None)
+ if target is None:
+ raise ImportError(f"auth-hook {spec!r}: {attr!r} not found in {module_name!r}")
+ if not callable(target):
+ raise TypeError(f"auth-hook {spec!r}: {target!r} is not callable")
+ return target
diff --git a/cli/openbb_cli/backend.py b/cli/openbb_cli/backend.py
new file mode 100644
index 00000000000..6cceb7a9fe0
--- /dev/null
+++ b/cli/openbb_cli/backend.py
@@ -0,0 +1,234 @@
+"""Pluggable controller backend."""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+from copy import deepcopy
+from typing import Any, Protocol, runtime_checkable
+
+
+@runtime_checkable
+class Translator(Protocol):
+ """Per-command translator matching the ``ArgparseTranslator`` surface controllers touch."""
+
+ _parser: argparse.ArgumentParser
+ func: Any
+
+ @property
+ def parser(self) -> argparse.ArgumentParser: ...
+
+ def execute_func(self, parsed_args: argparse.Namespace) -> Any: ...
+
+
+class Backend(Protocol):
+ """Source of truth for menu structure and command execution."""
+
+ @property
+ def routers(self) -> dict[str, str]: ...
+
+ @property
+ def reference_paths(self) -> dict[str, dict[str, Any]]: ...
+
+ @property
+ def reference_routers(self) -> dict[str, dict[str, Any]]: ...
+
+ def get_command_target(self, router: str) -> Any:
+ """Resolve a top-level command-typed router target."""
+
+ def get_translators_for_path(
+ self, router: str
+ ) -> tuple[dict[str, Translator], dict[str, str]]: ...
+
+
+class LocalBackend:
+ """Backend backed by in-process ``openbb``."""
+
+ def __init__(self) -> None:
+ self._obb: Any = None
+ self._routers: dict[str, str] | None = None
+
+ def _ensure_obb(self) -> Any:
+ if self._obb is None:
+ from openbb import obb
+
+ self._obb = obb
+ return self._obb
+
+ @property
+ def routers(self) -> dict[str, str]:
+ if self._routers is not None:
+ return self._routers
+ from pydantic import BaseModel
+
+ obb = self._ensure_obb()
+ self._routers = {
+ d: ("menu" if not isinstance(getattr(obb, d), BaseModel) else "command")
+ for d in dir(obb)
+ if not d.startswith("_") and d not in ("user", "system", "account")
+ }
+ return self._routers
+
+ @property
+ def reference_paths(self) -> dict[str, dict[str, Any]]:
+ return self._ensure_obb().reference.get("paths", {})
+
+ @property
+ def reference_routers(self) -> dict[str, dict[str, Any]]:
+ return self._ensure_obb().reference.get("routers", {})
+
+ def get_command_target(self, router: str) -> Any:
+ return getattr(self._ensure_obb(), router)
+
+ def get_translators_for_path(
+ self, router: str
+ ) -> tuple[dict[str, Translator], dict[str, str]]:
+ from openbb_cli.argparse_translator.argparse_class_processor import (
+ ArgparseClassProcessor,
+ )
+
+ target = getattr(self._ensure_obb(), router)
+ processor = ArgparseClassProcessor(
+ target_class=target, reference=self.reference_paths
+ )
+ return processor.translators, processor.paths # ty: ignore[invalid-return-type]
+
+
+class SpecBackend:
+ """Backend backed by a loaded ``.spec`` document and an HTTP dispatcher."""
+
+ def __init__(self, spec_doc: dict[str, Any], dispatcher: Any) -> None:
+ self._spec = spec_doc
+ self._dispatcher = dispatcher
+ self._top_level_routers: dict[str, str] = {
+ name: kind
+ for name, kind in spec_doc.get("routers", {}).items()
+ if "." not in name
+ }
+
+ @property
+ def routers(self) -> dict[str, str]:
+ return self._top_level_routers
+
+ @property
+ def reference_paths(self) -> dict[str, dict[str, Any]]:
+ return self._spec.get("reference", {}).get("paths", {})
+
+ @property
+ def reference_routers(self) -> dict[str, dict[str, Any]]:
+ return self._spec.get("reference", {}).get("routers", {})
+
+ def get_command_target(self, router: str) -> Any:
+ """Stub exposing ``model_dump()`` for top-level command-typed routers."""
+ commands = self._spec.get("commands", {})
+ meta = commands.get(router)
+ if meta is None:
+ return _CommandStub(router, {})
+ return _CommandStub(router, meta)
+
+ def get_translators_for_path(
+ self, router: str
+ ) -> tuple[dict[str, Translator], dict[str, str]]:
+ """Get translators for ``router.*`` commands and the direct sub-namespace list."""
+ prefix = router + "."
+ translators: dict[str, Translator] = {}
+ direct_subs: set[str] = set()
+
+ for cmd_name, cmd_spec in self._spec.get("commands", {}).items():
+ if not cmd_name.startswith(prefix):
+ continue
+ tail = cmd_name[len(prefix) :]
+ key = f"{router}_{tail.replace('.', '_')}"
+ translators[key] = SpecTranslator(cmd_name, cmd_spec, self._dispatcher)
+ parts = tail.split(".")
+ if len(parts) > 1:
+ direct_subs.add(parts[0])
+
+ sub_paths: dict[str, str] = {name: "subpath" for name in direct_subs}
+ return translators, sub_paths
+
+
+class _CommandStub:
+ """Minimal object exposing ``model_dump`` for command-typed top-level routers."""
+
+ def __init__(self, command: str, meta: dict[str, Any]) -> None:
+ self._command = command
+ self._meta = meta
+
+ def model_dump(self) -> dict[str, Any]:
+ return {"command": self._command, **self._meta}
+
+
+class SpecTranslator:
+ """ArgparseTranslator-shaped facade over a spec command and HTTP dispatcher."""
+
+ def __init__(self, command: str, cmd_spec: dict[str, Any], dispatcher: Any) -> None:
+ from openbb_cli.dispatchers.spec import (
+ command_parameters,
+ parser_from_command_spec,
+ )
+
+ self._command = command
+ self._spec = cmd_spec
+ self._dispatcher = dispatcher
+ self._parser = parser_from_command_spec(cmd_spec, prog=command)
+ all_params = command_parameters(cmd_spec)
+ self._param_names = {p["name"] for p in all_params if p.get("name")}
+ self._params_by_provider: dict[str, set[str]] = {}
+ providers: list[str] = cmd_spec.get("providers") or []
+ for provider in providers:
+ self._params_by_provider[provider] = {
+ p["name"]
+ for p in all_params
+ if p.get("name")
+ and (not p.get("providers") or provider in p["providers"])
+ }
+ self.func = _NamedStub(command.replace(".", "_"))
+
+ @property
+ def parser(self) -> argparse.ArgumentParser:
+ return deepcopy(self._parser)
+
+ def execute_func(self, parsed_args: argparse.Namespace) -> Any:
+ from openbb_cli.dispatchers.protocol import Request
+
+ params = {
+ k: v
+ for k, v in vars(parsed_args).items()
+ if v is not None and k in self._param_names
+ }
+ provider = params.get("provider")
+ if provider and provider in self._params_by_provider:
+ allowed = self._params_by_provider[provider]
+ stray = [k for k in params if k not in allowed and k != "provider"]
+ if stray:
+ raise RuntimeError(
+ f"flags not valid for provider={provider!r}: "
+ f"{', '.join('--' + s for s in stray)}"
+ )
+ request = Request(command=self._command, params=params)
+
+ async def _dispatch_and_close() -> Any:
+ try:
+ return await self._dispatcher.dispatch(request)
+ finally:
+ pass
+
+ response = asyncio.run(_dispatch_and_close())
+ if not response.ok:
+ raise RuntimeError(
+ response.error.message if response.error else "dispatch failed"
+ )
+ return response.result
+
+
+class _NamedStub:
+ """Callable carrying a ``__name__`` attribute used by export-filename generation."""
+
+ def __init__(self, name: str) -> None:
+ self.__name__ = name
+
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
+ raise RuntimeError(
+ "SpecTranslator stub called directly; use execute_func instead."
+ )
diff --git a/cli/openbb_cli/cli.py b/cli/openbb_cli/cli.py
index c76bfe4850a..e4ec809441e 100644
--- a/cli/openbb_cli/cli.py
+++ b/cli/openbb_cli/cli.py
@@ -1,32 +1,954 @@
"""OpenBB Platform CLI entry point."""
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import json
import logging
+import os
import sys
+from pathlib import Path
+from typing import Any
from openbb_cli.utils.utils import change_logging_sub_app, reset_logging_sub_app
-def main():
- """Use the main entry point for the OpenBB Platform CLI."""
- print("Loading...\n") # noqa: T201
+def _materialize_socrata_spec(story_source: str) -> str:
+ """Build a Socrata spec from a story and write it to a temp file."""
+ import tempfile
- # pylint: disable=import-outside-toplevel
+ from openbb_cli.dispatchers.socrata import build_socrata_spec
+ from openbb_cli.dispatchers.spec import write_spec
+
+ spec_doc = build_socrata_spec(story_source)
+ fd, path = tempfile.mkstemp(prefix="openbb-socrata-", suffix=".spec")
+ os.close(fd)
+ write_spec(path, spec_doc)
+ return path
+
+
+def _parse_spec_arg(token: str) -> tuple[str | None, str]:
+ """``[NAME=]PATH`` to ``(name, path)``; returns ``(None, path)`` if no name."""
+ if "=" in token:
+ name, _, path = token.partition("=")
+ name = name.strip()
+ path = path.strip()
+ if not name or not path:
+ raise ValueError(f"--spec entry {token!r} is malformed; expected NAME=PATH")
+ return (name, path)
+ return (None, token.strip())
+
+
+def _resolve_spec_entries(
+ cli_spec: list[str],
+ config_specs: Any,
+ config_single_spec: str | None = None,
+) -> list[tuple[str | None, str]]:
+ """Build the final ``[(namespace_or_None, path), ...]`` list."""
+ entries: list[tuple[str | None, str]] = []
+ if cli_spec:
+ for token in cli_spec:
+ entries.append(_parse_spec_arg(token))
+ elif isinstance(config_specs, dict) and config_specs:
+ for ns, info in config_specs.items():
+ path = info.get("path") if isinstance(info, dict) else info
+ if not path:
+ raise ValueError(f"[specs.{ns}] missing required 'path' key")
+ entries.append((str(ns), str(path)))
+ elif config_single_spec:
+ entries.append((None, str(config_single_spec)))
+ else:
+ env_path = os.environ.get("OPENBB_SPEC_PATH")
+ if env_path:
+ entries.append((None, env_path))
+ unnamed = [e for e in entries if e[0] is None]
+ if unnamed and len(entries) > 1:
+ raise ValueError(
+ "When passing multiple --spec entries, every one must be NAME=PATH; "
+ "an unnamed --spec PATH may only appear alone."
+ )
+ return entries
+
+
+def _split_scoped_token(token: str, namespaces: set[str]) -> tuple[str | None, str]:
+ """Detect a ``:rest`` namespace prefix on a header / query-param token."""
+ if ":" not in token:
+ return (None, token)
+ leading, _, rest = token.partition(":")
+ if leading.strip() in namespaces:
+ return (leading.strip(), rest)
+ return (None, token)
+
+
+def _split_per_namespace(
+ cli_tokens: list[str], namespaces: set[str]
+) -> tuple[list[str], dict[str, list[str]]]:
+ """Partition CLI ``-H`` / ``-Q`` tokens into ``(global, per_ns)`` buckets."""
+ global_tokens: list[str] = []
+ per_ns: dict[str, list[str]] = {ns: [] for ns in namespaces}
+ for token in cli_tokens or []:
+ ns, rest = _split_scoped_token(token, namespaces)
+ if ns is None:
+ global_tokens.append(token)
+ else:
+ per_ns[ns].append(rest)
+ return global_tokens, per_ns
+
+
+def _merge_dicts(*dicts: dict[str, str] | None) -> dict[str, str] | None:
+ """Right-biased merge of optional dicts; returns ``None`` if the result is empty."""
+ out: dict[str, str] = {}
+ for d in dicts:
+ if d:
+ out.update(d)
+ return out or None
+
+
+def _parse_header_kv(token: str) -> tuple[str, str]:
+ """Split a ``KEY=VALUE`` or ``KEY: VALUE`` header token."""
+ eq = token.find("=")
+ colon = token.find(":")
+ if eq == -1 and colon == -1:
+ raise ValueError(f"--header must be 'KEY=VALUE' or 'KEY: VALUE'; got {token!r}")
+ if eq != -1 and (colon == -1 or eq < colon):
+ key, value = token[:eq], token[eq + 1 :]
+ else:
+ key, value = token[:colon], token[colon + 1 :]
+ return key.strip(), value.strip()
+
+
+def _resolve_headers(
+ cli_headers: list[str] | None,
+ header_file: str | None,
+ config_headers: dict[str, Any] | None = None,
+) -> dict[str, str] | None:
+ """Merge headers in priority order (lowest to highest)."""
+ headers: dict[str, str] = {}
+ if config_headers:
+ for k, v in config_headers.items():
+ headers[str(k)] = str(v)
+ if header_file:
+ try:
+ file_data = json.loads(Path(header_file).read_text())
+ except (OSError, json.JSONDecodeError) as exc:
+ sys.stderr.write(f"--header-file: {exc}\n")
+ return None
+ if not isinstance(file_data, dict):
+ sys.stderr.write("--header-file must contain a JSON object.\n")
+ return None
+ for k, v in file_data.items():
+ headers[str(k)] = str(v)
+ for token in cli_headers or []:
+ k, v = _parse_header_kv(token)
+ headers[k] = v
+ return headers or None
+
+
+_QUERY_ENV_PREFIX = "OPENBB_HTTP_QUERY_"
+
+
+def _resolve_query_params(
+ cli_params: list[str] | None,
+ param_file: str | None,
+ config_query: dict[str, Any] | None = None,
+) -> dict[str, str] | None:
+ """Merge query params in priority order (lowest to highest)."""
+ import os
+
+ params: dict[str, str] = {}
+ if config_query:
+ for k, v in config_query.items():
+ params[str(k)] = str(v)
+ if param_file:
+ try:
+ file_data = json.loads(Path(param_file).read_text())
+ except (OSError, json.JSONDecodeError) as exc:
+ sys.stderr.write(f"--query-param-file: {exc}\n")
+ return None
+ if not isinstance(file_data, dict):
+ sys.stderr.write("--query-param-file must contain a JSON object.\n")
+ return None
+ for k, v in file_data.items():
+ params[str(k)] = str(v)
+ for env_key, env_val in os.environ.items():
+ if env_key.startswith(_QUERY_ENV_PREFIX):
+ name = env_key[len(_QUERY_ENV_PREFIX) :].lower()
+ params[name] = env_val
+ for token in cli_params or []:
+ k, _, v = token.partition("=")
+ if not k or not v:
+ sys.stderr.write(f"--query-param must be 'KEY=VALUE'; got {token!r}\n")
+ return None
+ params[k.strip()] = v.strip()
+ return params or None
+
+
+def _resolve_auth_hooks(
+ config: dict[str, Any],
+ namespaces: set[str],
+) -> tuple[Any, dict[str, Any]]:
+ """Import the configured auth hooks (global and per-namespace)."""
+ from openbb_cli.auth import resolve_auth_hook
+
+ def _hook_value(table: dict[str, Any]) -> str | None:
+ spec = table.get("auth-hook") or table.get("auth_hook")
+ return spec if isinstance(spec, str) and spec else None
+
+ global_spec = _hook_value(config)
+ global_hook = resolve_auth_hook(global_spec) if global_spec else None
+ per_ns_hooks: dict[str, Any] = {}
+ config_specs = config.get("specs")
+ if isinstance(config_specs, dict):
+ for ns in namespaces:
+ entry = config_specs.get(ns)
+ if not isinstance(entry, dict):
+ continue
+ ns_spec = _hook_value(entry)
+ if ns_spec:
+ per_ns_hooks[ns] = resolve_auth_hook(ns_spec)
+ return global_hook, per_ns_hooks
+
+
+def _resolve_per_ns_auth(
+ per_ns_h_tokens: dict[str, list[str]],
+ per_ns_q_tokens: dict[str, list[str]],
+ namespaces: set[str],
+ config_specs: dict[str, Any] | None,
+) -> tuple[dict[str, dict[str, str]] | None, dict[str, dict[str, str]] | None]:
+ """Build per-namespace header / query maps from CLI flags and TOML."""
+ per_ns_headers: dict[str, dict[str, str]] = {}
+ per_ns_query: dict[str, dict[str, str]] = {}
+ for ns in namespaces:
+ h: dict[str, str] = {}
+ q: dict[str, str] = {}
+ if config_specs and isinstance(config_specs.get(ns), dict):
+ spec_cfg = config_specs[ns]
+ for k, v in (spec_cfg.get("headers") or {}).items():
+ h[str(k)] = str(v)
+ for k, v in (spec_cfg.get("query") or {}).items():
+ q[str(k)] = str(v)
+ for token in per_ns_h_tokens.get(ns, []):
+ try:
+ key, value = _parse_header_kv(token)
+ except ValueError as exc:
+ sys.stderr.write(f"--header (scoped to {ns}): {exc}\n")
+ return None, None
+ h[key] = value
+ for token in per_ns_q_tokens.get(ns, []):
+ key, _, value = token.partition("=")
+ if not key or not value:
+ sys.stderr.write(
+ f"--query-param (scoped to {ns}) must be 'KEY=VALUE'; "
+ f"got {token!r}\n"
+ )
+ return None, None
+ q[key.strip()] = value.strip()
+ if h:
+ per_ns_headers[ns] = h
+ if q:
+ per_ns_query[ns] = q
+ return per_ns_headers, per_ns_query
+
+
+def _build_spec_dispatcher(
+ spec_entries: list[tuple[str | None, str]],
+ headers: dict[str, str] | None,
+ query_params: dict[str, str] | None,
+ per_ns_headers: dict[str, dict[str, str]] | None = None,
+ per_ns_query: dict[str, dict[str, str]] | None = None,
+ global_auth_hook: Any = None,
+ per_ns_auth_hooks: dict[str, Any] | None = None,
+):
+ """Build the HTTP dispatcher backed by one or more ``.spec`` files."""
+ from openbb_cli.dispatchers.http import http_dispatcher_from_spec
+ from openbb_cli.dispatchers.spec import load_spec
+
+ if len(spec_entries) == 1 and spec_entries[0][0] is None:
+ _, path = spec_entries[0]
+ return http_dispatcher_from_spec(
+ load_spec(path),
+ headers=headers,
+ query_params=query_params,
+ auth_hook=global_auth_hook,
+ )
+ from openbb_cli.dispatchers.multi import MultiSpecDispatcher
+
+ per_ns_headers = per_ns_headers or {}
+ per_ns_query = per_ns_query or {}
+ per_ns_auth_hooks = per_ns_auth_hooks or {}
+ backends: dict[str, Any] = {}
+ for name, path in spec_entries:
+ if name is None:
+ raise ValueError(
+ "multi-spec entry missing namespace; pass --spec NAME=PATH"
+ )
+ ns_headers = _merge_dicts(headers, per_ns_headers.get(name))
+ ns_query = _merge_dicts(query_params, per_ns_query.get(name))
+ ns_hook = per_ns_auth_hooks.get(name, global_auth_hook)
+ backends[name] = http_dispatcher_from_spec(
+ load_spec(path),
+ headers=ns_headers,
+ query_params=ns_query,
+ auth_hook=ns_hook,
+ namespace=name,
+ )
+ return MultiSpecDispatcher(backends)
+
+
+def _build_dispatcher(
+ server_url: str | None,
+ headers: dict[str, str] | None = None,
+ query_params: dict[str, str] | None = None,
+ spec_entries: list[tuple[str | None, str]] | None = None,
+ per_ns_headers: dict[str, dict[str, str]] | None = None,
+ per_ns_query: dict[str, dict[str, str]] | None = None,
+ global_auth_hook: Any = None,
+ per_ns_auth_hooks: dict[str, Any] | None = None,
+):
+ """Resolve a dispatcher from CLI args."""
+ if spec_entries:
+ return _build_spec_dispatcher(
+ spec_entries,
+ headers,
+ query_params,
+ per_ns_headers,
+ per_ns_query,
+ global_auth_hook=global_auth_hook,
+ per_ns_auth_hooks=per_ns_auth_hooks,
+ )
+ if server_url:
+ from openbb_cli.dispatchers.http import http_dispatcher_from_server
+
+ return http_dispatcher_from_server(
+ server_url,
+ headers=headers,
+ query_params=query_params,
+ auth_hook=global_auth_hook,
+ )
+ from openbb_cli.dispatchers import LocalDispatcher
+
+ return LocalDispatcher()
+
+
+def _launch_repl(
+ dev: bool,
+ debug: bool,
+ spec_entries: list[tuple[str | None, str]] | None = None,
+ server_url: str | None = None,
+ headers: dict[str, str] | None = None,
+ query_params: dict[str, str] | None = None,
+ per_ns_headers: dict[str, dict[str, str]] | None = None,
+ per_ns_query: dict[str, dict[str, str]] | None = None,
+ global_auth_hook: Any = None,
+ per_ns_auth_hooks: dict[str, Any] | None = None,
+ initial_command: list[str] | None = None,
+) -> int:
+ """Launch the interactive REPL."""
+ sys.stdout.write("Loading...\n")
+ sys.stdout.flush()
from openbb_cli.config.setup import bootstrap
- from openbb_cli.controllers.cli_controller import launch
+
+ backend: object | None = None
+ if spec_entries:
+ from openbb_cli.backend import SpecBackend
+
+ dispatcher = _build_spec_dispatcher(
+ spec_entries,
+ headers,
+ query_params,
+ per_ns_headers,
+ per_ns_query,
+ global_auth_hook=global_auth_hook,
+ per_ns_auth_hooks=per_ns_auth_hooks,
+ )
+ backend = SpecBackend(dispatcher._spec_doc, dispatcher)
+ elif server_url:
+ from openbb_cli.backend import SpecBackend
+ from openbb_cli.dispatchers.http import http_dispatcher_from_server
+ from openbb_cli.dispatchers.openapi_schema import fetch_openapi
+ from openbb_cli.dispatchers.spec import build_spec_document
+
+ openapi = fetch_openapi(server_url, headers=headers, query_params=query_params)
+ spec_doc = build_spec_document(openapi, base_url=server_url)
+ backend = SpecBackend(
+ spec_doc,
+ http_dispatcher_from_server(
+ server_url,
+ headers=headers,
+ query_params=query_params,
+ auth_hook=global_auth_hook,
+ ),
+ )
bootstrap()
+ from openbb_cli.controllers.cli_controller import run_cli, session
- dev = "--dev" in sys.argv[1:]
- debug = "--debug" in sys.argv[1:]
+ _apply_interactive_defaults(session.settings)
+ if debug:
+ session.settings.DEBUG_MODE = True
+ if dev:
+ session.settings.DEV_BACKEND = True
- launch(dev, debug)
+ queue: list[str] | None = None
+ if initial_command:
+ joined = " ".join(initial_command).replace(" /", "/home/")
+ if joined:
+ queue = [joined]
+ run_cli(queue, backend=backend)
+ return 0
+
+
+def _apply_interactive_defaults(settings: Any) -> None:
+ """Apply REPL-friendly defaults without clobbering explicit user choices."""
+ if getattr(settings, "OUTPUT_MODE", "tsv") == "tsv":
+ settings.OUTPUT_MODE = "rich"
+ if getattr(settings, "USE_INTERACTIVE_DF", False) is False:
+ settings.USE_INTERACTIVE_DF = True
+ settings.TEST_MODE = False
+
+
+def _generate_spec(
+ server_url: str | None,
+ output_path: str,
+ openapi_path: str | None,
+ headers: dict[str, str] | None = None,
+ query_params: dict[str, str] | None = None,
+ socrata_story: str | None = None,
+) -> int:
+ """Build a precomputed .spec file from one of the supported sources."""
+ from openbb_cli.dispatchers.spec import write_spec
+
+ if socrata_story:
+ from openbb_cli.dispatchers.socrata import build_socrata_spec
+
+ spec_doc = build_socrata_spec(socrata_story)
+ write_spec(output_path, spec_doc)
+ skipped = (spec_doc.get("_socrata") or {}).get("skipped") or []
+ sys.stdout.write(
+ f"wrote {len(spec_doc['commands'])} dataset commands to {output_path}"
+ )
+ if skipped:
+ sys.stdout.write(f" (skipped {len(skipped)} non-dataset views)")
+ sys.stdout.write("\n")
+ return 0
+
+ if not server_url:
+ sys.stderr.write(
+ "--generate-spec requires --server URL (or OPENBB_SERVER_URL env var) "
+ "or --socrata-story URL_OR_PATH.\n"
+ )
+ return 2
+
+ from openbb_cli.dispatchers.openapi_schema import fetch_openapi
+ from openbb_cli.dispatchers.spec import build_spec_document
+
+ openapi = fetch_openapi(
+ server_url, path=openapi_path, headers=headers, query_params=query_params
+ )
+ if openapi_path and (
+ openapi_path.startswith("http://") or openapi_path.startswith("https://")
+ ):
+ source_url = openapi_path
+ elif openapi_path:
+ source_url = server_url.rstrip("/") + openapi_path
+ else:
+ source_url = server_url.rstrip("/") + "/openapi.json"
+ spec_doc = build_spec_document(openapi, base_url=server_url, source_url=source_url)
+ write_spec(output_path, spec_doc)
+ sys.stdout.write(f"wrote {len(spec_doc['commands'])} commands to {output_path}\n")
+ return 0
+
+
+def _filter_spec_commands(
+ spec_doc: dict[str, Any],
+ *,
+ include: list[str] | None,
+ exclude: list[str] | None,
+) -> dict[str, Any]:
+ """Apply ``--include`` / ``--exclude`` glob filters to a spec's commands."""
+ if not include and not exclude:
+ return spec_doc
+ from fnmatch import fnmatchcase
+
+ commands: dict[str, Any] = spec_doc.get("commands") or {}
+ include_patterns = include or []
+ exclude_patterns = exclude or []
+
+ def _matches(name: str, patterns: list[str]) -> bool:
+ return any(fnmatchcase(name, pat) for pat in patterns)
+
+ filtered: dict[str, Any] = {}
+ for name, cmd in commands.items():
+ if include_patterns:
+ if _matches(name, include_patterns):
+ filtered[name] = cmd
+ continue
+ if exclude_patterns and _matches(name, exclude_patterns):
+ continue
+ filtered[name] = cmd
+ return {**spec_doc, "commands": filtered}
+
+
+def _generate_extension(
+ spec_entries: list[tuple[str | None, str]],
+ output_path: str,
+ *,
+ provider_name: str | None,
+ project_name: str | None,
+ package_name: str | None,
+ router_name: str | None,
+ include: list[str] | None = None,
+ exclude: list[str] | None = None,
+) -> int:
+ """Generate a full installable OpenBB extension from a ``.spec`` file.
+
+ Parameters
+ ----------
+ spec_entries : list of (str or None, str)
+ Resolved ``--spec`` entries; exactly one entry is required.
+ output_path : str
+ Directory to write the extension package to.
+ provider_name : str, optional
+ Snake-case provider identifier.
+ project_name : str, optional
+ PyPI distribution name.
+ package_name : str, optional
+ Python package directory name.
+ router_name : str, optional
+ Router identifier.
+ include : list of str, optional
+ Glob patterns; only matching commands are emitted. Takes priority over ``exclude``.
+ exclude : list of str, optional
+ Glob patterns; matching commands are dropped.
+
+ Returns
+ -------
+ int
+ Process exit code: 0 on success, 2 on bad input.
+ """
+ if len(spec_entries) != 1:
+ sys.stderr.write(
+ "--generate-extension requires exactly one --spec PATH (or [NAME=]PATH).\n"
+ )
+ return 2
+ _, spec_path = spec_entries[0]
+
+ from openbb_cli.codegen.package_gen import generate_packages
+ from openbb_cli.dispatchers.spec import load_spec
+
+ spec_doc = load_spec(spec_path)
+ original_count = len(spec_doc.get("commands") or {})
+ spec_doc = _filter_spec_commands(spec_doc, include=include, exclude=exclude)
+ filtered_count = len(spec_doc.get("commands") or {})
+ if (include or exclude) and filtered_count == 0:
+ sys.stderr.write(
+ "--generate-extension: --include / --exclude filters matched no "
+ f"commands (started with {original_count}). Check your patterns.\n"
+ )
+ return 2
+ if include or exclude:
+ sys.stdout.write(
+ f"filter: {filtered_count}/{original_count} commands kept "
+ f"(include={include or '-'}, exclude={exclude or '-'})\n"
+ )
+ output = Path(output_path)
+ derived_provider = provider_name or output.name or "generated_extension"
+ package_set = generate_packages(
+ spec_doc,
+ output_root=output,
+ provider_name=derived_provider,
+ project_name=project_name,
+ package_name=package_name,
+ )
+ paths = package_set.write()
+ for pkg, path in zip(package_set.packages, paths, strict=True):
+ total_get = sum(len(v) for v in pkg.fetchers_by_provider.values())
+ total_post = len(pkg.post_commands)
+ sys.stdout.write(
+ f"wrote project to {path}\n"
+ f" providers ({len(pkg.providers)}): "
+ f"{', '.join(p.provider_name for p in pkg.providers)}\n"
+ f" routers ({len(pkg.top_level_routers)}): "
+ f"{', '.join(pkg.top_level_routers)}\n"
+ f" fetchers: {total_get} GET (across all providers)\n"
+ f" POST: {total_post} local-compute commands\n"
+ f" install: pip install -e {path}\n"
+ f" build: openbb-build\n"
+ )
+ return 0
+
+
+def _run_spec_one_shot(
+ spec_entries: list[tuple[str | None, str]],
+ command_argv: list[str],
+ headers: dict[str, str] | None = None,
+ query_params: dict[str, str] | None = None,
+ per_ns_headers: dict[str, dict[str, str]] | None = None,
+ per_ns_query: dict[str, dict[str, str]] | None = None,
+ global_auth_hook: Any = None,
+ per_ns_auth_hooks: dict[str, Any] | None = None,
+) -> int:
+ """Dispatch a single command using one or more precomputed ``.spec`` files."""
+ from openbb_cli.dispatchers.protocol import Request, Response
+ from openbb_cli.dispatchers.runtime import _to_json_line
+ from openbb_cli.dispatchers.spec import SpecCommandError, parse_command_argv
+
+ if not command_argv:
+ sys.stderr.write(
+ "usage: openbb --spec [NAME=]PATH [--key value]\n"
+ )
+ return 2
+
+ dispatcher = _build_spec_dispatcher(
+ spec_entries,
+ headers,
+ query_params,
+ per_ns_headers,
+ per_ns_query,
+ global_auth_hook=global_auth_hook,
+ per_ns_auth_hooks=per_ns_auth_hooks,
+ )
+ try:
+ command, params = parse_command_argv(dispatcher._spec_doc, command_argv)
+ except SpecCommandError as exc:
+ sys.stderr.write(f"{exc}\n")
+ return 2
+
+ async def _dispatch_and_close() -> Response:
+ try:
+ return await dispatcher.dispatch(Request(command=command, params=params))
+ finally:
+ await dispatcher.aclose()
+
+ response: Response = asyncio.run(_dispatch_and_close())
+ sys.stdout.write(_to_json_line(response) + "\n")
+ sys.stdout.flush()
+ return 0 if response.ok else 1
+
+
+_CONFIG_SCALAR_KEYS = (
+ "server",
+ "openapi_path",
+ "header_file",
+ "query_param_file",
+ "output",
+ "batch_concurrency",
+)
+
+
+def _peek_flag(argv: list[str], flag: str) -> str | None:
+ """Pull ``--flag VALUE`` (or ``--flag=VALUE``) out of argv without parsing the rest."""
+ for i, tok in enumerate(argv):
+ if tok == flag and i + 1 < len(argv):
+ return argv[i + 1]
+ if tok.startswith(flag + "="):
+ return tok.split("=", 1)[1]
+ return None
+
+
+def _apply_config_defaults(
+ parser: Any, raw_argv: list[str], config: dict[str, Any]
+) -> None:
+ """Apply layered TOML config as argparse defaults for unset flags."""
+ overrides: dict[str, Any] = {}
+ for key in _CONFIG_SCALAR_KEYS:
+ value = config.get(key)
+ if value is None:
+ continue
+ existing = parser.get_default(key)
+ if existing in (None, "", []):
+ overrides[key] = value
+ if overrides:
+ parser.set_defaults(**overrides)
+
+
+def main(argv: list[str] | None = None) -> int: # noqa: PLR0911
+ """Use the main entry point for the OpenBB Platform CLI."""
+ from openbb_cli.config.loader import (
+ apply_settings_to_env,
+ load_config,
+ load_env_files,
+ )
+ from openbb_cli.dispatchers.runtime import build_parser, run_argv, run_batch
+
+ raw_argv = list(argv if argv is not None else sys.argv[1:])
+ load_env_files(_peek_flag(raw_argv, "--env-file"))
+ explicit_cfg = _peek_flag(raw_argv, "--config")
+ config = load_config(explicit_cfg)
+ apply_settings_to_env(config)
+
+ parser = build_parser()
+ _apply_config_defaults(parser, raw_argv, config)
+ args = parser.parse_args(raw_argv)
+
+ try:
+ spec_entries = _resolve_spec_entries(
+ getattr(args, "spec", []) or [],
+ config.get("specs"),
+ config_single_spec=config.get("spec"),
+ )
+ except ValueError as exc:
+ sys.stderr.write(f"--spec: {exc}\n")
+ return 2
+
+ socrata_story = getattr(args, "socrata_story", None)
+ if socrata_story and not args.generate_spec:
+ try:
+ socrata_path = _materialize_socrata_spec(socrata_story)
+ except (OSError, ValueError) as exc:
+ sys.stderr.write(f"--socrata-story: {exc}\n")
+ return 2
+ if not spec_entries:
+ spec_entries = [(None, socrata_path)]
+ else:
+ spec_entries.append(("socrata", socrata_path))
+
+ namespaces: set[str] = {n for n, _ in spec_entries if n}
+ config_specs = (
+ config.get("specs") if isinstance(config.get("specs"), dict) else None
+ )
+
+ cli_headers = list(getattr(args, "header", []) or [])
+ cli_query = list(getattr(args, "query_param", []) or [])
+ global_h_tokens, per_ns_h_tokens = _split_per_namespace(cli_headers, namespaces)
+ global_q_tokens, per_ns_q_tokens = _split_per_namespace(cli_query, namespaces)
+
+ headers = _resolve_headers(
+ global_h_tokens,
+ args.header_file,
+ config_headers=config.get("headers"),
+ )
+ if headers is None and (cli_headers or args.header_file):
+ return 2
+
+ query_params = _resolve_query_params(
+ global_q_tokens,
+ args.query_param_file,
+ config_query=config.get("query"),
+ )
+ if query_params is None and (cli_query or args.query_param_file):
+ return 2
+
+ per_ns_headers, per_ns_query = _resolve_per_ns_auth(
+ per_ns_h_tokens, per_ns_q_tokens, namespaces, config_specs
+ )
+ if per_ns_headers is None or per_ns_query is None:
+ return 2
+
+ try:
+ global_auth_hook, per_ns_auth_hooks = _resolve_auth_hooks(config, namespaces)
+ except (ValueError, ImportError, TypeError) as exc:
+ sys.stderr.write(f"auth-hook: {exc}\n")
+ return 2
+
+ if args.print_config_template:
+ from openbb_cli.config.loader import render_config_template
+
+ sys.stdout.write(render_config_template(active=config))
+ sys.stdout.flush()
+ return 0
+
+ if args.show_config:
+ sys.stdout.write(json.dumps(config, indent=2, default=str) + "\n")
+ sys.stdout.flush()
+ return 0
+
+ if args.generate_spec:
+ leftover = list(args.command or [])
+ output_path = args.output
+ if leftover:
+ if output_path == "openbb.spec":
+ output_path = leftover[0]
+ leftover = leftover[1:]
+ if leftover:
+ sys.stderr.write(
+ "--generate-spec takes no extra positional arguments after "
+ f"the output path; got {leftover!r}. Use "
+ "``--output PATH`` to be explicit.\n"
+ )
+ return 2
+ return _generate_spec(
+ args.server,
+ output_path,
+ args.openapi_path,
+ headers,
+ query_params,
+ socrata_story=getattr(args, "socrata_story", None),
+ )
+
+ if getattr(args, "generate_extension", False):
+ return _generate_extension(
+ spec_entries,
+ args.output,
+ provider_name=args.provider_name,
+ project_name=args.project_name,
+ package_name=args.package_name,
+ router_name=args.router_name,
+ include=getattr(args, "include", None),
+ exclude=getattr(args, "exclude", None),
+ )
+
+ if args.interactive:
+ return _launch_repl(
+ args.dev,
+ args.debug,
+ spec_entries,
+ args.server,
+ headers,
+ query_params,
+ per_ns_headers,
+ per_ns_query,
+ global_auth_hook=global_auth_hook,
+ per_ns_auth_hooks=per_ns_auth_hooks,
+ initial_command=args.command,
+ )
+
+ if args.list_commands or args.describe:
+ return _run_introspection(
+ spec_entries,
+ args.server,
+ headers,
+ query_params,
+ per_ns_headers,
+ per_ns_query,
+ global_auth_hook=global_auth_hook,
+ per_ns_auth_hooks=per_ns_auth_hooks,
+ list_commands=args.list_commands,
+ describe=args.describe,
+ )
+
+ if spec_entries and not args.batch:
+ return _run_spec_one_shot(
+ spec_entries,
+ args.command,
+ headers,
+ query_params,
+ per_ns_headers,
+ per_ns_query,
+ global_auth_hook=global_auth_hook,
+ per_ns_auth_hooks=per_ns_auth_hooks,
+ )
+
+ dispatcher = _build_dispatcher(
+ args.server,
+ headers=headers,
+ query_params=query_params,
+ spec_entries=spec_entries,
+ per_ns_headers=per_ns_headers,
+ per_ns_query=per_ns_query,
+ global_auth_hook=global_auth_hook,
+ per_ns_auth_hooks=per_ns_auth_hooks,
+ )
+
+ if args.batch:
+ return run_batch(dispatcher, concurrency=args.batch_concurrency)
+
+ if not args.command:
+ parser.print_help(sys.stderr)
+ return 2
+
+ spec_doc = getattr(dispatcher, "_spec_doc", None)
+ if spec_doc:
+ return _run_spec_dispatch(dispatcher, spec_doc, args.command)
+ return run_argv(dispatcher, args.command)
+
+
+def _run_spec_dispatch(
+ dispatcher: Any, spec_doc: dict[str, Any], command_argv: list[str]
+) -> int:
+ """Schema-validated one-shot dispatch reusing an existing dispatcher."""
+ from openbb_cli.dispatchers.protocol import Request, Response
+ from openbb_cli.dispatchers.runtime import _to_json_line
+ from openbb_cli.dispatchers.spec import SpecCommandError, parse_command_argv
+
+ try:
+ command, params = parse_command_argv(spec_doc, command_argv)
+ except SpecCommandError as exc:
+ sys.stderr.write(f"{exc}\n")
+ return 2
+
+ async def _dispatch() -> Response:
+ return await dispatcher.dispatch(Request(command=command, params=params))
+
+ response: Response = asyncio.run(_dispatch())
+ sys.stdout.write(_to_json_line(response) + "\n")
+ sys.stdout.flush()
+ return 0 if response.ok else 1
+
+
+def _run_introspection(
+ spec_entries: list[tuple[str | None, str]],
+ server_url: str | None,
+ headers: dict[str, str] | None,
+ query_params: dict[str, str] | None,
+ per_ns_headers: dict[str, dict[str, str]] | None,
+ per_ns_query: dict[str, dict[str, str]] | None,
+ global_auth_hook: Any = None,
+ per_ns_auth_hooks: dict[str, Any] | None = None,
+ *,
+ list_commands: bool,
+ describe: str | None,
+) -> int:
+ """Dispatch ``__commands__`` / ``__schema__`` against a spec/server."""
+ from openbb_cli.dispatchers.http import http_dispatcher_from_server
+ from openbb_cli.dispatchers.protocol import Request, Response
+ from openbb_cli.dispatchers.runtime import _to_json_line
+
+ if spec_entries:
+ dispatcher = _build_spec_dispatcher(
+ spec_entries,
+ headers,
+ query_params,
+ per_ns_headers,
+ per_ns_query,
+ global_auth_hook=global_auth_hook,
+ per_ns_auth_hooks=per_ns_auth_hooks,
+ )
+ elif server_url:
+ dispatcher = http_dispatcher_from_server(
+ server_url,
+ headers=headers,
+ query_params=query_params,
+ auth_hook=global_auth_hook,
+ )
+ else:
+ sys.stderr.write(
+ "--list-commands / --describe require --spec [NAME=]PATH or --server URL.\n"
+ )
+ return 2
+
+ if list_commands:
+ request = Request(command="__commands__")
+ else:
+ name, _, provider = (describe or "").partition(":")
+ params: dict[str, Any] = {"name": name}
+ if provider:
+ params["provider"] = provider
+ request = Request(command="__schema__", params=params)
+
+ async def _dispatch_and_close() -> Response:
+ try:
+ return await dispatcher.dispatch(request)
+ finally:
+ await dispatcher.aclose()
+
+ response: Response = asyncio.run(_dispatch_and_close())
+ sys.stdout.write(_to_json_line(response) + "\n")
+ sys.stdout.flush()
+ return 0 if response.ok else 1
if __name__ == "__main__":
initial_logging_sub_app = change_logging_sub_app()
try:
- main()
+ sys.exit(main())
+ except BrokenPipeError:
+ with contextlib.suppress(Exception):
+ sys.stdout.close()
+ sys.exit(0)
except Exception:
logging.exception("An unexpected error occurred")
+ sys.exit(1)
finally:
reset_logging_sub_app(initial_logging_sub_app)
diff --git a/cli/openbb_cli/codegen/__init__.py b/cli/openbb_cli/codegen/__init__.py
new file mode 100644
index 00000000000..ba020602ff3
--- /dev/null
+++ b/cli/openbb_cli/codegen/__init__.py
@@ -0,0 +1 @@
+"""Codegen pipeline that turns a ``.spec`` document into a full extension package."""
diff --git a/cli/openbb_cli/codegen/credentials.py b/cli/openbb_cli/codegen/credentials.py
new file mode 100644
index 00000000000..40cdcf76953
--- /dev/null
+++ b/cli/openbb_cli/codegen/credentials.py
@@ -0,0 +1,85 @@
+"""Credential detection โ separate user-facing params from auth secrets."""
+
+from __future__ import annotations
+
+from typing import Any, Literal
+
+CredentialLocation = Literal["query", "header"]
+
+_CREDENTIAL_NAMES: frozenset[str] = frozenset(
+ {
+ "api_key",
+ "apikey",
+ "x_api_key",
+ "x_apikey",
+ "key",
+ "subscription_key",
+ "ocp_apim_subscription_key",
+ "app_id",
+ "app_key",
+ "app_token",
+ "appid",
+ "client_id",
+ "client_secret",
+ "client_token",
+ "token",
+ "access_token",
+ "auth_token",
+ "x_auth_token",
+ "bearer_token",
+ "secret",
+ "secret_key",
+ "authorization",
+ "consumer_key",
+ "consumer_secret",
+ "private_key",
+ "session_token",
+ }
+)
+
+
+def normalize_credential_key(name: str) -> str:
+ """Canonicalize a parameter name for credential lookup."""
+ return name.lower().replace("-", "_").strip("_")
+
+
+def is_credential_name(name: str) -> bool:
+ """Return ``True`` if ``name`` looks like a credential (not a request param)."""
+ return normalize_credential_key(name) in _CREDENTIAL_NAMES
+
+
+def classify_parameter(param: dict[str, Any]) -> CredentialLocation | None:
+ """Return ``"query"`` / ``"header"`` for a credential param, ``None`` otherwise."""
+ name = param.get("name") if isinstance(param, dict) else None
+ if not isinstance(name, str) or not is_credential_name(name):
+ return None
+ location = param.get("in", "query")
+ return "header" if location == "header" else "query"
+
+
+def credentials_from_command(
+ cmd_spec: dict[str, Any],
+) -> dict[str, dict[str, str]]:
+ """Walk a command spec entry; return ``{canonical_key: {name, in}}``."""
+ out: dict[str, dict[str, str]] = {}
+ for param in cmd_spec.get("parameters") or []:
+ loc = classify_parameter(param)
+ if loc is None:
+ continue
+ canonical = normalize_credential_key(param["name"])
+ out[canonical] = {"name": param["name"], "in": loc}
+ return out
+
+
+def credentials_from_spec(spec_doc: dict[str, Any]) -> dict[str, dict[str, str]]:
+ """Aggregate every credential entry across an entire spec doc."""
+ out: dict[str, dict[str, str]] = {}
+ for cmd in (spec_doc.get("commands") or {}).values():
+ for canonical, entry in credentials_from_command(cmd).items():
+ out.setdefault(canonical, entry)
+ return out
+
+
+def filter_user_params(parameters: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """Strip credential params from a parameter list, leaving user-facing ones."""
+ return [p for p in parameters or [] if classify_parameter(p) is None]
diff --git a/cli/openbb_cli/codegen/fetcher_gen.py b/cli/openbb_cli/codegen/fetcher_gen.py
new file mode 100644
index 00000000000..fb2b4626b22
--- /dev/null
+++ b/cli/openbb_cli/codegen/fetcher_gen.py
@@ -0,0 +1,890 @@
+"""Generate ``QueryParams`` + ``Data`` + ``Fetcher`` modules for one spec command."""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+from typing import Any, cast
+
+from openbb_cli.codegen.credentials import (
+ credentials_from_command,
+ filter_user_params,
+)
+from openbb_cli.codegen.pydantic_gen import (
+ GeneratedClass,
+ class_name_from,
+ consolidate_imports,
+ generate_class,
+ render_function_docstring,
+ safe_field_name,
+)
+
+_PATH_TEMPLATE_RE = re.compile(r"\{([^}]+)\}")
+
+
+@dataclass
+class FetcherCommandSpec:
+ """Per-command inputs to the fetcher emitter.
+
+ Parameters
+ ----------
+ name : str
+ Dotted command path as it appears in ``spec_doc["commands"]``.
+ cmd_spec : dict
+ The spec entry.
+ base_url : str
+ The upstream API root.
+ api_prefix : str
+ Path prefix shared across every command in the spec.
+ provider_name : str
+ Snake-case provider name.
+ """
+
+ name: str
+ cmd_spec: dict[str, Any]
+ base_url: str
+ api_prefix: str
+ provider_name: str
+
+
+@dataclass
+class GeneratedFetcher:
+ """The output of ``generate_fetcher_module``.
+
+ Parameters
+ ----------
+ module_name : str
+ Snake-case module filename (without ``.py``).
+ model_name : str
+ The Pydantic model name registered in the provider's ``fetcher_dict``.
+ query_params_class : str
+ Name of the emitted ``QueryParams`` subclass.
+ data_class : str
+ Name of the emitted ``Data`` subclass.
+ fetcher_class : str
+ Name of the emitted ``Fetcher`` subclass.
+ source : str
+ Full module source ready to write to disk.
+ credentials_used : dict
+ ``{canonical_name: {"name": wire_name, "in": "query"|"header"}}``.
+ """
+
+ module_name: str
+ model_name: str
+ query_params_class: str
+ data_class: str
+ fetcher_class: str
+ source: str
+ credentials_used: dict[str, dict[str, str]] = field(default_factory=dict)
+
+
+def _module_name_from_command(name: str) -> str:
+ """Convert a dotted command path to a snake_case module identifier.
+
+ Parameters
+ ----------
+ name : str
+ Dotted command path.
+
+ Returns
+ -------
+ str
+ Snake-case identifier.
+ """
+ safe = re.sub(r"[^0-9a-zA-Z]+", "_", name).strip("_")
+ if not safe:
+ safe = "command"
+ if safe[0].isdigit():
+ safe = f"_{safe}"
+ return safe.lower()
+
+
+def _path_template_keys(url_path: str) -> list[str]:
+ """Extract ``{placeholder}`` names from a URL template, in declaration order."""
+ return [m.group(1) for m in _PATH_TEMPLATE_RE.finditer(url_path)]
+
+
+def _query_params_schema(
+ cmd_spec: dict[str, Any], provider_name: str
+) -> dict[str, Any]:
+ """Build a JSON-schema dict combining query, path, and body fields.
+
+ Parameters
+ ----------
+ cmd_spec : dict
+ The spec command entry.
+ provider_name : str
+ Provider this fetcher is being generated for.
+
+ Returns
+ -------
+ dict
+ A synthetic ``{"type": "object", "properties": {...}, "required": [...]}``.
+ """
+ properties: dict[str, Any] = {}
+ required: list[str] = []
+ for raw in filter_user_params(cmd_spec.get("parameters") or []):
+ name = raw.get("name")
+ if not name:
+ continue
+ if name == "provider":
+ continue
+ param_providers = raw.get("providers") or []
+ if param_providers and provider_name not in param_providers:
+ continue
+ description = raw.get("help") or raw.get("description")
+ if description:
+ description = re.sub(r"\s*\(provider:[^)]*\)\s*$", "", description).strip()
+ prop_schema: dict[str, Any] = {}
+ for key in ("type", "default", "choices"):
+ if key in raw and raw[key] is not None:
+ prop_schema[key] = raw[key]
+ if raw.get("in") == "path" and prop_schema.get("type") == "number":
+ prop_schema["type"] = "integer"
+ if description:
+ prop_schema["description"] = description
+ if raw.get("is_list"):
+ inner = {"type": prop_schema.get("type", "string")}
+ prop_schema = {"type": "array", "items": inner}
+ if description:
+ prop_schema["description"] = description
+ if raw.get("choices"):
+ prop_schema["enum"] = list(raw["choices"])
+ properties[name] = prop_schema
+ if raw.get("required"):
+ required.append(name)
+ body_schema = cmd_spec.get("request_body_schema") or {}
+ body_props = body_schema.get("properties") or {}
+ body_required = body_schema.get("required") or []
+ for body_name, body_prop in body_props.items():
+ if body_name not in properties:
+ properties[body_name] = body_prop
+ if body_name in body_required:
+ required.append(body_name)
+ return {
+ "type": "object",
+ "properties": properties,
+ "required": required,
+ }
+
+
+def _column_metadata_from_data_schema(
+ data_schema: dict[str, Any],
+) -> dict[str, dict[str, Any]]:
+ """Pull per-column ``description`` / ``socrata_format`` out of the row schema."""
+ properties = (data_schema or {}).get("properties") or {}
+ out: dict[str, dict[str, Any]] = {}
+ for field_name, schema in properties.items():
+ if not isinstance(schema, dict):
+ continue
+ meta: dict[str, Any] = {}
+ if schema.get("description"):
+ meta["description"] = schema["description"]
+ if schema.get("format"):
+ meta["format"] = schema["format"]
+ if schema.get("socrata_format"):
+ meta["socrata_format"] = schema["socrata_format"]
+ if meta:
+ out[field_name] = meta
+ return out
+
+
+def _data_schema(cmd_spec: dict[str, Any]) -> dict[str, Any]:
+ """Pull the per-row data schema out of a response_schema.
+
+ Parameters
+ ----------
+ cmd_spec : dict
+ The spec command entry.
+
+ Returns
+ -------
+ dict
+ The schema that should drive the ``Data`` Pydantic class.
+ """
+ response = cmd_spec.get("response_schema") or {}
+ if not isinstance(response, dict):
+ return {}
+ properties = response.get("properties") or {}
+ results = properties.get("results")
+ if isinstance(results, dict):
+ non_null = [
+ v
+ for v in results.get("anyOf") or [results]
+ if isinstance(v, dict) and v.get("type") != "null"
+ ]
+ if non_null:
+ inner = non_null[0]
+ if inner.get("type") == "array" and isinstance(inner.get("items"), dict):
+ inner = inner["items"]
+ if isinstance(inner.get("oneOf"), list):
+ concrete = [c for c in inner["oneOf"] if isinstance(c, dict)]
+ if concrete:
+ inner = concrete[0]
+ return inner
+ return _unwrap_schema_envelopes(response)
+
+
+def _unwrap_schema_envelopes(schema: Any) -> dict[str, Any]:
+ """Strip schema envelopes that ``unpack_response`` strips at runtime."""
+ if not isinstance(schema, dict):
+ return {}
+ typed: dict[str, Any] = schema
+ if typed.get("type") == "array":
+ return _unwrap_schema_envelopes(typed.get("items"))
+ combinator_target = _first_non_null_combinator_variant(typed)
+ if combinator_target is not None:
+ return _unwrap_schema_envelopes(combinator_target)
+ if _is_scalar_schema(typed):
+ return _wrap_scalar_as_value(typed)
+ props = typed.get("properties")
+ if not isinstance(props, dict) or not props:
+ return typed
+ inner = _envelope_inner_schema(typed, props)
+ if inner is not None:
+ return _unwrap_schema_envelopes(inner)
+ return typed
+
+
+def _first_non_null_combinator_variant(schema: dict[str, Any]) -> dict[str, Any] | None:
+ """Return the first non-null variant of a top-level ``oneOf`` / ``anyOf``."""
+ for combinator in ("oneOf", "anyOf"):
+ variants = schema.get(combinator)
+ if not isinstance(variants, list):
+ continue
+ for variant in variants:
+ if isinstance(variant, dict) and variant.get("type") != "null":
+ return cast(dict[str, Any], variant)
+ return None
+
+
+def _envelope_inner_schema(
+ typed: dict[str, Any], # noqa: ARG001
+ props: dict[str, Any],
+) -> dict[str, Any] | None:
+ """Resolve the inner row schema when ``typed`` is a recognized envelope."""
+ if len(props) == 1:
+ only_value = next(iter(props.values()))
+ if isinstance(only_value, dict):
+ only_dict = cast(dict[str, Any], only_value)
+ if only_dict.get("type") == "array":
+ items = only_dict.get("items")
+ return cast(dict[str, Any], items) if isinstance(items, dict) else None
+ if (
+ only_dict.get("properties")
+ or only_dict.get("oneOf")
+ or only_dict.get("anyOf")
+ ):
+ return only_dict
+ array_keys = [
+ k for k, v in props.items() if isinstance(v, dict) and v.get("type") == "array"
+ ]
+ if len(array_keys) == 1:
+ items = props[array_keys[0]].get("items")
+ if isinstance(items, dict):
+ return cast(dict[str, Any], items)
+ return None
+
+
+def _is_scalar_schema(schema: dict[str, Any]) -> bool:
+ """Return True when the schema represents a primitive (string/number/etc.)."""
+ return schema.get("type") in {
+ "string",
+ "integer",
+ "number",
+ "boolean",
+ } and not schema.get("properties")
+
+
+def _wrap_scalar_as_value(schema: dict[str, Any]) -> dict[str, Any]:
+ """Promote a scalar schema to ``{type: object, properties: {value: scalar}}``."""
+ return {
+ "type": "object",
+ "properties": {"value": schema},
+ "required": ["value"],
+ }
+
+
+def _credential_lookup_lines(
+ creds: dict[str, dict[str, str]], provider_name: str
+) -> list[str]:
+ """Render ``api_key = creds.get("_api_key", "")`` lines.
+
+ Parameters
+ ----------
+ creds : dict
+ Mapping returned by ``credentials_from_command``.
+ provider_name : str
+ Snake-case provider name used as the key prefix.
+
+ Returns
+ -------
+ list of str
+ One assignment per credential, in canonical-name order.
+ """
+ lines: list[str] = []
+ for canonical in sorted(creds):
+ var = f"_cred_{canonical}"
+ full_key = f"{provider_name}_{canonical}"
+ lines.append(f' {var} = _creds.get("{full_key}", "")')
+ return lines
+
+
+def _query_dict_construction(
+ cmd_spec: dict[str, Any],
+ creds: dict[str, dict[str, str]],
+ path_params: list[str],
+ provider_name: str,
+) -> str:
+ """Compose the ``query_dict = {...}`` block emitted in ``aextract_data``.
+
+ Parameters
+ ----------
+ cmd_spec : dict
+ The spec command entry.
+ creds : dict
+ Credential entries.
+ path_params : list of str
+ URL-template placeholders.
+
+ Returns
+ -------
+ str
+ The Python source for the ``query_dict`` assignment.
+ """
+ body_props = (cmd_spec.get("request_body_schema") or {}).get("properties") or {}
+ excluded = set(path_params) | set(body_props)
+ range_params = [
+ p
+ for p in (cmd_spec.get("parameters") or [])
+ if isinstance(p, dict) and p.get("_socrata_op") in {"date_min", "date_max"}
+ ]
+ excluded.update(p["name"] for p in range_params)
+ if excluded:
+ excluded_literal = (
+ "{" + ", ".join(repr(name) for name in sorted(excluded)) + "}"
+ )
+ dump = (
+ f" _query_dict = query.model_dump("
+ f"by_alias=True, exclude={excluded_literal}, exclude_none=True)"
+ )
+ else:
+ dump = (
+ " _query_dict = query.model_dump(by_alias=True, exclude_none=True)"
+ )
+ lines = [dump]
+ declares_provider = any(
+ isinstance(p, dict) and p.get("name") == "provider"
+ for p in (cmd_spec.get("parameters") or [])
+ )
+ if declares_provider:
+ lines.append(f' _query_dict["provider"] = {provider_name!r}')
+ for canonical, info in creds.items():
+ if info["in"] != "query":
+ continue
+ var = f"_cred_{canonical}"
+ wire_name = info["name"]
+ lines.append(f" if {var}:")
+ lines.append(f" _query_dict[{wire_name!r}] = {var}")
+ if range_params:
+ lines.extend(_socrata_where_clause_lines(range_params))
+ date_column = next(
+ (p["_socrata_column"] for p in range_params if p.get("_socrata_column")),
+ None,
+ )
+ if date_column:
+ lines.append(
+ f" _query_dict.setdefault('$order', {date_column!r} + ' DESC')"
+ )
+ rename_pairs: list[tuple[str, str]] = []
+ for param in cmd_spec.get("parameters") or []:
+ if not isinstance(param, dict):
+ continue
+ wire_name = param.get("wire_name")
+ if wire_name and wire_name != param.get("name"):
+ rename_pairs.append((param["name"], wire_name))
+ if rename_pairs:
+ lines.append(" for _src, _dst in (")
+ for src, dst in rename_pairs:
+ lines.append(f" ({src!r}, {dst!r}),")
+ lines.append(" ):")
+ lines.append(" if _src in _query_dict:")
+ lines.append(" _query_dict[_dst] = _query_dict.pop(_src)")
+ return "\n".join(lines)
+
+
+def _socrata_where_clause_lines(range_params: list[dict[str, Any]]) -> list[str]:
+ """Compose runtime lines that fold Socrata range params into ``$where``."""
+ field_lookups: list[str] = []
+ for param in range_params:
+ wire_name = param["name"]
+ column = param["_socrata_column"]
+ op = ">=" if param["_socrata_op"] == "date_min" else "<="
+ py_field, _ = safe_field_name(wire_name)
+ var = f"_range_{py_field}"
+ field_lookups.append(
+ f" {var} = getattr(query, {py_field!r}, None)\n"
+ f" if {var} is not None:\n"
+ f" _socrata_where_parts.append("
+ f'f"{column} {op} \'" + str({var}) + "\'")'
+ )
+ out: list[str] = [" _socrata_where_parts: list[str] = []"]
+ out.extend(field_lookups)
+ out.append(" if _socrata_where_parts:")
+ out.append(" _existing_where = _query_dict.pop('$where', None)")
+ out.append(" _socrata_where = ' AND '.join(_socrata_where_parts)")
+ out.append(" if _existing_where:")
+ out.append(
+ " _socrata_where = f'({_existing_where}) AND ({_socrata_where})'"
+ )
+ out.append(" _query_dict['$where'] = _socrata_where")
+ return out
+
+
+def _header_dict_construction(creds: dict[str, dict[str, str]]) -> str:
+ """Compose the ``_headers = {...}`` block for credential headers.
+
+ Parameters
+ ----------
+ creds : dict
+ Credential entries.
+
+ Returns
+ -------
+ str
+ Python source for the headers dict.
+ """
+ lines = [" _headers: dict[str, str] = {}"]
+ for canonical, info in creds.items():
+ if info["in"] != "header":
+ continue
+ var = f"_cred_{canonical}"
+ wire_name = info["name"]
+ lines.append(f" if {var}:")
+ lines.append(f" _headers[{wire_name!r}] = {var}")
+ return "\n".join(lines)
+
+
+def _resolve_url_path(api_prefix: str, url_path: str) -> str:
+ """Combine api_prefix with url_path; the prefix is dropped if already present."""
+ prefix = ("/" + api_prefix.strip("/")) if api_prefix.strip("/") else ""
+ path = url_path if url_path.startswith("/") else "/" + url_path
+ if prefix and path.startswith(prefix + "/"):
+ return path
+ if prefix:
+ return prefix + path if not path.startswith(prefix) else path
+ return path
+
+
+def generate_fetcher_module(spec: FetcherCommandSpec) -> GeneratedFetcher:
+ """Render the full three-class fetcher module for one spec command.
+
+ Parameters
+ ----------
+ spec : FetcherCommandSpec
+ The command-level inputs (see ``FetcherCommandSpec``).
+
+ Returns
+ -------
+ GeneratedFetcher
+ The emitted source plus metadata the package builder needs to
+ register the fetcher in the provider's ``fetcher_dict``.
+ """
+ module_name = _module_name_from_command(spec.name)
+ model_name = class_name_from(spec.name)
+ qp_class = f"{model_name}QueryParams"
+ data_class = f"{model_name}Data"
+ fetcher_class = f"{model_name}Fetcher"
+
+ qp_schema = _query_params_schema(spec.cmd_spec, spec.provider_name)
+ data_schema = _data_schema(spec.cmd_spec) or {"type": "object"}
+
+ qp_generated = generate_class(
+ qp_schema,
+ class_name=qp_class,
+ base_class="QueryParams",
+ docstring=f"Query parameters for {spec.name}.",
+ )
+ data_generated = generate_class(
+ data_schema,
+ class_name=data_class,
+ base_class="Data",
+ docstring=f"Response row for {spec.name}.",
+ )
+
+ creds = credentials_from_command(spec.cmd_spec)
+ path_params = _path_template_keys(spec.cmd_spec.get("url_path") or "")
+
+ imports = {
+ "from typing import Any",
+ "from openbb_core.app.model.abstract.error import OpenBBError",
+ "from openbb_core.provider.abstract.annotated_result import AnnotatedResult",
+ "from openbb_core.provider.abstract.data import Data",
+ "from openbb_core.provider.abstract.fetcher import Fetcher",
+ "from openbb_core.provider.abstract.query_params import QueryParams",
+ "from openbb_core.provider.utils.helpers import "
+ "get_async_requests_session, get_querystring",
+ "from pydantic import BaseModel, Field",
+ "from ....utils import safe_json_loads, unpack_response",
+ }
+ imports.update(qp_generated.collect_imports())
+ imports.update(data_generated.collect_imports())
+
+ method = (spec.cmd_spec.get("method") or "get").lower()
+ full_url_path = _resolve_url_path(
+ spec.api_prefix, spec.cmd_spec.get("url_path") or ""
+ )
+ base_url = spec.base_url.rstrip("/")
+ description = (spec.cmd_spec.get("description") or f"Fetch {spec.name}.").strip()
+
+ cred_lines = _credential_lookup_lines(creds, spec.provider_name)
+ query_block = _query_dict_construction(
+ spec.cmd_spec, creds, path_params, spec.provider_name
+ )
+ header_block = _header_dict_construction(creds)
+
+ source = _render_fetcher_source(
+ spec=spec,
+ qp_class=qp_class,
+ qp_classes=qp_generated.flatten(),
+ data_class=data_class,
+ data_classes=data_generated.flatten(),
+ fetcher_class=fetcher_class,
+ model_name=model_name,
+ method=method,
+ url_path_template=full_url_path,
+ base_url=base_url,
+ path_params=path_params,
+ cred_lines=cred_lines,
+ query_block=query_block,
+ header_block=header_block,
+ creds=creds,
+ description=description,
+ imports=consolidate_imports(imports),
+ column_metadata=_column_metadata_from_data_schema(data_schema),
+ )
+
+ return GeneratedFetcher(
+ module_name=module_name,
+ model_name=model_name,
+ query_params_class=qp_class,
+ data_class=data_class,
+ fetcher_class=fetcher_class,
+ source=source,
+ credentials_used=creds,
+ )
+
+
+def _render_fetcher_source(
+ *,
+ spec: FetcherCommandSpec,
+ qp_class: str,
+ qp_classes: list[GeneratedClass],
+ data_class: str,
+ data_classes: list[GeneratedClass],
+ fetcher_class: str,
+ model_name: str,
+ method: str,
+ url_path_template: str,
+ base_url: str,
+ path_params: list[str],
+ cred_lines: list[str],
+ query_block: str,
+ header_block: str,
+ creds: dict[str, dict[str, str]],
+ description: str,
+ imports: list[str],
+ column_metadata: dict[str, dict[str, Any]] | None = None,
+) -> str:
+ """Format the final fetcher module source.
+
+ Parameters
+ ----------
+ spec : FetcherCommandSpec
+ Original command input.
+ qp_class, data_class, fetcher_class : str
+ Class names emitted in the module.
+ qp_classes, data_classes : list of GeneratedClass
+ Pre-flattened lists of emitted classes.
+ model_name : str
+ The Pydantic model key referenced by ``@router.command(model=...)``.
+ method : str
+ HTTP verb.
+ url_path_template : str
+ URL path with placeholders intact.
+ base_url : str
+ Upstream API root, no trailing slash.
+ path_params : list of str
+ Names of placeholders in ``url_path_template``.
+ cred_lines : list of str
+ Credential-extraction source lines.
+ query_block, header_block : str
+ Pre-rendered query-dict and headers-dict source.
+ creds : dict
+ Credential entries.
+ description : str
+ First paragraph of the spec command's description.
+ imports : list of str
+ All imports the module needs, already sorted.
+
+ Returns
+ -------
+ str
+ The full module source text.
+ """
+ stdlib_imports = [
+ i
+ for i in imports
+ if i.startswith(
+ ("from typing", "from datetime", "import typing", "import datetime")
+ )
+ ]
+ third_party_imports = [i for i in imports if i not in stdlib_imports]
+
+ parts: list[str] = []
+ parts.append(
+ f'"""Fetcher for {spec.name} โ generated from spec.\n\n'
+ f"Hits ``{base_url}{url_path_template}`` via HTTP "
+ f'{method.upper()}.\n"""'
+ )
+ parts.append("")
+ if stdlib_imports:
+ parts.extend(stdlib_imports)
+ parts.append("")
+ if third_party_imports:
+ parts.extend(third_party_imports)
+ parts.append("")
+ parts.append("")
+
+ for cls in qp_classes:
+ parts.append(cls.source)
+ for cls in data_classes:
+ parts.append(cls.source)
+
+ fetcher_doc = render_function_docstring(
+ description.split("\n", 1)[0],
+ )
+
+ transform_query_doc = render_function_docstring(
+ "Validate raw input into typed query parameters.",
+ parameters=[
+ ("params", "dict[str, Any]", "Raw user input (CLI flags / API body)."),
+ ],
+ returns=(qp_class, "Validated query parameters."),
+ indent=" ",
+ )
+
+ aextract_doc_params: list[tuple[str, str, str | None]] = [
+ ("query", qp_class, "Validated query parameters."),
+ (
+ "credentials",
+ "dict[str, str] | None",
+ "Provider credentials registered on the ``Provider`` instance.",
+ ),
+ ("**kwargs", "Any", "Forwarded by the provider runtime; unused."),
+ ]
+ aextract_doc = render_function_docstring(
+ f"Fetch from {base_url}{url_path_template} and split rows from metadata.",
+ parameters=aextract_doc_params,
+ returns=(
+ "dict[str, Any]",
+ (
+ "``{'rows': [...], 'metadata': {...}}`` โ single-element list "
+ "and single-key envelope wrappers are stripped at the response "
+ "boundary, scalar fields beside the data array become metadata."
+ ),
+ ),
+ indent=" ",
+ )
+
+ transform_data_doc = render_function_docstring(
+ f"Type the unpacked rows as {data_class}; surface metadata via AnnotatedResult.",
+ parameters=[
+ ("query", qp_class, "The validated query (unused but provided)."),
+ ("data", "dict[str, Any]", "Output of ``aextract_data``."),
+ ("**kwargs", "Any", "Forwarded by the provider runtime; unused."),
+ ],
+ returns=(
+ f"list[{data_class}] | AnnotatedResult[list[{data_class}]]",
+ (
+ "Typed rows; wrapped in ``AnnotatedResult`` when the response "
+ "carried metadata alongside the data array."
+ ),
+ ),
+ indent=" ",
+ )
+
+ has_path_params = bool(path_params)
+ has_query_creds = any(info["in"] == "query" for info in creds.values())
+
+ body_construction = _aextract_body(
+ method=method,
+ base_url=base_url,
+ url_path_template=url_path_template,
+ path_params=path_params,
+ cred_lines=cred_lines,
+ query_block=query_block,
+ header_block=header_block,
+ body_field_names=list(
+ (spec.cmd_spec.get("request_body_schema") or {}).get("properties") or {}
+ ),
+ has_path_params=has_path_params,
+ has_query_creds=has_query_creds,
+ )
+
+ fetcher_source_lines = [
+ f"class {fetcher_class}(Fetcher[{qp_class}, list[{data_class}]]):",
+ fetcher_doc,
+ "",
+ " @staticmethod",
+ f" def transform_query(params: dict[str, Any]) -> {qp_class}:",
+ transform_query_doc,
+ f" return {qp_class}(**params)",
+ "",
+ " @staticmethod",
+ " async def aextract_data(",
+ f" query: {qp_class},",
+ " credentials: dict[str, str] | None,",
+ " **kwargs: Any,",
+ " ) -> dict[str, Any]:",
+ aextract_doc,
+ " _creds = credentials or {}",
+ ]
+ fetcher_source_lines.extend(body_construction)
+ column_meta_literal = repr(column_metadata) if column_metadata else None
+ transform_data_lines = [
+ "",
+ " @staticmethod",
+ " def transform_data(",
+ f" query: {qp_class},",
+ " data: dict[str, Any],",
+ " **kwargs: Any,",
+ f" ) -> list[{data_class}] | AnnotatedResult[list[{data_class}]]:",
+ transform_data_doc,
+ f" _typed = [{data_class}(**row) for row in data['rows']]",
+ " _metadata = dict(data.get('metadata') or {})",
+ ]
+ if column_meta_literal:
+ transform_data_lines.append(f" _column_metadata = {column_meta_literal}")
+ transform_data_lines.append(
+ " _metadata.setdefault('columns', _column_metadata)"
+ )
+ transform_data_lines.extend(
+ [
+ " if _metadata:",
+ " return AnnotatedResult(result=_typed, metadata=_metadata)",
+ " return _typed",
+ "",
+ ]
+ )
+ fetcher_source_lines.extend(transform_data_lines)
+ parts.append("\n".join(fetcher_source_lines))
+
+ return "\n".join(parts).rstrip() + "\n"
+
+
+def _aextract_body(
+ *,
+ method: str,
+ base_url: str,
+ url_path_template: str,
+ path_params: list[str],
+ cred_lines: list[str],
+ query_block: str,
+ header_block: str,
+ body_field_names: list[str],
+ has_path_params: bool,
+ has_query_creds: bool,
+) -> list[str]:
+ """Render the body of ``aextract_data`` as a list of source lines.
+
+ Parameters
+ ----------
+ method : str
+ ``"get"`` or ``"post"``.
+ base_url : str
+ Upstream API root, no trailing slash.
+ url_path_template : str
+ URL path with ``{placeholder}`` substitutions intact.
+ path_params : list of str
+ Placeholder names in ``url_path_template``.
+ cred_lines : list of str
+ Credential-extraction source lines.
+ query_block : str
+ Pre-rendered query-dict construction block.
+ header_block : str
+ Pre-rendered headers-dict construction block.
+ body_field_names : list of str
+ Names of fields that should travel in the JSON request body.
+ has_path_params : bool
+ Whether the URL has placeholders to substitute.
+ has_query_creds : bool
+ Whether any credential lives in the query string.
+
+ Returns
+ -------
+ list of str
+ Indented lines forming the body of ``aextract_data``.
+ """
+ lines: list[str] = list(cred_lines)
+ if has_path_params:
+ sub_args = ", ".join(f"{p}=getattr(query, {p!r})" for p in path_params)
+ lines.append(f" _path = {url_path_template!r}.format({sub_args})")
+ else:
+ lines.append(f" _path = {url_path_template!r}")
+ lines.append("")
+ lines.append(query_block)
+ lines.append(" _query_string = get_querystring(_query_dict, [])")
+ lines.append(
+ f' _url = f"{base_url}{{_path}}" + ("?" + _query_string if _query_string else "")'
+ )
+ lines.append("")
+ lines.append(header_block)
+ lines.append("")
+ if method == "post":
+ body_literal = (
+ "{"
+ + ", ".join(
+ f"{name!r}: getattr(query, {safe_field_name(name)[0]!r}, None)"
+ for name in body_field_names
+ )
+ + "}"
+ )
+ lines.append(f" _body = {body_literal}")
+ lines.append(' _method = "POST"')
+ lines.append(' _request_kwargs: dict[str, Any] = {"json": _body}')
+ else:
+ lines.append(' _method = "GET"')
+ lines.append(" _request_kwargs: dict[str, Any] = {}")
+ lines.append(" async with await get_async_requests_session() as _session:")
+ lines.append(
+ " async with await _session.request("
+ "_method, _url, headers=_headers, **_request_kwargs) as _resp:"
+ )
+ lines.append(
+ " _ct = (_resp.headers.get('Content-Type') or '').lower()"
+ )
+ lines.append(" _text = await _resp.text()")
+ lines.append(" if _resp.status >= 400:")
+ lines.append(
+ " raise OpenBBError("
+ "f'HTTP {_resp.status} from {_url}: {_text[:500]}')"
+ )
+ lines.append(
+ " if 'json' in _ct or _text.lstrip().startswith(('{', '[')):"
+ )
+ lines.append(" try:")
+ lines.append(" _payload = safe_json_loads(_text)")
+ lines.append(" except ValueError as _exc:")
+ lines.append(
+ " raise OpenBBError("
+ "f'Upstream {_url} returned malformed JSON: {_exc}') from _exc"
+ )
+ lines.append(" else:")
+ lines.append(
+ " return {'rows': [{'content': _text, "
+ "'content_type': _ct}], 'metadata': {}}"
+ )
+ lines.append(" _rows, _metadata = unpack_response(_payload)")
+ lines.append(" return {'rows': _rows, 'metadata': _metadata}")
+ return lines
diff --git a/cli/openbb_cli/codegen/namespace_tree.py b/cli/openbb_cli/codegen/namespace_tree.py
new file mode 100644
index 00000000000..d50641123c5
--- /dev/null
+++ b/cli/openbb_cli/codegen/namespace_tree.py
@@ -0,0 +1,158 @@
+"""Group spec commands into a hierarchical namespace tree."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any
+
+
+@dataclass
+class NamespaceNode:
+ """One node in the per-spec namespace tree.
+
+ Parameters
+ ----------
+ name : str
+ The leaf segment of this node's dotted path.
+ full_path : str
+ The full dotted path from the root.
+ cmd_spec : dict, optional
+ The spec command entry when this node is a leaf command.
+ children : dict[str, NamespaceNode]
+ Sub-nodes keyed by their ``name``.
+ """
+
+ name: str
+ full_path: str
+ cmd_spec: dict[str, Any] | None = None
+ children: dict[str, NamespaceNode] = field(default_factory=dict)
+
+ @property
+ def is_command(self) -> bool:
+ """Whether this node corresponds to an actual spec command."""
+ return self.cmd_spec is not None
+
+ @property
+ def is_namespace(self) -> bool:
+ """Whether this node has nested children (acts as a menu / sub-router)."""
+ return bool(self.children)
+
+
+def build_namespace_tree(commands: dict[str, dict[str, Any]]) -> NamespaceNode:
+ """Build a hierarchical tree from the spec's flat command dict.
+
+ Parameters
+ ----------
+ commands : dict
+ Mapping of dotted command path to spec entry.
+
+ Returns
+ -------
+ NamespaceNode
+ Synthetic root node whose ``children`` are the top-level namespaces.
+ """
+ root = NamespaceNode(name="", full_path="")
+ for dotted, cmd_spec in commands.items():
+ if not dotted:
+ continue
+ parts = dotted.split(".")
+ node = root
+ path_so_far: list[str] = []
+ for segment in parts:
+ path_so_far.append(segment)
+ child = node.children.get(segment)
+ if child is None:
+ child = NamespaceNode(name=segment, full_path=".".join(path_so_far))
+ node.children[segment] = child
+ node = child
+ node.cmd_spec = cmd_spec
+ return root
+
+
+def filter_tree_by_provider(root: NamespaceNode, provider: str) -> NamespaceNode:
+ """Return a copy of ``root`` keeping only commands that include ``provider``.
+
+ Parameters
+ ----------
+ root : NamespaceNode
+ The full namespace tree.
+ provider : str
+ Provider identifier to filter by.
+
+ Returns
+ -------
+ NamespaceNode
+ New tree containing only the surviving commands.
+ """
+ return _filter_node(root, lambda providers: provider in providers) or NamespaceNode(
+ name="", full_path=""
+ )
+
+
+def filter_tree_local_only(root: NamespaceNode) -> NamespaceNode:
+ """Return a copy of ``root`` keeping only commands with no provider list.
+
+ Parameters
+ ----------
+ root : NamespaceNode
+ The full namespace tree.
+
+ Returns
+ -------
+ NamespaceNode
+ New tree containing only commands whose ``providers`` list is empty.
+ """
+ return _filter_node(root, lambda providers: not providers) or NamespaceNode(
+ name="", full_path=""
+ )
+
+
+def _filter_node(node: NamespaceNode, predicate) -> NamespaceNode | None:
+ """Recursive helper that keeps commands matching ``predicate(providers)``."""
+ keep_self = False
+ new_cmd: dict[str, Any] | None = None
+ if node.cmd_spec is not None:
+ providers_list = node.cmd_spec.get("providers") or []
+ if predicate(providers_list):
+ keep_self = True
+ new_cmd = node.cmd_spec
+
+ new_children: dict[str, NamespaceNode] = {}
+ for name, child in node.children.items():
+ kept = _filter_node(child, predicate)
+ if kept is not None:
+ new_children[name] = kept
+
+ if not keep_self and not new_children:
+ return None
+
+ return NamespaceNode(
+ name=node.name,
+ full_path=node.full_path,
+ cmd_spec=new_cmd,
+ children=new_children,
+ )
+
+
+def iter_commands(root: NamespaceNode) -> list[tuple[str, dict[str, Any]]]:
+ """Walk the tree and return ``[(dotted_path, cmd_spec), ...]`` for every command."""
+ out: list[tuple[str, dict[str, Any]]] = []
+ _walk_commands(root, out)
+ return out
+
+
+def _walk_commands(node: NamespaceNode, out: list[tuple[str, dict[str, Any]]]) -> None:
+ """Depth-first command collector."""
+ if node.cmd_spec is not None:
+ out.append((node.full_path, node.cmd_spec))
+ for child in node.children.values():
+ _walk_commands(child, out)
+
+
+def providers_from_tree(root: NamespaceNode) -> set[str]:
+ """Collect every provider mentioned across the tree's commands."""
+ out: set[str] = set()
+ for _, cmd_spec in iter_commands(root):
+ for p in cmd_spec.get("providers") or []:
+ out.add(str(p))
+ return out
diff --git a/cli/openbb_cli/codegen/package_gen.py b/cli/openbb_cli/codegen/package_gen.py
new file mode 100644
index 00000000000..0360e571ef6
--- /dev/null
+++ b/cli/openbb_cli/codegen/package_gen.py
@@ -0,0 +1,560 @@
+"""Top-level codegen orchestrator: spec doc to one installable project."""
+
+from __future__ import annotations
+
+import re
+import shutil
+import subprocess
+import sys
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from openbb_cli.codegen.fetcher_gen import (
+ FetcherCommandSpec,
+ GeneratedFetcher,
+ generate_fetcher_module,
+)
+from openbb_cli.codegen.namespace_tree import (
+ build_namespace_tree,
+ iter_commands,
+ providers_from_tree,
+)
+from openbb_cli.codegen.post_gen import (
+ GeneratedPostCommand,
+ PostCommandSpec,
+ generate_post_command_module,
+)
+from openbb_cli.codegen.project_gen import GeneratedProject, generate_pyproject
+from openbb_cli.codegen.provider_gen import GeneratedProvider, generate_provider_module
+from openbb_cli.codegen.router_gen import (
+ GeneratedRouter,
+ GeneratedRouters,
+ generate_routers,
+)
+from openbb_cli.codegen.test_gen import GeneratedTestModule, generate_provider_tests
+
+
+@dataclass
+class GeneratedPackage:
+ """One installable project that bundles every provider in the spec.
+
+ Parameters
+ ----------
+ root : Path
+ Project root directory.
+ project : GeneratedProject
+ ``pyproject.toml`` source.
+ providers : list of GeneratedProvider
+ One provider registration module per declared spec provider.
+ routers : GeneratedRouters
+ Shared router modules.
+ fetchers_by_provider : dict
+ ``{provider_name: [GeneratedFetcher, ...]}``.
+ post_commands : list of GeneratedPostCommand
+ Local-compute POST endpoints.
+ top_level_routers : list of str
+ Top-level namespace names registered as ``openbb_core_extension`` entry points.
+ """
+
+ root: Path
+ project: GeneratedProject
+ providers: list[GeneratedProvider]
+ routers: GeneratedRouters
+ fetchers_by_provider: dict[str, list[GeneratedFetcher]]
+ post_commands: list[GeneratedPostCommand] = field(default_factory=list)
+ top_level_routers: list[str] = field(default_factory=list)
+ root_namespace: str = ""
+ base_url: str = ""
+ commands_by_provider: dict[str, list[str]] = field(default_factory=dict)
+ test_modules: list[GeneratedTestModule] = field(default_factory=list)
+
+ def write(self) -> Path:
+ """Materialize the project on disk under ``self.root``.
+
+ Returns
+ -------
+ Path
+ ``self.root``.
+ """
+ pkg = self.root / self.project.package_name
+ routers_dir = pkg / "routers"
+ providers_dir = pkg / "providers"
+ routers_dir.mkdir(parents=True, exist_ok=True)
+ providers_dir.mkdir(parents=True, exist_ok=True)
+
+ for path in (pkg, routers_dir, providers_dir):
+ init = path / "__init__.py"
+ if not init.exists():
+ init.write_text(f'"""{path.name} subpackage."""\n')
+
+ (pkg / "utils.py").write_text(RUNTIME_UTILS_SOURCE)
+
+ for r in self.routers.routers:
+ (routers_dir / f"{r.module_name}.py").write_text(r.source)
+
+ for prov in self.providers:
+ prov_dir = providers_dir / prov.provider_name
+ models_dir = prov_dir / "models"
+ models_dir.mkdir(parents=True, exist_ok=True)
+ for path in (prov_dir, models_dir):
+ init = path / "__init__.py"
+ if not init.exists():
+ init.write_text(f'"""{path.name} subpackage."""\n')
+ (prov_dir / "__init__.py").write_text(prov.source)
+ for fetcher in self.fetchers_by_provider.get(prov.provider_name, []):
+ (models_dir / f"{fetcher.module_name}.py").write_text(fetcher.source)
+
+ if self.post_commands:
+ tools_models = providers_dir / "tools" / "models"
+ tools_models.mkdir(parents=True, exist_ok=True)
+ for path in (providers_dir / "tools", tools_models):
+ init = path / "__init__.py"
+ if not init.exists(): # pragma: no cover
+ init.write_text(f'"""{path.name} subpackage."""\n')
+ for post in self.post_commands:
+ (tools_models / f"{post.module_name}.py").write_text(post.source)
+
+ if self.test_modules:
+ tests_dir = self.root / "tests"
+ tests_dir.mkdir(parents=True, exist_ok=True)
+ init = tests_dir / "__init__.py"
+ if not init.exists():
+ init.write_text('"""Auto-generated tests."""\n')
+ for tm in self.test_modules:
+ (tests_dir / f"{tm.module_name}.py").write_text(tm.source)
+
+ (self.root / "pyproject.toml").write_text(self.project.source)
+ (self.root / "README.md").write_text(self._readme())
+ _apply_ruff(self.root)
+ return self.root
+
+ def _readme(self) -> str:
+ """Render a README that describes the actual generated surface."""
+ ns = self.root_namespace or self.project.package_name
+ total_get = sum(len(v) for v in self.fetchers_by_provider.values())
+ total_post = len(self.post_commands)
+ source = self.base_url or "the source OpenAPI spec"
+
+ sections: list[str] = []
+ sections.append(f"# {self.project.project_name}\n")
+ sections.append(
+ f"OpenBB Platform extension generated from {source}. "
+ f"Adds {total_get} GET command{'s' if total_get != 1 else ''}"
+ + (
+ f" and {total_post} POST command{'s' if total_post != 1 else ''}"
+ if total_post
+ else ""
+ )
+ + f" under `obb.{ns}.*`.\n"
+ )
+
+ sections.append("## Install\n")
+ sections.append("```bash")
+ sections.append("pip install -e .")
+ sections.append("openbb-build")
+ sections.append("```\n")
+
+ sections.append("## Quick start\n")
+ first_cmd = self._first_command_example(ns)
+ if first_cmd is not None:
+ dotted, model = first_cmd
+ sections.append("```python")
+ sections.append("from openbb import obb")
+ sections.append("")
+ sections.append(f"# {model}")
+ sections.append(f"result = obb.{ns}.{dotted}()")
+ sections.append("print(result.to_df())")
+ sections.append("```\n")
+
+ sections.append(self._providers_section(ns))
+ sections.append(self._commands_section(ns))
+
+ creds = self._unique_credentials()
+ if creds:
+ sections.append(self._credentials_section(creds))
+
+ sections.append(self._notes_section(ns))
+
+ return "\n".join(sections).rstrip() + "\n"
+
+ def _first_command_example(self, ns: str) -> tuple[str, str] | None:
+ """Pick a representative command to seed the Quick start snippet."""
+ for prov in sorted(self.commands_by_provider):
+ cmds = sorted(self.commands_by_provider[prov])
+ if not cmds:
+ continue
+ dotted = cmds[0]
+ fetcher = next(
+ (
+ f
+ for f in self.fetchers_by_provider.get(prov, [])
+ if f"{ns}_{f.module_name}".endswith(dotted.replace(".", "_"))
+ or f.module_name == dotted.replace(".", "_")
+ ),
+ None,
+ )
+ model = fetcher.model_name if fetcher else "Returns OBBject"
+ return dotted, model
+ return None
+
+ def _providers_section(self, ns: str) -> str:
+ lines = ["## Providers\n"]
+ for p in self.providers:
+ cmd_count = len(self.commands_by_provider.get(p.provider_name, []))
+ label = "command" if cmd_count == 1 else "commands"
+ cred_note = (
+ f" โ credentials: {', '.join(f'`{k}`' for k in p.credential_keys)}"
+ if p.credential_keys
+ else ""
+ )
+ lines.append(f"- **{p.provider_name}** ({cmd_count} {label}){cred_note}")
+ return "\n".join(lines) + "\n"
+
+ def _commands_section(self, ns: str) -> str:
+ lines = ["## Commands\n"]
+ lines.append(
+ f"Every command lands at `obb.{ns}.` with a typed "
+ "signature, full Pydantic validation, and `OBBject` results.\n"
+ )
+ groups: dict[str, list[tuple[str, str]]] = {}
+ for prov, dotted_paths in self.commands_by_provider.items():
+ for dotted in sorted(dotted_paths):
+ top = dotted.split(".", 1)[0]
+ groups.setdefault(top, []).append((dotted, prov))
+ for top in sorted(groups):
+ lines.append(f"### `obb.{ns}.{top}`\n")
+ for dotted, prov in groups[top]:
+ lines.append(f"- `obb.{ns}.{dotted}` โ provider: `{prov}`")
+ lines.append("")
+ return "\n".join(lines).rstrip() + "\n"
+
+ def _unique_credentials(self) -> list[str]:
+ seen: set[str] = set()
+ for p in self.providers:
+ seen.update(p.credential_keys)
+ return sorted(seen)
+
+ def _credentials_section(self, creds: list[str]) -> str:
+ lines = ["## Credentials\n"]
+ lines.append(
+ "Set these in `~/.openbb_platform/user_settings.json` under the "
+ "matching `_` field, or pass via the `OBB_USER_SETTINGS` "
+ "env var:\n"
+ )
+ for c in creds:
+ lines.append(f"- `{c}`")
+ lines.append("")
+ return "\n".join(lines).rstrip() + "\n"
+
+ def _notes_section(self, ns: str) -> str:
+ return (
+ "## Notes\n\n"
+ "- Routers mount under a single root namespace "
+ f"(`obb.{ns}.*`) so the extension never shadows OpenBB's own "
+ "first-party routers.\n"
+ "- Each `aextract_data` strips single-key envelopes and unpacks "
+ "single-element lists at the response boundary; sibling scalar "
+ "fields surface as `AnnotatedResult` metadata in `OBBject.extra`.\n"
+ "- Non-JSON responses (XML, CSV, plain text) come back as a "
+ "single-row dict with `content` / `content_type` rather than "
+ "raising on parse.\n"
+ )
+
+
+def _slugify(text: str) -> str:
+ """Lower-case + collapse non-alphanumerics to single underscores."""
+ cleaned = re.sub(r"[^0-9a-zA-Z]+", "_", text).strip("_").lower()
+ return cleaned or "extension"
+
+
+def _apply_ruff(root: Path) -> None:
+ """Run ``ruff check --fix --unsafe-fixes`` and ``ruff format`` over ``root``."""
+ ruff = _find_ruff()
+ if ruff is None:
+ return
+ target = str(root)
+ subprocess.run( # noqa: S603
+ [ruff, "check", "--fix", "--unsafe-fixes", "--quiet", target],
+ check=False,
+ )
+ subprocess.run( # noqa: S603
+ [ruff, "format", "--quiet", target],
+ check=False,
+ )
+
+
+def _find_ruff() -> str | None:
+ """Locate the ``ruff`` binary; checks the running Python env first."""
+ sibling = Path(sys.executable).parent / "ruff"
+ if sibling.exists():
+ return str(sibling)
+ return shutil.which("ruff")
+
+
+def _build_root_router(
+ *,
+ package_name: str,
+ root_namespace: str,
+ sub_routers: list[GeneratedRouter],
+) -> GeneratedRouter:
+ """Emit a top-level router that mounts every namespace under ``root_namespace``."""
+ parts: list[str] = [
+ f'"""Root router for {root_namespace} โ generated from spec."""',
+ "",
+ "from openbb_core.app.router import Router",
+ ]
+ if sub_routers:
+ parts.append("")
+ for r in sub_routers:
+ parts.append(
+ f"from {package_name}.routers.{r.module_name} import "
+ f"router as _{r.module_name}_router"
+ )
+ parts.append("")
+ parts.append("")
+ parts.append('router = Router(prefix="")')
+ if sub_routers:
+ parts.append("")
+ for r in sub_routers:
+ parts.append(
+ f"router.include_router(_{r.module_name}_router, "
+ f'prefix="/{r.module_name}")'
+ )
+ return GeneratedRouter(
+ module_name=root_namespace,
+ entry_point_name=root_namespace,
+ source="\n".join(parts).rstrip() + "\n",
+ )
+
+
+_SKIP_TOP_NAMESPACES: frozenset[str] = frozenset({"coverage"})
+
+
+def _runtime_utils_source() -> str:
+ """Read the canonical ``unpack_response`` source from ``_unpack.py``."""
+ from pathlib import Path
+
+ text = (
+ Path(__file__)
+ .resolve()
+ .parent.parent.joinpath("dispatchers", "_unpack.py")
+ .read_text()
+ )
+ return text
+
+
+RUNTIME_UTILS_SOURCE = _runtime_utils_source()
+
+
+def generate_packages(
+ spec_doc: dict[str, Any],
+ *,
+ output_root: Path,
+ provider_name: str | None = None,
+ project_name: str | None = None,
+ package_name: str | None = None,
+ description: str | None = None,
+ website: str | None = None,
+ version: str = "0.1.0",
+) -> GeneratedPackageSet:
+ """Build ONE ``GeneratedPackage`` bundling every provider in ``spec_doc``.
+
+ Parameters
+ ----------
+ spec_doc : dict
+ Loaded ``.spec`` document.
+ output_root : Path
+ Directory under which the project is created.
+ provider_name : str, optional
+ Project / package slug.
+ project_name, package_name, description, website : str, optional
+ Project-level overrides.
+ version : str
+ Initial version string for ``pyproject.toml``.
+
+ Returns
+ -------
+ GeneratedPackageSet
+ Single-element wrapper around the project.
+ """
+ base_url = (spec_doc.get("base_url") or "").rstrip("/")
+ api_prefix = spec_doc.get("api_prefix") or ""
+ filtered_commands = {
+ name: cmd
+ for name, cmd in (spec_doc.get("commands") or {}).items()
+ if name.split(".", 1)[0] not in _SKIP_TOP_NAMESPACES
+ }
+ full_tree = build_namespace_tree(filtered_commands)
+ declared_providers = providers_from_tree(full_tree)
+
+ project_slug = _slugify(provider_name or "codegen")
+ project_name = project_name or f"openbb-{project_slug.replace('_', '-')}"
+ package_name = package_name or f"openbb_{project_slug}"
+ description = (
+ description or f"Generated OpenBB Platform extension bundle ({project_slug})."
+ )
+ website = website or base_url
+
+ fetchers_by_provider: dict[str, list[GeneratedFetcher]] = {}
+ post_commands: list[GeneratedPostCommand] = []
+ fetchers_index: dict[str, GeneratedFetcher] = {}
+ post_index: dict[str, GeneratedPostCommand] = {}
+
+ if not declared_providers:
+ target_providers: list[str] = [_slugify(provider_name or "default")]
+ else:
+ target_providers = sorted(declared_providers)
+
+ commands_by_provider: dict[str, list[str]] = {}
+ commands_by_dotted: dict[str, dict[str, Any]] = {}
+
+ for dotted, cmd_spec in iter_commands(full_tree):
+ commands_by_dotted[dotted] = cmd_spec
+ method = (cmd_spec.get("method") or "get").lower()
+ body = cmd_spec.get("request_body_schema")
+ has_body = isinstance(body, dict) and (
+ body.get("properties") or body.get("type") == "array"
+ )
+ cmd_providers = cmd_spec.get("providers") or []
+
+ if method == "post" and has_body:
+ post = generate_post_command_module(
+ PostCommandSpec(
+ name=dotted,
+ cmd_spec=cmd_spec,
+ base_url=base_url,
+ api_prefix=api_prefix,
+ provider_name="tools",
+ )
+ )
+ post_commands.append(post)
+ post_index[dotted] = post
+ commands_by_provider.setdefault("tools", []).append(dotted)
+ continue
+
+ owners = (
+ [p for p in target_providers if p in cmd_providers]
+ if cmd_providers
+ else target_providers
+ )
+ if not owners: # pragma: no cover
+ continue
+ for prov in owners:
+ fetcher = generate_fetcher_module(
+ FetcherCommandSpec(
+ name=dotted,
+ cmd_spec=cmd_spec,
+ base_url=base_url,
+ api_prefix=api_prefix,
+ provider_name=prov,
+ )
+ )
+ fetchers_by_provider.setdefault(prov, []).append(fetcher)
+ commands_by_provider.setdefault(prov, []).append(dotted)
+ fetchers_index.setdefault(dotted, fetcher)
+
+ routers = generate_routers(
+ full_tree,
+ package_name=package_name,
+ provider_name="",
+ fetchers_by_command=fetchers_index,
+ post_commands_by_command=post_index,
+ )
+ for r in routers.routers:
+ r.source = r.source.replace(
+ f"{package_name}.providers..models.",
+ f"{package_name}.providers.tools.models.",
+ )
+
+ root_namespace = _slugify(provider_name or project_slug)
+ sub_routers = [r for r in routers.routers if r.entry_point_name]
+ for r in sub_routers:
+ r.entry_point_name = None
+ root_router = _build_root_router(
+ package_name=package_name,
+ root_namespace=root_namespace,
+ sub_routers=sub_routers,
+ )
+ routers.routers.append(root_router)
+
+ providers: list[GeneratedProvider] = []
+ for prov in target_providers:
+ providers.append(
+ generate_provider_module(
+ package_name=package_name,
+ provider_name=prov,
+ description=f"{prov} provider proxied through {base_url}.",
+ website=website,
+ fetchers=fetchers_by_provider.get(prov, []),
+ )
+ )
+ if post_commands:
+ providers.append(
+ generate_provider_module(
+ package_name=package_name,
+ provider_name="tools",
+ description=(
+ "Local-compute commands (econometrics, quantitative, "
+ f"technical) proxied through {base_url}."
+ ),
+ website=website,
+ fetchers=[],
+ )
+ )
+
+ project = generate_pyproject(
+ project_name=project_name,
+ package_name=package_name,
+ providers=[p.provider_name for p in providers],
+ routers=[r for r in routers.routers if r.entry_point_name],
+ description=description,
+ version=version,
+ spec_provenance={
+ "source_url": str(spec_doc.get("source_url") or ""),
+ "api_version": str(spec_doc.get("api_version") or ""),
+ "generator": str(spec_doc.get("generator") or ""),
+ "generated_at": str(spec_doc.get("generated_at") or ""),
+ "spec_version": str(spec_doc.get("version") or ""),
+ "spec_sha256": str(spec_doc.get("content_sha256") or ""),
+ },
+ )
+
+ test_modules: list[GeneratedTestModule] = []
+ for prov in target_providers:
+ tm = generate_provider_tests(
+ package_name=package_name,
+ provider_name=prov,
+ fetchers=fetchers_by_provider.get(prov, []),
+ commands_by_dotted=commands_by_dotted,
+ )
+ if tm is not None:
+ test_modules.append(tm)
+
+ package = GeneratedPackage(
+ root=Path(output_root) / project_name,
+ project=project,
+ providers=providers,
+ routers=routers,
+ fetchers_by_provider=fetchers_by_provider,
+ post_commands=post_commands,
+ top_level_routers=[
+ r.entry_point_name for r in routers.routers if r.entry_point_name
+ ],
+ root_namespace=root_namespace,
+ base_url=base_url,
+ commands_by_provider=commands_by_provider,
+ test_modules=test_modules,
+ )
+ return GeneratedPackageSet(packages=[package])
+
+
+@dataclass
+class GeneratedPackageSet:
+ """Wrapper around the generated package list (kept for caller symmetry)."""
+
+ packages: list[GeneratedPackage] = field(default_factory=list)
+
+ def write(self) -> list[Path]:
+ """Materialize every package; returns the list of written roots."""
+ return [pkg.write() for pkg in self.packages]
diff --git a/cli/openbb_cli/codegen/post_gen.py b/cli/openbb_cli/codegen/post_gen.py
new file mode 100644
index 00000000000..fb4a1b8e6ea
--- /dev/null
+++ b/cli/openbb_cli/codegen/post_gen.py
@@ -0,0 +1,724 @@
+"""Generate free-form router modules for POST endpoints with request bodies."""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+from typing import Any, cast
+
+from openbb_cli.codegen.credentials import (
+ credentials_from_command,
+ filter_user_params,
+)
+from openbb_cli.codegen.pydantic_gen import (
+ GeneratedClass,
+ class_name_from,
+ consolidate_imports,
+ generate_class,
+ safe_field_name,
+)
+
+
+@dataclass
+class PostCommandSpec:
+ """Per-command inputs for a POST router emitter.
+
+ Parameters
+ ----------
+ name : str
+ Dotted command path from the spec.
+ cmd_spec : dict
+ The command's spec entry.
+ base_url : str
+ Upstream API root, no trailing slash.
+ api_prefix : str
+ Path prefix shared across every command in the spec.
+ provider_name : str
+ Snake-case provider identifier.
+ """
+
+ name: str
+ cmd_spec: dict[str, Any]
+ base_url: str
+ api_prefix: str
+ provider_name: str
+
+
+@dataclass
+class GeneratedPostCommand:
+ """Output of ``generate_post_command_module``.
+
+ Parameters
+ ----------
+ module_name : str
+ Snake-case module filename (without ``.py``).
+ function_name : str
+ The router function name.
+ body_class : str, optional
+ Name of the emitted body element class, if a body is present.
+ data_class : str
+ Name of the emitted response-row class.
+ source : str
+ Full module source ready to write to disk.
+ credentials_used : dict
+ Same shape as ``GeneratedFetcher.credentials_used``.
+ """
+
+ module_name: str
+ function_name: str
+ data_class: str
+ source: str
+ body_class: str | None = None
+ credentials_used: dict[str, dict[str, str]] = field(default_factory=dict)
+
+
+_PATH_TEMPLATE_RE = re.compile(r"\{([^}]+)\}")
+
+
+def _module_name_from_command(name: str) -> str:
+ """Convert a dotted command path to a snake_case module identifier."""
+ safe = re.sub(r"[^0-9a-zA-Z]+", "_", name).strip("_")
+ if not safe:
+ safe = "command"
+ if safe[0].isdigit():
+ safe = f"_{safe}"
+ return safe.lower()
+
+
+def _path_template_keys(url_path: str) -> list[str]:
+ """Extract ``{placeholder}`` names from a URL template, in declaration order."""
+ return [m.group(1) for m in _PATH_TEMPLATE_RE.finditer(url_path)]
+
+
+def _resolve_url_path(api_prefix: str, url_path: str) -> str:
+ """Combine api_prefix with url_path; the prefix is dropped if already present."""
+ prefix = ("/" + api_prefix.strip("/")) if api_prefix.strip("/") else ""
+ path = url_path if url_path.startswith("/") else "/" + url_path
+ if prefix and path.startswith(prefix + "/"):
+ return path
+ if prefix:
+ return prefix + path if not path.startswith(prefix) else path
+ return path
+
+
+def _python_type_from_param(param: dict[str, Any]) -> str:
+ """Render a Python type annotation for a normalized parameter entry.
+
+ Parameters
+ ----------
+ param : dict
+ Spec parameter entry.
+
+ Returns
+ -------
+ str
+ Annotation text suitable for the generated function signature.
+ """
+ base_type = {
+ "string": "str",
+ "integer": "int",
+ "number": "float",
+ "boolean": "bool",
+ }.get(param.get("type", "string"), "str")
+ if param.get("choices"):
+ rendered = ", ".join(repr(c) for c in param["choices"])
+ base_type = f"Literal[{rendered}]"
+ if param.get("is_list"):
+ return f"list[{base_type}]"
+ return base_type
+
+
+def _array_item_class(
+ body_schema: dict[str, Any],
+ *,
+ parent_class_name: str,
+) -> tuple[str | None, GeneratedClass | None, str | None]:
+ """Detect a body's array-of-objects field and emit its item class.
+
+ Parameters
+ ----------
+ body_schema : dict
+ The request_body_schema.
+ parent_class_name : str
+ Camel-case base for the generated item class name.
+
+ Returns
+ -------
+ tuple
+ ``(field_name, item_class, type_annotation)``.
+ """
+ if body_schema.get("type") == "array":
+ items = body_schema.get("items")
+ if isinstance(items, dict):
+ item_class_name = class_name_from(parent_class_name, "BodyItem")
+ item_class = generate_class(
+ items,
+ class_name=item_class_name,
+ base_class="Data",
+ docstring=f"Body item for {parent_class_name}.",
+ )
+ return ("data", item_class, f"list[{item_class_name}]")
+ return (None, None, None)
+
+ properties = body_schema.get("properties") or {}
+ for name, schema in properties.items():
+ if not isinstance(schema, dict):
+ continue
+ if schema.get("type") != "array":
+ continue
+ items = schema.get("items")
+ if not isinstance(items, dict):
+ continue
+ if items.get("type") != "object" and "properties" not in items:
+ continue
+ item_class_name = class_name_from(parent_class_name, "BodyItem")
+ item_class = generate_class(
+ items,
+ class_name=item_class_name,
+ base_class="Data",
+ docstring=f"Body item for {parent_class_name}.",
+ )
+ return (name, item_class, f"list[{item_class_name}]")
+ return (None, None, None)
+
+
+def _data_schema(cmd_spec: dict[str, Any]) -> dict[str, Any]:
+ """Pull the per-row data schema out of a response_schema (same logic as fetcher_gen)."""
+ response = cmd_spec.get("response_schema") or {}
+ if not isinstance(response, dict):
+ return {}
+ properties = response.get("properties") or {}
+ results = properties.get("results")
+ if isinstance(results, dict):
+ non_null = [
+ v
+ for v in results.get("anyOf") or [results]
+ if isinstance(v, dict) and v.get("type") != "null"
+ ]
+ if non_null:
+ inner = non_null[0]
+ if inner.get("type") == "array" and isinstance(inner.get("items"), dict):
+ inner = inner["items"]
+ if isinstance(inner.get("oneOf"), list):
+ concrete = [c for c in inner["oneOf"] if isinstance(c, dict)]
+ if concrete:
+ inner = concrete[0]
+ return inner
+ return _unwrap_schema_envelopes(response)
+
+
+def _unwrap_schema_envelopes(schema: Any) -> dict[str, Any]:
+ """Strip schema envelopes that ``unpack_response`` strips at runtime."""
+ if not isinstance(schema, dict):
+ return {}
+ typed: dict[str, Any] = schema
+ if typed.get("type") == "array":
+ return _unwrap_schema_envelopes(typed.get("items"))
+ combinator_target = _first_non_null_combinator_variant(typed)
+ if combinator_target is not None:
+ return _unwrap_schema_envelopes(combinator_target)
+ if _is_scalar_schema(typed):
+ return _wrap_scalar_as_value(typed)
+ props = typed.get("properties")
+ if not isinstance(props, dict) or not props:
+ return typed
+ inner = _envelope_inner_schema(typed, props)
+ if inner is not None:
+ return _unwrap_schema_envelopes(inner)
+ return typed
+
+
+def _first_non_null_combinator_variant(schema: dict[str, Any]) -> dict[str, Any] | None:
+ """Return the first non-null variant of a top-level ``oneOf`` / ``anyOf``."""
+ for combinator in ("oneOf", "anyOf"):
+ variants = schema.get(combinator)
+ if not isinstance(variants, list):
+ continue
+ for variant in variants:
+ if isinstance(variant, dict) and variant.get("type") != "null":
+ return cast(dict[str, Any], variant)
+ return None
+
+
+def _envelope_inner_schema(
+ typed: dict[str, Any], # noqa: ARG001
+ props: dict[str, Any],
+) -> dict[str, Any] | None:
+ """Resolve the inner row schema when ``typed`` is a recognized envelope."""
+ if len(props) == 1:
+ only_value = next(iter(props.values()))
+ if isinstance(only_value, dict):
+ only_dict = cast(dict[str, Any], only_value)
+ if only_dict.get("type") == "array":
+ items = only_dict.get("items")
+ return cast(dict[str, Any], items) if isinstance(items, dict) else None
+ if (
+ only_dict.get("properties")
+ or only_dict.get("oneOf")
+ or only_dict.get("anyOf")
+ ):
+ return only_dict
+ array_keys = [
+ k for k, v in props.items() if isinstance(v, dict) and v.get("type") == "array"
+ ]
+ if len(array_keys) == 1:
+ items = props[array_keys[0]].get("items")
+ if isinstance(items, dict):
+ return cast(dict[str, Any], items)
+ return None
+
+
+def _is_scalar_schema(schema: dict[str, Any]) -> bool:
+ """Return True when the schema represents a primitive (string/number/etc.)."""
+ return schema.get("type") in {
+ "string",
+ "integer",
+ "number",
+ "boolean",
+ } and not schema.get("properties")
+
+
+def _wrap_scalar_as_value(schema: dict[str, Any]) -> dict[str, Any]:
+ """Promote a scalar schema to ``{type: object, properties: {value: scalar}}``."""
+ return {
+ "type": "object",
+ "properties": {"value": schema},
+ "required": ["value"],
+ }
+
+
+def _credential_lookup_lines(
+ creds: dict[str, dict[str, str]], provider_name: str
+) -> list[str]:
+ """Render the credential extraction block for the POST router body.
+
+ Parameters
+ ----------
+ creds : dict
+ Mapping from canonical credential names to ``{name, in}`` info.
+ provider_name : str
+ Snake-case provider name.
+
+ Returns
+ -------
+ list of str
+ Indented lines that pull each credential into ``_cred_`` locals.
+ """
+ if not creds:
+ return []
+ lines: list[str] = [" _user_creds = cc.user_settings.credentials"]
+ for canonical in sorted(creds):
+ var = f"_cred_{canonical}"
+ attr = f"{provider_name}_{canonical}"
+ lines.append(f' {var} = (getattr(_user_creds, "{attr}", "") or "")')
+ return lines
+
+
+def _signature_params(
+ cmd_spec: dict[str, Any],
+ array_field: str | None,
+ array_annotation: str | None,
+ has_credentials: bool,
+) -> list[tuple[str, str, str | None, bool, Any]]:
+ """Build the function parameter list (name, annotation, description, required, default).
+
+ Parameters
+ ----------
+ cmd_spec : dict
+ Spec command entry.
+ array_field : str, optional
+ Name of the body's array-of-objects field.
+ array_annotation : str, optional
+ The Python annotation for that field.
+ has_credentials : bool
+ Whether the command needs credentials.
+
+ Returns
+ -------
+ list of tuple
+ Per-parameter tuples in declaration order.
+ """
+ out: list[tuple[str, str, str | None, bool, Any]] = []
+ if has_credentials:
+ out.append(
+ (
+ "cc",
+ "CommandContext",
+ "Provides access to user-configured credentials.",
+ True,
+ None,
+ )
+ )
+ if array_field and array_annotation:
+ out.append((array_field, array_annotation, "Body data rows.", True, None))
+ body_props = (cmd_spec.get("request_body_schema") or {}).get("properties") or {}
+ body_required = set(
+ (cmd_spec.get("request_body_schema") or {}).get("required") or []
+ )
+ for name, schema in body_props.items():
+ if name == array_field:
+ continue
+ if not isinstance(schema, dict):
+ continue
+ ann = _python_type_from_param(
+ {
+ "type": schema.get("type"),
+ "is_list": schema.get("type") == "array",
+ "choices": schema.get("enum"),
+ }
+ )
+ out.append(
+ (
+ name,
+ ann,
+ schema.get("description") or schema.get("title"),
+ name in body_required,
+ schema.get("default"),
+ )
+ )
+ for raw in filter_user_params(cmd_spec.get("parameters") or []):
+ name = raw.get("name")
+ if not name:
+ continue
+ ann = _python_type_from_param(raw)
+ out.append(
+ (
+ name,
+ ann,
+ raw.get("help"),
+ bool(raw.get("required")),
+ raw.get("default"),
+ )
+ )
+ out.sort(key=lambda entry: (0 if entry[0] == "cc" else 1, 0 if entry[3] else 1))
+ return out
+
+
+def _render_signature(
+ func_name: str,
+ params: list[tuple[str, str, str | None, bool, Any]],
+ return_annotation: str,
+) -> str:
+ """Render ``async def func_name(...) -> return_annotation:`` lines.
+
+ Parameters
+ ----------
+ func_name : str
+ Generated function identifier.
+ params : list of tuple
+ Output of ``_signature_params``.
+ return_annotation : str
+ The return type annotation text.
+
+ Returns
+ -------
+ str
+ Multi-line signature ready for the function body to follow.
+ """
+ if not params:
+ return f"async def {func_name}() -> {return_annotation}:"
+ lines = [f"async def {func_name}("]
+ for name, annotation, _, required, default in params:
+ if required:
+ lines.append(f" {name}: {annotation},")
+ else:
+ default_repr = render_default(default)
+ lines.append(f" {name}: {annotation} = {default_repr},")
+ lines.append(f") -> {return_annotation}:")
+ return "\n".join(lines)
+
+
+def render_default(default: Any) -> str:
+ """Render a Python literal for a default value.
+
+ Parameters
+ ----------
+ default : Any
+ The literal default.
+
+ Returns
+ -------
+ str
+ The literal text usable on the right-hand side of ``= default``.
+ """
+ if default is None:
+ return "None"
+ if isinstance(default, bool):
+ return "True" if default else "False"
+ return repr(default)
+
+
+def generate_post_command_module(spec: PostCommandSpec) -> GeneratedPostCommand:
+ """Render a POST command module for one spec command.
+
+ Parameters
+ ----------
+ spec : PostCommandSpec
+ Per-command inputs (see ``PostCommandSpec``).
+
+ Returns
+ -------
+ GeneratedPostCommand
+ The emitted module source plus metadata the package builder
+ needs to register the command in the router.
+ """
+ module_name = _module_name_from_command(spec.name)
+ base_class_name = class_name_from(spec.name)
+ function_name = module_name
+ data_class = f"{base_class_name}Data"
+
+ body_field, body_item_class, body_annotation = _array_item_class(
+ spec.cmd_spec.get("request_body_schema") or {},
+ parent_class_name=base_class_name,
+ )
+
+ data_schema = _data_schema(spec.cmd_spec) or {"type": "object"}
+ data_generated = generate_class(
+ data_schema,
+ class_name=data_class,
+ base_class="BaseModel",
+ docstring=f"Response row for {spec.name}.",
+ )
+
+ creds = credentials_from_command(spec.cmd_spec)
+ cred_lines = _credential_lookup_lines(creds, spec.provider_name)
+
+ params = _signature_params(
+ spec.cmd_spec, body_field, body_annotation, has_credentials=bool(creds)
+ )
+ return_annotation = f"OBBject[list[{data_class}]]"
+ signature = _render_signature(function_name, params, return_annotation)
+
+ path_params = _path_template_keys(spec.cmd_spec.get("url_path") or "")
+ full_url_path = _resolve_url_path(
+ spec.api_prefix, spec.cmd_spec.get("url_path") or ""
+ )
+ body_block = _render_body_block(
+ cmd_spec=spec.cmd_spec,
+ body_field=body_field,
+ creds=creds,
+ path_params=path_params,
+ url_path_template=full_url_path,
+ base_url=spec.base_url.rstrip("/"),
+ cred_lines=cred_lines,
+ data_class=data_class,
+ params=params,
+ )
+
+ imports = {
+ "import json as _json",
+ "from typing import Any",
+ "from openbb_core.app.model.abstract.error import OpenBBError",
+ "from openbb_core.app.model.obbject import OBBject",
+ "from openbb_core.provider.abstract.data import Data",
+ "from openbb_core.provider.utils.helpers import "
+ "get_async_requests_session, get_querystring",
+ "from pydantic import BaseModel, Field",
+ "from ....utils import unpack_response",
+ }
+ if creds:
+ imports.add("from openbb_core.app.model.command_context import CommandContext")
+ imports.update(data_generated.collect_imports())
+ if body_item_class:
+ imports.update(body_item_class.collect_imports())
+ if any(p[1].startswith("Literal[") for p in params):
+ imports.add("from typing import Literal")
+
+ sorted_imports = consolidate_imports(imports)
+
+ description = (spec.cmd_spec.get("description") or f"POST {spec.name}.").strip()
+ summary = description.split("\n", 1)[0]
+ parts: list[str] = [
+ f'"""POST router function for {spec.name} โ generated from spec.\n\n'
+ f"Hits ``{spec.base_url.rstrip('/')}{full_url_path}`` via HTTP POST.\n"
+ f'"""',
+ "",
+ ]
+ stdlib = [
+ i
+ for i in sorted_imports
+ if i.startswith(
+ ("from typing", "from datetime", "import typing", "import datetime")
+ )
+ ]
+ third_party = [i for i in sorted_imports if i not in stdlib]
+ if stdlib:
+ parts.extend(stdlib)
+ parts.append("")
+ if third_party:
+ parts.extend(third_party)
+ parts.append("")
+ parts.append("")
+
+ if body_item_class:
+ for cls in body_item_class.flatten():
+ parts.append(cls.source)
+ for cls in data_generated.flatten():
+ parts.append(cls.source)
+
+ cleaned_summary = (description or summary).strip()
+ if not cleaned_summary:
+ cleaned_summary = summary
+ if not cleaned_summary.endswith((".", "!", "?")):
+ cleaned_summary += "."
+ if "\n" in cleaned_summary:
+ first, _, rest = cleaned_summary.partition("\n")
+ rest_indented = "\n".join(
+ f" {line}" if line.strip() else "" for line in rest.split("\n")
+ )
+ func_doc = f' """{first}\n\n{rest_indented}\n """'
+ else:
+ func_doc = f' """{cleaned_summary}"""'
+ parts.append(f"{signature}\n{func_doc}\n{body_block}\n")
+
+ return GeneratedPostCommand(
+ module_name=module_name,
+ function_name=function_name,
+ body_class=body_item_class.name if body_item_class else None,
+ data_class=data_class,
+ source="\n".join(parts).rstrip() + "\n",
+ credentials_used=creds,
+ )
+
+
+def _render_body_block(
+ *,
+ cmd_spec: dict[str, Any],
+ body_field: str | None,
+ creds: dict[str, dict[str, str]],
+ path_params: list[str],
+ url_path_template: str,
+ base_url: str,
+ cred_lines: list[str],
+ data_class: str,
+ params: list[tuple[str, str, str | None, bool, Any]],
+) -> str:
+ """Render the function body for a POST command.
+
+ Parameters
+ ----------
+ cmd_spec : dict
+ The spec command entry.
+ body_field : str, optional
+ Name of the array-of-objects body field, if any.
+ creds : dict
+ Credential entries from ``credentials_from_command``.
+ path_params : list of str
+ URL-template placeholders to substitute.
+ url_path_template : str
+ URL path with placeholders intact.
+ base_url : str
+ Upstream API root, no trailing slash.
+ cred_lines : list of str
+ Credential-extraction source lines.
+ data_class : str
+ Name of the response Data class.
+ params : list of tuple
+ Function parameters.
+
+ Returns
+ -------
+ str
+ Full function body.
+ """
+ body_props = (cmd_spec.get("request_body_schema") or {}).get("properties") or {}
+ body_field_names = list(body_props)
+ query_field_names = [
+ n for n, _, _, _, _ in params if n != "cc" and n not in body_field_names
+ ]
+
+ lines: list[str] = []
+ lines.extend(cred_lines)
+
+ if path_params:
+ sub_args = ", ".join(f"{p}={p}" for p in path_params)
+ lines.append(f" _path = {url_path_template!r}.format({sub_args})")
+ else:
+ lines.append(f" _path = {url_path_template!r}")
+
+ lines.append(" _query_dict: dict[str, Any] = {}")
+ for name in query_field_names:
+ if name in path_params:
+ continue
+ lines.append(f" if {name} is not None:")
+ lines.append(f" _query_dict[{name!r}] = {name}")
+ for canonical, info in creds.items():
+ if info["in"] != "query":
+ continue
+ wire_name = info["name"]
+ var = f"_cred_{canonical}"
+ lines.append(f" if {var}:")
+ lines.append(f" _query_dict[{wire_name!r}] = {var}")
+ lines.append(" _query_string = get_querystring(_query_dict, [])")
+ lines.append(
+ f' _url = f"{base_url}{{_path}}" + ("?" + _query_string if _query_string else "")'
+ )
+
+ lines.append(" _headers: dict[str, str] = {}")
+ for canonical, info in creds.items():
+ if info["in"] != "header":
+ continue
+ wire_name = info["name"]
+ var = f"_cred_{canonical}"
+ lines.append(f" if {var}:")
+ lines.append(f" _headers[{wire_name!r}] = {var}")
+
+ body_top_is_array = (cmd_spec.get("request_body_schema") or {}).get(
+ "type"
+ ) == "array"
+ if body_top_is_array and body_field:
+ safe = safe_field_name(body_field)[0]
+ lines.append(
+ " _body: list[dict[str, Any]] = "
+ f'[_item.model_dump(mode="json") for _item in {safe}]'
+ )
+ else:
+ body_entries: list[str] = []
+ for name in body_field_names:
+ safe = safe_field_name(name)[0]
+ if name == body_field:
+ body_entries.append(
+ f' {name!r}: [_item.model_dump(mode="json") for _item in {safe}],'
+ )
+ else:
+ body_entries.append(f" {name!r}: {safe},")
+ if body_entries:
+ body_lines = "\n".join(body_entries)
+ lines.append(f" _body: dict[str, Any] = {{\n{body_lines}\n }}")
+ else:
+ lines.append(" _body: dict[str, Any] = {}")
+
+ lines.append(" _rows: list[dict[str, Any]] = []")
+ lines.append(" _metadata: dict[str, Any] = {}")
+ lines.append(" async with await get_async_requests_session() as _session:")
+ lines.append(
+ " async with await _session.request("
+ '"POST", _url, headers=_headers, json=_body) as _resp:'
+ )
+ lines.append(" _ct = (_resp.headers.get('Content-Type') or '').lower()")
+ lines.append(" _text = await _resp.text()")
+ lines.append(" if _resp.status >= 400:")
+ lines.append(
+ " raise OpenBBError("
+ "f'HTTP {_resp.status} from {_url}: {_text[:500]}')"
+ )
+ lines.append(
+ " if 'json' in _ct or _text.lstrip().startswith(('{', '[')):"
+ )
+ lines.append(" try:")
+ lines.append(" _payload = _json.loads(_text)")
+ lines.append(" except ValueError as _exc:")
+ lines.append(
+ " raise OpenBBError("
+ "f'Upstream {_url} returned malformed JSON: {_exc}') from _exc"
+ )
+ lines.append(" _rows, _metadata = unpack_response(_payload)")
+ lines.append(" else:")
+ lines.append(" _rows = [{'content': _text, 'content_type': _ct}]")
+ lines.append(f" _typed = [{data_class}(**row) for row in _rows]")
+ lines.append(" if _metadata:")
+ lines.append(
+ ' return OBBject(results=_typed, extra={"results_metadata": _metadata})'
+ )
+ lines.append(" return OBBject(results=_typed)")
+ return "\n".join(lines)
diff --git a/cli/openbb_cli/codegen/project_gen.py b/cli/openbb_cli/codegen/project_gen.py
new file mode 100644
index 00000000000..9c0292813a3
--- /dev/null
+++ b/cli/openbb_cli/codegen/project_gen.py
@@ -0,0 +1,130 @@
+"""Generate ``pyproject.toml`` and the package skeleton files."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass
+class GeneratedProject:
+ """Output of ``generate_pyproject``.
+
+ Parameters
+ ----------
+ project_name : str
+ PyPI distribution name.
+ package_name : str
+ Snake-case Python package name.
+ source : str
+ Full ``pyproject.toml`` contents.
+ """
+
+ project_name: str
+ package_name: str
+ source: str
+
+
+def generate_pyproject(
+ *,
+ project_name: str,
+ package_name: str,
+ providers: list[str],
+ routers: list,
+ description: str,
+ version: str = "0.1.0",
+ spec_provenance: dict[str, str] | None = None,
+) -> GeneratedProject:
+ """Render a PEP 621 + Hatchling ``pyproject.toml`` for the bundled extension.
+
+ Parameters
+ ----------
+ project_name : str
+ PyPI / distribution name.
+ package_name : str
+ Python package directory name (snake_case).
+ providers : list of str
+ Provider identifiers.
+ routers : list of GeneratedRouter
+ Top-level routers.
+ description : str
+ Free-form description for ``[project] description``.
+ version : str
+ Initial version string.
+
+ Returns
+ -------
+ GeneratedProject
+ Project metadata plus the ``pyproject.toml`` source.
+ """
+ provider_entries = "\n".join(
+ f'{p} = "{package_name}.providers.{p}:{p}_provider"' for p in sorted(providers)
+ )
+ router_entries = "\n".join(
+ f'{r.entry_point_name} = "{package_name}.routers.{r.module_name}:router"'
+ for r in routers
+ if r.entry_point_name
+ )
+ provenance_block = _render_provenance_block(spec_provenance or {})
+ body = f"""[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+
+[project]
+name = "{project_name}"
+version = "{version}"
+description = {description!r}
+requires-python = ">=3.10,<4"
+license = {{ text = "AGPL-3.0-only" }}
+dependencies = [
+ "openbb-core>=2.0.0",
+]
+
+
+[tool.hatch.build.targets.wheel]
+packages = ["{package_name}"]
+
+
+[project.entry-points."openbb_provider_extension"]
+{provider_entries}
+
+[project.entry-points."openbb_core_extension"]
+{router_entries}
+{provenance_block}
+[tool.ruff]
+line-length = 100
+target-version = "py310"
+
+[tool.ruff.lint]
+select = ["E", "F", "W", "I", "B", "UP", "D"]
+ignore = [
+ "D100", "D101", "D102", "D103", "D104", "D105", "D107",
+ "D200", "D205", "D212", "D401", "D415",
+ "E501", "B008",
+]
+
+[tool.ruff.lint.pydocstyle]
+convention = "numpy"
+"""
+ return GeneratedProject(
+ project_name=project_name,
+ package_name=package_name,
+ source=body,
+ )
+
+
+def _render_provenance_block(provenance: dict[str, str]) -> str:
+ """Render ``[tool.openbb-codegen]`` (empty string when no provenance)."""
+ keys = (
+ "source_url",
+ "api_version",
+ "generator",
+ "generated_at",
+ "spec_version",
+ "spec_sha256",
+ )
+ entries = [(k, provenance[k]) for k in keys if provenance.get(k)]
+ if not entries:
+ return ""
+ body = "\n".join(f"{k} = {v!r}" for k, v in entries)
+ return f"\n[tool.openbb-codegen]\n{body}\n"
diff --git a/cli/openbb_cli/codegen/provider_gen.py b/cli/openbb_cli/codegen/provider_gen.py
new file mode 100644
index 00000000000..8e865c0b55d
--- /dev/null
+++ b/cli/openbb_cli/codegen/provider_gen.py
@@ -0,0 +1,107 @@
+"""Generate the ``providers//__init__.py`` registration module."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from openbb_cli.codegen.fetcher_gen import GeneratedFetcher
+
+
+@dataclass
+class GeneratedProvider:
+ """Output of ``generate_provider_module``.
+
+ Parameters
+ ----------
+ package_name : str
+ Snake-case package name.
+ provider_name : str
+ Snake-case provider identifier.
+ source : str
+ Full source for ``providers//__init__.py``.
+ credential_keys : list of str
+ Canonical credential names declared on the provider.
+ """
+
+ package_name: str
+ provider_name: str
+ source: str
+ credential_keys: list[str]
+
+
+def generate_provider_module(
+ package_name: str,
+ provider_name: str,
+ description: str,
+ website: str,
+ fetchers: list[GeneratedFetcher],
+) -> GeneratedProvider:
+ """Render the provider's ``__init__.py``.
+
+ Parameters
+ ----------
+ package_name : str
+ Snake-case top-level package name.
+ provider_name : str
+ Snake-case provider identifier.
+ description : str
+ Provider description shown in OpenBB introspection.
+ website : str
+ Provider home page URL.
+ fetchers : list of GeneratedFetcher
+ Per-command fetchers.
+
+ Returns
+ -------
+ GeneratedProvider
+ Module source plus the credential-key list.
+ """
+ cred_set: set[str] = set()
+ for f in fetchers:
+ cred_set.update(f.credentials_used)
+ credential_keys = sorted(cred_set)
+
+ parts: list[str] = []
+ parts.append(
+ f'"""Provider registration for {provider_name} โ generated from spec."""'
+ )
+ parts.append("")
+ parts.append("from openbb_core.provider.abstract.provider import Provider")
+ parts.append("")
+
+ for f in fetchers:
+ parts.append(
+ f"from {package_name}.providers.{provider_name}.models."
+ f"{f.module_name} import {f.fetcher_class}"
+ )
+ parts.append("")
+ parts.append("")
+
+ fetcher_dict_lines = [f' "{f.model_name}": {f.fetcher_class},' for f in fetchers]
+ if credential_keys:
+ cred_repr = (
+ "[\n "
+ + ",\n ".join(repr(k) for k in credential_keys)
+ + ",\n ]"
+ )
+ cred_block = f" credentials={cred_repr},\n"
+ else:
+ cred_block = ""
+
+ parts.append(
+ f"{provider_name}_provider = Provider(\n"
+ f' name="{provider_name}",\n'
+ f" description={description!r},\n"
+ f"{cred_block}"
+ f' website="{website}",\n'
+ " fetcher_dict={\n" + "\n".join(fetcher_dict_lines) + "\n },\n)\n"
+ )
+
+ return GeneratedProvider(
+ package_name=package_name,
+ provider_name=provider_name,
+ source="\n".join(parts),
+ credential_keys=credential_keys,
+ )
diff --git a/cli/openbb_cli/codegen/pydantic_gen.py b/cli/openbb_cli/codegen/pydantic_gen.py
new file mode 100644
index 00000000000..767c48011cc
--- /dev/null
+++ b/cli/openbb_cli/codegen/pydantic_gen.py
@@ -0,0 +1,691 @@
+"""Convert OpenAPI JSON-schema fragments into Pydantic class source code."""
+
+from __future__ import annotations
+
+import keyword
+import re
+from dataclasses import dataclass, field
+from typing import Any
+
+_PRIMITIVE_TYPES: dict[str, str] = {
+ "string": "str",
+ "integer": "int",
+ "number": "float",
+ "boolean": "bool",
+}
+
+_STRING_FORMATS: dict[str, str] = {
+ "date": "datetime.date",
+ "date-time": "datetime.datetime",
+}
+
+_PYTHON_RESERVED_FIELD_NAMES = frozenset(
+ {"model_config", "model_fields", "model_dump", "model_validate"}
+)
+
+
+@dataclass
+class GeneratedClass:
+ """One emitted Pydantic class plus any nested classes it depends on."""
+
+ name: str
+ source: str
+ nested: list[GeneratedClass] = field(default_factory=list)
+ imports: set[str] = field(default_factory=set)
+
+ def flatten(self) -> list[GeneratedClass]:
+ """Return ``[nested..., self]`` in topological order, deduped by name."""
+ seen: set[str] = set()
+ out: list[GeneratedClass] = []
+ for child in self.nested:
+ for grandchild in child.flatten():
+ if grandchild.name not in seen:
+ seen.add(grandchild.name)
+ out.append(grandchild)
+ if self.name not in seen:
+ out.append(self)
+ return out
+
+ def collect_imports(self) -> set[str]:
+ """Union of ``imports`` across this class and every descendant."""
+ out = set(self.imports)
+ for child in self.nested:
+ out.update(child.collect_imports())
+ return out
+
+
+def safe_field_name(name: str) -> tuple[str, bool]:
+ """Normalize a JSON property name to a valid Python identifier.
+
+ Returns ``(safe_name, needs_alias)``.
+ """
+ cleaned = re.sub(r"[^0-9a-zA-Z_]", "_", name).lstrip("_")
+ if not cleaned:
+ cleaned = "field"
+ if cleaned[0].isdigit():
+ cleaned = f"f_{cleaned}"
+ if keyword.iskeyword(cleaned) or cleaned in _PYTHON_RESERVED_FIELD_NAMES:
+ cleaned = f"{cleaned}_"
+ return (cleaned, cleaned != name)
+
+
+def class_name_from(*parts: str) -> str:
+ """Join + camel-case a set of name parts into a Python class identifier."""
+ words: list[str] = []
+ for part in parts:
+ if not part:
+ continue
+ for word in re.split(r"[^0-9a-zA-Z]+", part):
+ if word:
+ words.append(word[:1].upper() + word[1:])
+ name = "".join(words) or "Anonymous"
+ if name[0].isdigit():
+ name = f"_{name}"
+ return name
+
+
+def schema_to_type( # noqa: PLR0911
+ schema: Any,
+ *,
+ parent_class_name: str,
+ field_name: str,
+ nested_classes: list[GeneratedClass],
+ imports: set[str],
+) -> str:
+ """Resolve a JSON schema to a Python type annotation string."""
+ if not isinstance(schema, dict):
+ imports.add("from typing import Any")
+ return "Any"
+
+ if "$ref" in schema:
+ imports.add("from typing import Any")
+ return "dict[str, Any]"
+
+ any_of = schema.get("anyOf") or schema.get("oneOf")
+ if any_of:
+ return _resolve_union(
+ any_of,
+ parent_class_name=parent_class_name,
+ field_name=field_name,
+ nested_classes=nested_classes,
+ imports=imports,
+ )
+
+ enum = schema.get("enum")
+ if enum:
+ return _enum_primitive(enum, schema.get("type"), imports)
+
+ if "const" in schema:
+ return _enum_primitive([schema["const"]], schema.get("type"), imports)
+
+ schema_type = schema.get("type")
+ if schema_type == "array":
+ inner = schema_to_type(
+ schema.get("items") or {},
+ parent_class_name=parent_class_name,
+ field_name=field_name + "_item",
+ nested_classes=nested_classes,
+ imports=imports,
+ )
+ return f"list[{inner}]"
+
+ if schema_type == "object" or "properties" in schema:
+ return _resolve_object(
+ schema,
+ parent_class_name=parent_class_name,
+ field_name=field_name,
+ nested_classes=nested_classes,
+ imports=imports,
+ )
+
+ if schema_type in _PRIMITIVE_TYPES:
+ py_type = _PRIMITIVE_TYPES[schema_type]
+ if py_type == "str":
+ fmt = schema.get("format")
+ if fmt in _STRING_FORMATS:
+ qualified = _STRING_FORMATS[fmt]
+ imports.add("import datetime")
+ return qualified
+ return py_type
+
+ imports.add("from typing import Any")
+ return "Any"
+
+
+def _enum_primitive(
+ values: list[Any],
+ declared_type: Any, # noqa: ARG001
+ imports: set[str],
+) -> str:
+ """Map an ``enum`` / ``const`` to a ``Literal[...]`` annotation."""
+ if not values:
+ imports.add("from typing import Any")
+ return "Any"
+ rendered_members = ", ".join(repr(v) for v in values)
+ imports.add("from typing import Literal")
+ return f"Literal[{rendered_members}]"
+
+
+def _resolve_union(
+ variants: list[Any],
+ *,
+ parent_class_name: str,
+ field_name: str,
+ nested_classes: list[GeneratedClass],
+ imports: set[str],
+) -> str:
+ """Resolve an ``anyOf`` / ``oneOf`` list into a Python annotation."""
+ non_null = [
+ v for v in variants if not (isinstance(v, dict) and v.get("type") == "null")
+ ]
+ is_optional = len(non_null) != len(variants)
+
+ if not non_null:
+ imports.add("from typing import Any")
+ return "Any"
+
+ rendered: list[str] = []
+ for variant in non_null:
+ rendered.append(
+ schema_to_type(
+ variant,
+ parent_class_name=parent_class_name,
+ field_name=field_name,
+ nested_classes=nested_classes,
+ imports=imports,
+ )
+ )
+ seen: set[str] = set()
+ unique: list[str] = []
+ for r in rendered:
+ if r not in seen:
+ seen.add(r)
+ unique.append(r)
+
+ inner = unique[0] if len(unique) == 1 else " | ".join(unique)
+
+ if is_optional:
+ return f"{inner} | None"
+ return inner
+
+
+def _resolve_object(
+ schema: dict[str, Any],
+ *,
+ parent_class_name: str,
+ field_name: str,
+ nested_classes: list[GeneratedClass],
+ imports: set[str],
+) -> str:
+ """Either generate a nested class or fall back to ``dict[str, Any]``."""
+ properties = schema.get("properties") or {}
+ if not properties:
+ ap = schema.get("additionalProperties")
+ if isinstance(ap, dict):
+ value_type = schema_to_type(
+ ap,
+ parent_class_name=parent_class_name,
+ field_name=field_name + "_value",
+ nested_classes=nested_classes,
+ imports=imports,
+ )
+ return f"dict[str, {value_type}]"
+ imports.add("from typing import Any")
+ return "dict[str, Any]"
+
+ nested_name = schema.get("title") or class_name_from(parent_class_name, field_name)
+ nested_name = class_name_from(nested_name)
+ nested = generate_class(
+ schema,
+ class_name=nested_name,
+ docstring=schema.get("description"),
+ )
+ nested_classes.append(nested)
+ return nested_name
+
+
+def render_field_default(default: Any) -> str:
+ """Render a JSON default value as a Python literal for ``Field(default=...)``."""
+ if isinstance(default, bool):
+ return "True" if default else "False"
+ if isinstance(default, (int, float)):
+ return repr(default)
+ if isinstance(default, str):
+ return repr(default)
+ if default is None:
+ return "None"
+ if isinstance(default, (list, tuple, dict)):
+ return repr(default)
+ return repr(default)
+
+
+_LINE_LENGTH_BUDGET = 88
+
+
+def _build_field_call(
+ *,
+ description: str | None,
+ default: Any,
+ has_default: bool,
+ required: bool,
+ alias: str | None,
+ leading_width: int = 0,
+) -> str:
+ """Compose the ``Field(...)`` text used after ``field_name: type =``.
+
+ Parameters
+ ----------
+ description : str, optional
+ The field description.
+ default : Any
+ The literal default value, or ``None`` when ``has_default`` is False.
+ has_default : bool
+ Whether the schema specified an explicit default.
+ required : bool
+ Whether the field is required.
+ alias : str, optional
+ On-the-wire JSON name when it differs from the Python identifier.
+ leading_width : int
+ Number of characters consumed before the ``Field(...)`` call on the line.
+
+ Returns
+ -------
+ str
+ The full ``Field(...)`` source.
+ """
+ args: list[str] = []
+ if has_default and not required:
+ args.append(f"default={render_field_default(default)}")
+ elif not required:
+ args.append("default=None")
+ if alias is not None:
+ args.append(f"alias={alias!r}")
+ if description is not None:
+ args.append(f"description={_python_string_literal(description)}")
+ if not args:
+ return ""
+ single_line = "Field(" + ", ".join(args) + ")"
+ if leading_width + len(single_line) <= _LINE_LENGTH_BUDGET:
+ return single_line
+ inner = ",\n ".join(args)
+ return f"Field(\n {inner},\n )"
+
+
+def _resolve_field_description(
+ prop: dict[str, Any],
+ field_name: str | None = None, # noqa: ARG001
+) -> str:
+ """Pick the description text for a Pydantic Field."""
+ text = prop.get("description") or prop.get("title")
+ parts: list[str] = []
+ if text:
+ parts.append(text.rstrip(". ") + ".")
+ elif prop.get("example") is not None:
+ parts.append(f"Example: {prop['example']}.")
+ enum = prop.get("enum") or (
+ [prop["const"]]
+ if isinstance(prop.get("const"), (str, int, float, bool))
+ else None
+ )
+ if enum:
+ rendered = ", ".join(str(v) for v in enum)
+ parts.append(f"Choices: {rendered}.")
+ return " ".join(parts).rstrip()
+
+
+def _nested_object_breakout(prop: dict[str, Any]) -> str:
+ """Render an inline breakout of a nested object's fields."""
+ target = prop
+ if isinstance(prop.get("items"), dict):
+ target = prop["items"]
+ for combinator in ("oneOf", "anyOf"):
+ variants = target.get(combinator)
+ if isinstance(variants, list):
+ for variant in variants:
+ if isinstance(variant, dict) and isinstance(
+ variant.get("properties"), dict
+ ):
+ target = variant
+ break
+ properties = target.get("properties")
+ if not isinstance(properties, dict) or not properties:
+ return ""
+ summaries: list[str] = []
+ for inner_name, inner_sch in properties.items():
+ if not isinstance(inner_sch, dict):
+ summaries.append(inner_name)
+ continue
+ inner_type = _quick_schema_type(inner_sch)
+ summaries.append(f"{inner_name} ({inner_type})")
+ label = (
+ "Inner item fields" if isinstance(prop.get("items"), dict) else "Inner fields"
+ )
+ return f"{label}: {', '.join(summaries)}."
+
+
+def _quick_schema_type(schema: dict[str, Any]) -> str:
+ """Best-effort shorthand type label for breakout text."""
+ if isinstance(schema.get("enum"), list):
+ return "str"
+ schema_type = schema.get("type")
+ if schema_type == "array":
+ items = schema.get("items")
+ inner = "Any"
+ if isinstance(items, dict):
+ inner = _quick_schema_type(items)
+ return f"list[{inner}]"
+ if schema_type == "object" or "properties" in schema:
+ title = schema.get("title")
+ return title or "object"
+ if schema_type in _PRIMITIVE_TYPES:
+ if schema_type == "string":
+ fmt = schema.get("format")
+ if fmt == "date":
+ return "date"
+ if fmt == "date-time":
+ return "datetime"
+ return _PRIMITIVE_TYPES[schema_type]
+ return "Any"
+
+
+def _python_string_literal(text: str) -> str:
+ """Render a docstring-safe string literal that survives multi-line text.
+
+ Parameters
+ ----------
+ text : str
+ The raw string to embed in generated source.
+
+ Returns
+ -------
+ str
+ A quoted Python literal.
+ """
+ if "\n" in text or "'" in text and '"' in text:
+ escaped = text.replace("\\", "\\\\").replace('"""', '\\"\\"\\"')
+ return f'"""{escaped}"""'
+ return repr(text)
+
+
+def _annotation_for_docstring(annotation: str) -> tuple[str, bool]:
+ """Strip ``| None`` from a type annotation for Numpy-style ``:`` lines.
+
+ Parameters
+ ----------
+ annotation : str
+ The Python type annotation.
+
+ Returns
+ -------
+ tuple of (str, bool)
+ The bare type without ``| None`` suffix, and an optional flag.
+ """
+ suffix = " | None"
+ if annotation.endswith(suffix):
+ return (annotation[: -len(suffix)].strip(), True)
+ return (annotation, False)
+
+
+def _render_numpy_parameters(
+ fields: list[tuple[str, str, str | None, bool, Any, bool]],
+) -> str:
+ """Build a Numpy-style ``Parameters`` block for a class / function docstring.
+
+ Parameters
+ ----------
+ fields : list of tuple
+ Each tuple is ``(name, annotation, description, has_default, default, optional)``.
+
+ Returns
+ -------
+ str
+ The full block.
+ """
+ if not fields:
+ return ""
+ lines = ["Parameters", "----------"]
+ for name, annotation, description, has_default, default, optional in fields:
+ bare, _ = _annotation_for_docstring(annotation)
+ type_label = f"{bare}, optional" if optional else bare
+ lines.append(f"{name} : {type_label}")
+ body = description or ""
+ if has_default and default is not None:
+ suffix = f"(default: {render_field_default(default)})"
+ body = f"{body} {suffix}".strip() if body else suffix
+ if body:
+ for piece in body.split("\n"):
+ lines.append(f" {piece}")
+ return "\n".join(lines)
+
+
+def _render_class_docstring(
+ summary: str,
+ fields: list[tuple[str, str, str | None, bool, Any, bool]],
+) -> str:
+ """Render a Numpy-style class docstring and indent every line by four spaces.
+
+ Parameters
+ ----------
+ summary : str
+ The summary sentence shown immediately after the opening quotes.
+ fields : list of tuple
+ Tuples consumed by ``_render_numpy_parameters``.
+
+ Returns
+ -------
+ str
+ The full triple-double-quoted block.
+ """
+ summary_clean = summary.strip()
+ if not summary_clean.endswith("."):
+ summary_clean += "."
+ lines = [f' """{summary_clean}']
+ params_block = _render_numpy_parameters(fields)
+ if params_block:
+ lines.append("")
+ for line in params_block.split("\n"):
+ lines.append(f" {line}" if line else "")
+ lines.append(' """')
+ return "\n".join(lines)
+
+
+def render_function_docstring(
+ summary: str,
+ *,
+ parameters: list[tuple[str, str, str | None]] | None = None,
+ returns: tuple[str, str | None] | None = None,
+ indent: str = " ",
+) -> str:
+ """Render a Numpy-style function / method docstring.
+
+ Parameters
+ ----------
+ summary : str
+ The one-line summary sentence.
+ parameters : list of (name, type, description) tuples, optional
+ Each tuple becomes one entry under the ``Parameters`` section.
+ returns : tuple of (type, description), optional
+ When supplied, emits a ``Returns`` section.
+ indent : str
+ The indentation prefix for every line of the rendered block.
+
+ Returns
+ -------
+ str
+ The full triple-double-quoted block.
+ """
+ summary_clean = summary.strip()
+ if not summary_clean.endswith("."):
+ summary_clean += "."
+ lines = [f'{indent}"""{summary_clean}']
+ if parameters:
+ lines.append("")
+ lines.append(f"{indent}Parameters")
+ lines.append(f"{indent}----------")
+ for name, type_label, description in parameters:
+ lines.append(f"{indent}{name} : {type_label}")
+ if description:
+ for piece in description.split("\n"):
+ lines.append(f"{indent} {piece}")
+ if returns is not None:
+ return_type, return_desc = returns
+ lines.append("")
+ lines.append(f"{indent}Returns")
+ lines.append(f"{indent}-------")
+ lines.append(f"{indent}{return_type}")
+ if return_desc:
+ for piece in return_desc.split("\n"):
+ lines.append(f"{indent} {piece}")
+ lines.append(f'{indent}"""')
+ return "\n".join(lines)
+
+
+def generate_class(
+ schema: dict[str, Any],
+ *,
+ class_name: str,
+ base_class: str = "BaseModel",
+ docstring: str | None = None,
+) -> GeneratedClass:
+ """Build a Pydantic class definition from an OpenAPI ``object`` schema."""
+ properties: dict[str, Any] = schema.get("properties") or {}
+ required_set: set[str] = set(schema.get("required") or [])
+ nested_classes: list[GeneratedClass] = []
+ imports: set[str] = {"from pydantic import BaseModel, Field"}
+ body_lines: list[str] = []
+ doc_fields: list[tuple[str, str, str | None, bool, Any, bool]] = []
+
+ for raw_name, prop_schema in properties.items():
+ safe_name, needs_alias = safe_field_name(raw_name)
+ annotation = schema_to_type(
+ prop_schema if isinstance(prop_schema, dict) else {},
+ parent_class_name=class_name,
+ field_name=safe_name,
+ nested_classes=nested_classes,
+ imports=imports,
+ )
+ is_required = raw_name in required_set
+ prop = prop_schema if isinstance(prop_schema, dict) else {}
+ has_default = "default" in prop
+ if not is_required and "None" not in annotation:
+ annotation = f"{annotation} | None"
+ description = _resolve_field_description(prop, field_name=raw_name)
+ breakout = _nested_object_breakout(prop)
+ if breakout:
+ description = (
+ f"{description} {breakout}".strip() if description else breakout
+ )
+ prefix = f" {safe_name}: {annotation} = "
+ field_call = _build_field_call(
+ description=description,
+ default=prop.get("default"),
+ has_default=has_default,
+ required=is_required,
+ alias=raw_name if needs_alias else None,
+ leading_width=len(prefix),
+ )
+ line = f" {safe_name}: {annotation}"
+ if field_call:
+ line += f" = {field_call}"
+ body_lines.append(line)
+
+ doc_fields.append(
+ (
+ safe_name,
+ annotation,
+ description,
+ has_default,
+ prop.get("default"),
+ not is_required,
+ )
+ )
+
+ if not body_lines:
+ body_lines.append(" pass")
+
+ summary = docstring or class_name
+ doc_block = _render_class_docstring(summary, doc_fields)
+
+ source = (
+ f"class {class_name}({base_class}):\n"
+ f"{doc_block}\n\n" + "\n".join(body_lines) + "\n"
+ )
+
+ return GeneratedClass(
+ name=class_name,
+ source=source,
+ nested=nested_classes,
+ imports=imports,
+ )
+
+
+def consolidate_imports(imports: set[str]) -> list[str]:
+ """Merge multiple ``from X import A`` / ``from X import B`` into one line.
+
+ Parameters
+ ----------
+ imports : set of str
+ Raw import statements.
+
+ Returns
+ -------
+ list of str
+ Sorted list with same-module ``from`` imports collapsed.
+ """
+ from_imports: dict[str, set[str]] = {}
+ bare_imports: set[str] = set()
+ for raw_line in imports:
+ line = raw_line.strip()
+ if line.startswith("from ") and " import " in line:
+ module, _, names = line[len("from ") :].partition(" import ")
+ for raw_name in names.split(","):
+ name = raw_name.strip()
+ if name:
+ from_imports.setdefault(module.strip(), set()).add(name)
+ else:
+ bare_imports.add(line)
+ out: list[str] = sorted(bare_imports)
+ for module in sorted(from_imports):
+ names = ", ".join(sorted(from_imports[module]))
+ out.append(f"from {module} import {names}")
+ return out
+
+
+def render_module(
+ cls: GeneratedClass,
+ *,
+ module_docstring: str | None = None,
+) -> str:
+ """Render a ``cls`` and its nested classes as a single importable module."""
+ classes = cls.flatten()
+ imports = consolidate_imports(cls.collect_imports())
+
+ stdlib: list[str] = []
+ third_party: list[str] = []
+ for imp in imports:
+ if (
+ imp.startswith("from typing")
+ or imp.startswith("from datetime")
+ or imp.startswith("import typing")
+ or imp.startswith("import datetime")
+ ):
+ stdlib.append(imp)
+ else:
+ third_party.append(imp)
+
+ parts: list[str] = []
+ if module_docstring:
+ summary = module_docstring.strip()
+ if not summary.endswith("."):
+ summary += "."
+ parts.append(f'"""{summary}"""')
+ parts.append("")
+ if stdlib:
+ parts.extend(stdlib)
+ parts.append("")
+ if third_party:
+ parts.extend(third_party)
+ parts.append("")
+ parts.append("")
+ parts.extend(c.source for c in classes)
+ return "\n".join(parts).rstrip() + "\n"
diff --git a/cli/openbb_cli/codegen/router_gen.py b/cli/openbb_cli/codegen/router_gen.py
new file mode 100644
index 00000000000..deb7ef5a31b
--- /dev/null
+++ b/cli/openbb_cli/codegen/router_gen.py
@@ -0,0 +1,315 @@
+"""Generate router modules that mirror the spec's hierarchical namespace."""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING
+
+from openbb_cli.codegen.namespace_tree import NamespaceNode
+
+if TYPE_CHECKING:
+ from openbb_cli.codegen.fetcher_gen import GeneratedFetcher
+ from openbb_cli.codegen.post_gen import GeneratedPostCommand
+
+
+@dataclass
+class GeneratedRouter:
+ """One router module emitted by ``generate_routers``.
+
+ Parameters
+ ----------
+ module_name : str
+ Snake-case module filename without ``.py``.
+ entry_point_name : str | None
+ When set, this router is registered as an ``openbb_core_extension`` entry point.
+ source : str
+ Full module source ready to write to ``routers/.py``.
+ """
+
+ module_name: str
+ entry_point_name: str | None
+ source: str
+
+
+@dataclass
+class GeneratedRouters:
+ """Collection of router modules covering every namespace in the tree.
+
+ Parameters
+ ----------
+ routers : list of GeneratedRouter
+ All emitted router modules.
+ """
+
+ routers: list[GeneratedRouter] = field(default_factory=list)
+
+
+def _safe_segment(name: str) -> str:
+ """Lower-case + replace non-alphanumerics with underscores."""
+ cleaned = re.sub(r"[^0-9a-zA-Z]+", "_", name).strip("_")
+ return cleaned.lower() or "ns"
+
+
+def _module_name_for(path: str) -> str:
+ """Convert a dotted namespace path to a router module name.
+
+ Parameters
+ ----------
+ path : str
+ Dotted namespace path.
+
+ Returns
+ -------
+ str
+ Snake-case module identifier.
+ """
+ return "_".join(_safe_segment(p) for p in path.split(".") if p)
+
+
+def _model_name_for(path: str) -> str:
+ """Convert a dotted command path to a CamelCase model identifier."""
+ return "".join(
+ seg[:1].upper() + seg[1:]
+ for seg in (re.split(r"[^0-9a-zA-Z]+", part) for part in path.split("."))
+ for seg in seg
+ if seg
+ )
+
+
+def generate_routers(
+ root: NamespaceNode,
+ *,
+ package_name: str,
+ provider_name: str,
+ fetchers_by_command: dict[str, GeneratedFetcher],
+ post_commands_by_command: dict[str, GeneratedPostCommand],
+) -> GeneratedRouters:
+ """Emit router modules for every namespace in ``root``.
+
+ Parameters
+ ----------
+ root : NamespaceNode
+ The synthetic root node returned by ``build_namespace_tree``.
+ package_name : str
+ Top-level Python package name.
+ provider_name : str
+ Snake-case provider identifier.
+ fetchers_by_command : dict
+ Map ``{dotted_command: GeneratedFetcher}`` for every GET command.
+ post_commands_by_command : dict
+ Map ``{dotted_command: GeneratedPostCommand}`` for every POST command.
+
+ Returns
+ -------
+ GeneratedRouters
+ One ``GeneratedRouter`` per namespace node.
+ """
+ out = GeneratedRouters()
+ for top_name, top_node in sorted(root.children.items()):
+ _emit_router(
+ top_node,
+ package_name=package_name,
+ provider_name=provider_name,
+ fetchers_by_command=fetchers_by_command,
+ post_commands_by_command=post_commands_by_command,
+ collected=out,
+ is_top_level=True,
+ )
+ return out
+
+
+def _emit_router(
+ node: NamespaceNode,
+ *,
+ package_name: str,
+ provider_name: str,
+ fetchers_by_command: dict[str, GeneratedFetcher],
+ post_commands_by_command: dict[str, GeneratedPostCommand],
+ collected: GeneratedRouters,
+ is_top_level: bool,
+) -> str:
+ """Recursively emit one router module per namespace node.
+
+ Parameters
+ ----------
+ node : NamespaceNode
+ The namespace this call emits a router for.
+ package_name : str
+ Top-level Python package name.
+ provider_name : str
+ Provider identifier for ``providers//`` imports.
+ fetchers_by_command : dict
+ GET-command fetcher modules.
+ post_commands_by_command : dict
+ POST-command modules.
+ collected : GeneratedRouters
+ Output accumulator.
+ is_top_level : bool
+ Whether this namespace is at the top of the tree.
+
+ Returns
+ -------
+ str
+ The module name of the emitted router.
+ """
+ module_name = _module_name_for(node.full_path)
+ parts: list[str] = []
+ parts.append(f'"""Router for {node.full_path} commands โ generated from spec."""')
+ parts.append("")
+ parts.append("from openbb_core.app.model.command_context import CommandContext")
+ parts.append("from openbb_core.app.model.obbject import OBBject")
+ parts.append(
+ "from openbb_core.app.provider_interface import "
+ "ExtraParams, ProviderChoices, StandardParams"
+ )
+ parts.append("from openbb_core.app.query import Query")
+ parts.append("from openbb_core.app.router import Router")
+
+ sub_imports: list[tuple[str, str, str]] = []
+ for sub_name, sub_node in sorted(node.children.items()):
+ if not sub_node.is_namespace:
+ continue
+ sub_module = _emit_router(
+ sub_node,
+ package_name=package_name,
+ provider_name=provider_name,
+ fetchers_by_command=fetchers_by_command,
+ post_commands_by_command=post_commands_by_command,
+ collected=collected,
+ is_top_level=False,
+ )
+ sub_imports.append((sub_module, _safe_segment(sub_name), sub_node.full_path))
+
+ post_imports: list[tuple[str, str]] = []
+ for child in node.children.values():
+ if child.is_command and child.cmd_spec is not None:
+ post = post_commands_by_command.get(child.full_path)
+ if post is not None:
+ post_module = (
+ f"{package_name}.providers.{provider_name}.models."
+ f"{post.module_name}"
+ )
+ post_imports.append((post_module, post.function_name))
+
+ if post_imports:
+ parts.append("")
+ for module_path, function_name in post_imports:
+ parts.append(
+ f"from {module_path} import {function_name} as _{function_name}"
+ )
+
+ if sub_imports:
+ parts.append("")
+ for sub_module, sub_segment, _full in sub_imports:
+ parts.append(
+ f"from {package_name}.routers.{sub_module} import "
+ f"router as _{sub_segment}_router"
+ )
+
+ parts.append("")
+ parts.append("")
+ parts.append('router = Router(prefix="")')
+ parts.append("")
+
+ for _, sub_segment, _full in sub_imports:
+ parts.append(
+ f'router.include_router(_{sub_segment}_router, prefix="/{sub_segment}")'
+ )
+ if sub_imports:
+ parts.append("")
+
+ for child_name, child in sorted(node.children.items()):
+ if not child.is_command or child.cmd_spec is None:
+ continue
+ function_name = _safe_segment(child_name)
+ description = (child.cmd_spec.get("description") or "").strip()
+ fetcher = fetchers_by_command.get(child.full_path)
+ if fetcher is not None:
+ parts.append(
+ _render_get_command(
+ fetcher,
+ function_name=function_name,
+ description=description,
+ )
+ )
+ else:
+ post = post_commands_by_command.get(child.full_path)
+ if post is not None:
+ parts.append(
+ f'router.command(methods=["POST"])(_{post.function_name})\n'
+ )
+
+ source = "\n".join(parts).rstrip() + "\n"
+ collected.routers.append(
+ GeneratedRouter(
+ module_name=module_name,
+ entry_point_name=node.name if is_top_level else None,
+ source=source,
+ )
+ )
+ return module_name
+
+
+def _render_get_command(
+ fetcher: GeneratedFetcher,
+ *,
+ function_name: str,
+ description: str,
+) -> str:
+ """Render one ``@router.command(model=...)`` block for a GET endpoint.
+
+ Parameters
+ ----------
+ fetcher : GeneratedFetcher
+ Per-command fetcher metadata.
+ function_name : str
+ Leaf segment of the dotted path used as the function identifier.
+ description : str
+ Spec-supplied command description.
+
+ Returns
+ -------
+ str
+ The decorator + ``async def`` block.
+ """
+ summary = description or f"{fetcher.model_name} command."
+ docstring = _format_docstring(summary)
+ return (
+ f'@router.command(model="{fetcher.model_name}")\n'
+ f"async def {function_name}(\n"
+ " cc: CommandContext,\n"
+ " provider_choices: ProviderChoices,\n"
+ " standard_params: StandardParams,\n"
+ " extra_params: ExtraParams,\n"
+ ") -> OBBject:\n"
+ f"{docstring}\n"
+ " return await OBBject.from_query(Query(**locals()))\n"
+ )
+
+
+def _format_docstring(text: str) -> str:
+ """Render ``text`` as an indented function docstring.
+
+ Parameters
+ ----------
+ text : str
+ Source description from the spec.
+
+ Returns
+ -------
+ str
+ Triple-quoted docstring body indented four spaces.
+ """
+ cleaned = text.strip()
+ if not cleaned:
+ cleaned = "."
+ if not cleaned.endswith((".", "!", "?")):
+ cleaned += "."
+ if "\n" not in cleaned:
+ return f' """{cleaned}"""'
+ first, _, rest = cleaned.partition("\n")
+ rest_indented = "\n".join(
+ f" {line}" if line.strip() else "" for line in rest.split("\n")
+ )
+ return f' """{first}\n\n{rest_indented}\n """'
diff --git a/cli/openbb_cli/codegen/test_gen.py b/cli/openbb_cli/codegen/test_gen.py
new file mode 100644
index 00000000000..4028e723976
--- /dev/null
+++ b/cli/openbb_cli/codegen/test_gen.py
@@ -0,0 +1,306 @@
+"""Emit per-provider unit-test modules for a generated extension."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from openbb_cli.codegen.fetcher_gen import GeneratedFetcher
+
+
+_STANDARD_HEADER_REDACTIONS: tuple[tuple[str, str | None], ...] = (
+ ("User-Agent", None),
+ ("Authorization", "Bearer MOCK_TOKEN"),
+ ("X-API-Key", "MOCK_API_KEY"),
+ ("X-Api-Key", "MOCK_API_KEY"),
+ ("apikey", "MOCK_API_KEY"),
+ ("api-key", "MOCK_API_KEY"),
+ ("X-Auth-Token", "MOCK_TOKEN"),
+ ("X-Access-Token", "MOCK_TOKEN"),
+ ("Cookie", "MOCK_COOKIE"),
+ ("Set-Cookie", "MOCK_COOKIE"),
+)
+
+_STANDARD_QUERY_REDACTIONS: tuple[tuple[str, str], ...] = (
+ ("apikey", "MOCK_API_KEY"),
+ ("api_key", "MOCK_API_KEY"),
+ ("api-key", "MOCK_API_KEY"),
+ ("key", "MOCK_API_KEY"),
+ ("subscription_key", "MOCK_API_KEY"),
+ ("ocp_apim_subscription_key", "MOCK_API_KEY"),
+ ("token", "MOCK_TOKEN"),
+ ("access_token", "MOCK_TOKEN"),
+ ("auth_token", "MOCK_TOKEN"),
+ ("bearer_token", "MOCK_TOKEN"),
+ ("session_token", "MOCK_TOKEN"),
+ ("client_id", "MOCK_CLIENT_ID"),
+ ("client_secret", "MOCK_CLIENT_SECRET"),
+ ("client_token", "MOCK_TOKEN"),
+ ("app_id", "MOCK_APP_ID"),
+ ("app_key", "MOCK_APP_KEY"),
+ ("app_token", "MOCK_TOKEN"),
+ ("appid", "MOCK_APP_ID"),
+ ("secret", "MOCK_SECRET"),
+ ("secret_key", "MOCK_SECRET"),
+ ("private_key", "MOCK_SECRET"),
+ ("consumer_key", "MOCK_API_KEY"),
+ ("consumer_secret", "MOCK_SECRET"),
+)
+
+
+def _mock_value_for(name: str) -> str:
+ """Pick a mock placeholder that matches what kind of credential ``name`` is."""
+ lower = name.lower().replace("-", "_")
+ if "secret" in lower:
+ return "MOCK_SECRET"
+ if "token" in lower:
+ return "MOCK_TOKEN"
+ if "client_id" in lower or lower.endswith("_id") or lower == "appid":
+ return "MOCK_CLIENT_ID"
+ return "MOCK_API_KEY"
+
+
+@dataclass
+class GeneratedTestModule:
+ """One ``tests/test__fetchers.py`` ready to write to disk.
+
+ Parameters
+ ----------
+ module_name : str
+ Filename without ``.py``.
+ source : str
+ Full module source.
+ """
+
+ module_name: str
+ source: str
+
+
+def generate_provider_tests(
+ *,
+ package_name: str,
+ provider_name: str,
+ fetchers: list[GeneratedFetcher],
+ commands_by_dotted: dict[str, dict[str, Any]],
+) -> GeneratedTestModule | None:
+ """Render a unit-test module exercising every fetcher in ``fetchers``.
+
+ Parameters
+ ----------
+ package_name : str
+ Snake-case top-level package.
+ provider_name : str
+ Snake-case provider identifier.
+ fetchers : list of GeneratedFetcher
+ The fetchers to test.
+ commands_by_dotted : dict
+ Map ``{dotted_command: cmd_spec}``.
+
+ Returns
+ -------
+ GeneratedTestModule or None
+ ``None`` when there's nothing to test.
+ """
+ if not fetchers:
+ return None
+
+ testable: list[tuple[GeneratedFetcher, dict[str, Any]]] = []
+ for f in sorted(fetchers, key=lambda x: x.fetcher_class):
+ dotted = _dotted_for_fetcher(f, commands_by_dotted)
+ cmd_spec = commands_by_dotted.get(dotted, {}) if dotted else {}
+ params = _derive_test_params(cmd_spec, provider_name)
+ if params is None:
+ continue
+ testable.append((f, params))
+
+ if not testable:
+ return None
+
+ parts: list[str] = [
+ f'"""Auto-generated unit tests for the {provider_name} provider."""',
+ "",
+ "import pytest",
+ "from openbb_core.app.service.user_service import UserService",
+ "",
+ ]
+ for f, _ in testable:
+ parts.append(
+ f"from {package_name}.providers.{provider_name}.models."
+ f"{f.module_name} import {f.fetcher_class}"
+ )
+ parts.append("")
+ parts.append("")
+ parts.append(
+ "test_credentials = UserService()"
+ ".default_user_settings.credentials.model_dump(mode='json')"
+ )
+ parts.append("")
+ parts.append("")
+ header_filters, query_filters = _merge_redactions([f for f, _ in testable])
+ parts.append('@pytest.fixture(scope="module")')
+ parts.append("def vcr_config():")
+ parts.append(' """Scrub credential fields from recorded cassettes."""')
+ parts.append(" return {")
+ parts.append(f' "filter_headers": {header_filters!r},')
+ parts.append(f' "filter_query_parameters": {query_filters!r},')
+ parts.append(" }")
+ parts.append("")
+ parts.append("")
+ for f, params in testable:
+ parts.append("@pytest.mark.record_http")
+ parts.append(
+ f"def test_{_snake(f.fetcher_class)}(credentials=test_credentials):"
+ )
+ parts.append(f" params = {params!r}")
+ parts.append(f" fetcher = {f.fetcher_class}()")
+ parts.append(" result = fetcher.test(params, credentials)")
+ parts.append(" assert result is None")
+ parts.append("")
+ parts.append("")
+
+ source = "\n".join(parts).rstrip() + "\n"
+ return GeneratedTestModule(
+ module_name=f"test_{provider_name}_fetchers",
+ source=source,
+ )
+
+
+def _merge_redactions(
+ fetchers: list[GeneratedFetcher],
+) -> tuple[list[tuple[str, str | None]], list[tuple[str, str]]]:
+ """Build (header_filters, query_filters) for the ``vcr_config`` fixture."""
+ headers: dict[str, str | None] = {}
+ queries: dict[str, str] = {}
+ for f in fetchers:
+ for info in f.credentials_used.values():
+ wire = info.get("name")
+ location = info.get("in", "query")
+ if not wire:
+ continue
+ if location == "header":
+ headers[wire] = _mock_value_for(wire)
+ else:
+ queries[wire] = _mock_value_for(wire)
+ for n, v in _STANDARD_HEADER_REDACTIONS:
+ headers[n] = v
+ for n, v in _STANDARD_QUERY_REDACTIONS:
+ queries[n] = v
+ return (
+ sorted(headers.items()),
+ sorted(queries.items()),
+ )
+
+
+def _dotted_for_fetcher(
+ fetcher: GeneratedFetcher,
+ commands_by_dotted: dict[str, dict[str, Any]],
+) -> str | None:
+ """Find the dotted command path that produced ``fetcher``."""
+ target = fetcher.module_name
+ for dotted in commands_by_dotted:
+ if _normalize_to_module(dotted) == target:
+ return dotted
+ return None
+
+
+def _normalize_to_module(dotted: str) -> str:
+ """Match ``fetcher_gen._module_name_from_command`` exactly."""
+ import re
+
+ safe = re.sub(r"[^0-9a-zA-Z]+", "_", dotted).strip("_").lower()
+ if not safe:
+ safe = "command"
+ if safe[0].isdigit():
+ safe = f"_{safe}"
+ return safe
+
+
+_DATE_PARAM_NAMES: frozenset[str] = frozenset(
+ {
+ "date",
+ "from",
+ "to",
+ "fromdate",
+ "todate",
+ "start_date",
+ "end_date",
+ "startdate",
+ "enddate",
+ }
+)
+
+
+def _is_date_param(name: str) -> bool:
+ """Heuristic: this param's name implies a date / time range filter."""
+ lower = name.lower().replace("-", "_")
+ return lower in _DATE_PARAM_NAMES or "date" in lower
+
+
+def _derive_test_params(
+ cmd_spec: dict[str, Any], provider_name: str
+) -> dict[str, Any] | None:
+ """Build a kwargs dict for the command's testable params, or ``None``."""
+ out: dict[str, Any] = {}
+ for raw in cmd_spec.get("parameters") or []:
+ if not isinstance(raw, dict):
+ continue
+ name = raw.get("name")
+ if not name or name == "provider":
+ continue
+ param_providers = raw.get("providers") or []
+ if param_providers and provider_name not in param_providers:
+ continue
+ if raw.get("required"):
+ value = _spec_supplied_value(raw)
+ if value is None:
+ return None
+ out[name] = value
+ elif _is_date_param(name):
+ out[name] = _refresh_date_shape(name, "1970-01-01")
+ return out
+
+
+def _spec_supplied_value(param: dict[str, Any]) -> Any:
+ """Return a value the spec itself vouches for, or ``None`` to abort the test."""
+ raw_value: Any = None
+ if param.get("example") is not None:
+ raw_value = param["example"]
+ elif param.get("default") is not None:
+ raw_value = param["default"]
+ else:
+ choices = param.get("choices") or []
+ if choices:
+ raw_value = choices[0]
+ if raw_value is None:
+ return None
+ return _refresh_date_shape(param.get("name") or "", raw_value)
+
+
+def _refresh_date_shape(name: str, value: Any) -> Any:
+ """Replace an aged date-shaped string with an equivalent current date."""
+ import re
+ from datetime import date, datetime, timedelta, timezone
+
+ if not isinstance(value, str):
+ return value
+ lower = name.lower()
+ is_start = "start" in lower or lower in {"from", "fromdate"}
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
+ today = date.today()
+ return str(today - timedelta(days=30) if is_start else today)
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?", value):
+ now = datetime.now(timezone.utc).replace(microsecond=0)
+ chosen = (now - timedelta(days=30)) if is_start else now
+ return chosen.isoformat().replace("+00:00", "Z")
+ return value
+
+
+def _snake(camel: str) -> str:
+ """``CamelCase`` -> ``snake_case`` for test function names."""
+ out: list[str] = []
+ for i, ch in enumerate(camel):
+ if ch.isupper() and i > 0 and not camel[i - 1].isupper():
+ out.append("_")
+ out.append(ch.lower())
+ return "".join(out)
diff --git a/cli/openbb_cli/config/completer.py b/cli/openbb_cli/config/completer.py
index 9dd0f92184a..9f5099b5e1b 100644
--- a/cli/openbb_cli/config/completer.py
+++ b/cli/openbb_cli/config/completer.py
@@ -13,29 +13,29 @@ from prompt_toolkit.history import FileHistory
NestedDict = Mapping[str, Any | set[str] | None | Completer]
-# pylint: disable=too-many-arguments,global-statement,too-many-branches,global-variable-not-assigned
-
class WordCompleter(Completer):
"""Simple autocompletion on a list of words.
- :param words: List of words or callable that returns a list of words.
- :param ignore_case: If True, case-insensitive completion.
- :param meta_dict: Optional dict mapping words to their meta-text. (This
- should map strings to strings or formatted text.)
- :param WORD: When True, use WORD characters.
- :param sentence: When True, don't complete by comparing the word before the
- cursor, but by comparing all the text before the cursor. In this case,
- the list of words is just a list of strings, where each string can
- contain spaces. (Can not be used together with the WORD option.)
- :param match_middle: When True, match not only the start, but also in the
- middle of the word.
- :param pattern: Optional compiled regex for finding the word before
- the cursor to complete. When given, use this regex pattern instead of
- default one (see document._FIND_WORD_RE)
+ Parameters
+ ----------
+ words : list[str] or callable
+ List of words or a callable returning a list of words.
+ ignore_case : bool
+ When ``True``, complete case-insensitively.
+ meta_dict : Mapping[str, AnyFormattedText], optional
+ Maps words to their meta-text.
+ WORD : bool
+ When ``True``, use WORD characters.
+ sentence : bool
+ When ``True``, compare all text before the cursor. Cannot be used with ``WORD``.
+ match_middle : bool
+ When ``True``, match the middle of the word too.
+ pattern : re.Pattern[str], optional
+ Compiled regex for finding the word before the cursor.
"""
- def __init__( # pylint: disable=R0917
+ def __init__(
self,
words: list[str] | Callable[[], list[str]],
ignore_case: bool = False,
@@ -58,18 +58,16 @@ class WordCompleter(Completer):
self.match_middle = match_middle
self.pattern = pattern
- def get_completions(
+ def get_completions( # ty: ignore[invalid-method-override]
self,
document: Document,
_complete_event: CompleteEvent,
) -> Iterable[Completion]:
"""Get completions."""
- # Get list of words.
words = self.words
if callable(words):
- words = words()
+ words = words() # ty: ignore[call-top-callable]
- # Get word/text before cursor.
if self.sentence:
word_before_cursor = document.text_before_cursor
else:
@@ -82,7 +80,9 @@ class WordCompleter(Completer):
>= document.text_before_cursor.rfind(" -")
):
word_before_cursor = f"--{document.text_before_cursor.split('--')[-1]}"
- elif f"--{word_before_cursor}" == document.text_before_cursor:
+ elif ( # pragma: no cover
+ f"--{word_before_cursor}" == document.text_before_cursor
+ ):
word_before_cursor = document.text_before_cursor
if self.ignore_case:
@@ -110,15 +110,7 @@ class WordCompleter(Completer):
class NestedCompleter(Completer):
- """Completer which wraps around several other completers, and calls any the
- one that corresponds with the first word of the input.
-
- By combining multiple `NestedCompleter` instances, we can achieve multiple
- hierarchical levels of autocompletion. This is useful when `WordCompleter`
- is not sufficient.
-
- If you need multiple levels, check out the `from_nested_dict` classmethod.
- """
+ """Completer wrapping several others, dispatching by the input's first word."""
complementary: list = list()
@@ -138,29 +130,7 @@ class NestedCompleter(Completer):
@classmethod
def from_nested_dict(cls, data: dict) -> "NestedCompleter":
- """Create a `NestedCompleter`.
-
- It starts from a nested dictionary data structure, like this:
-
- .. code::
-
- data = {
- 'show': {
- 'version': None,
- 'interfaces': None,
- 'clock': None,
- 'ip': {'interface': {'brief'}}
- },
- 'exit': None
- 'enable': None
- }
-
- The value should be `None` if there is no further completion at some
- point. If all values in the dictionary are None, it is also possible to
- use a set instead.
-
- Values in this data structure can be a completers as well.
- """
+ """Create a `NestedCompleter` from a nested dictionary."""
options: dict[str, Any] = {}
for key, value in data.items():
if isinstance(value, Completer):
@@ -187,7 +157,6 @@ class NestedCompleter(Completer):
self, document: Document, complete_event: CompleteEvent
) -> Iterable[Completion]:
"""Get completions."""
- # Split document.
cmd = ""
text = document.text_before_cursor.lstrip()
if " " in text:
@@ -201,7 +170,6 @@ class NestedCompleter(Completer):
unprocessed_text = text
stripped_len = len(document.text_before_cursor) - len(text)
- # Check if there are multiple flags for the same command
if self.complementary:
for same_flags in self.complementary:
if (
@@ -218,8 +186,8 @@ class NestedCompleter(Completer):
if cmd:
self.options = {
- k: self.original_options.get(cmd).options[k] # type: ignore
- for k in self.original_options.get(cmd).options # type: ignore
+ k: self.original_options.get(cmd).options[k] # ty: ignore[unresolved-attribute]
+ for k in self.original_options.get(cmd).options # ty: ignore[unresolved-attribute]
if k not in self.flags_processed
}
else:
@@ -229,11 +197,9 @@ class NestedCompleter(Completer):
if k not in self.flags_processed
}
- # If there is a space, check for the first term, and use a subcompleter.
if " " in unprocessed_text:
first_term = unprocessed_text.split()[0]
- # user is updating one of the values
if unprocessed_text[-1] != " ":
self.flags_processed = [
flag for flag in self.flags_processed if flag != first_term
@@ -248,14 +214,18 @@ class NestedCompleter(Completer):
same_flags[1] in self.flags_processed
and same_flags[0] not in self.flags_processed
):
- if same_flags[0] in self.flags_processed:
+ if (
+ same_flags[0] in self.flags_processed
+ ): # pragma: no cover
self.flags_processed.remove(same_flags[0])
- elif same_flags[1] in self.flags_processed:
+ elif (
+ same_flags[1] in self.flags_processed
+ ): # pragma: no cover
self.flags_processed.remove(same_flags[1])
if cmd and self.original_options.get(cmd):
self.options = self.original_options
- else:
+ else: # pragma: no cover
self.options = {
k: self.original_options[k]
for k in self.original_options
@@ -265,11 +235,10 @@ class NestedCompleter(Completer):
if "-" not in text:
completer = self.options.get(first_term)
elif cmd in self.options and self.options.get(cmd):
- completer = self.options.get(cmd).options.get(first_term) # type: ignore
- else:
+ completer = self.options.get(cmd).options.get(first_term) # ty: ignore[unresolved-attribute]
+ else: # pragma: no cover
completer = self.options.get(first_term)
- # If we have a sub completer, use this for the completions.
if completer is not None:
remaining_text = unprocessed_text[len(first_term) :].lstrip()
move_cursor = len(text) - len(remaining_text) + stripped_len
@@ -279,7 +248,6 @@ class NestedCompleter(Completer):
cursor_position=document.cursor_position - move_cursor,
)
- # Provides auto-completion but if user doesn't take it still keep going
if " " in new_document.text:
if (
new_document.text in [f"{opt} " for opt in self.options]
@@ -288,22 +256,21 @@ class NestedCompleter(Completer):
self.flags_processed.append(first_term)
if cmd:
self.options = {
- k: self.original_options.get(cmd).options[k] # type: ignore
- for k in self.original_options.get(cmd).options # type: ignore
+ k: self.original_options.get(cmd).options[k] # ty: ignore[unresolved-attribute]
+ for k in self.original_options.get(cmd).options # ty: ignore[unresolved-attribute]
if k not in self.flags_processed
}
- else:
+ else: # pragma: no cover
self.options = {
k: self.original_options[k]
for k in self.original_options
if k not in self.flags_processed
}
- # In case the users inputs a single boolean flag
- elif not completer.options: # type: ignore
+ elif not completer.options: # ty: ignore[unresolved-attribute]
self.flags_processed.append(first_term)
- if self.complementary:
+ if self.complementary: # pragma: no cover
for same_flags in self.complementary:
if (
same_flags[0] in self.flags_processed
@@ -319,11 +286,11 @@ class NestedCompleter(Completer):
if cmd:
self.options = {
- k: self.original_options.get(cmd).options[k] # type: ignore
- for k in self.original_options.get(cmd).options # type: ignore
+ k: self.original_options.get(cmd).options[k] # ty: ignore[unresolved-attribute]
+ for k in self.original_options.get(cmd).options # ty: ignore[unresolved-attribute]
if k not in self.flags_processed
}
- else:
+ else: # pragma: no cover
self.options = {
k: self.original_options[k]
for k in self.original_options
@@ -331,12 +298,9 @@ class NestedCompleter(Completer):
}
else:
- # This is a NestedCompleter
yield from completer.get_completions(new_document, complete_event)
- # No space in the input: behave exactly like `WordCompleter`.
else:
- # check if the prompt has been updated in the meantime
if " " in text or "-" in text:
actual_flags_processed = [
flag for flag in self.flags_processed if flag in text
@@ -353,15 +317,17 @@ class NestedCompleter(Completer):
):
if same_flags[0] in actual_flags_processed:
actual_flags_processed.append(same_flags[1])
- elif same_flags[1] in actual_flags_processed:
+ elif (
+ same_flags[1] in actual_flags_processed
+ ): # pragma: no cover
actual_flags_processed.append(same_flags[0])
if len(actual_flags_processed) < len(self.flags_processed):
self.flags_processed = actual_flags_processed
- if cmd:
+ if cmd: # pragma: no cover
self.options = {
- k: self.original_options.get(cmd).options[k] # type: ignore
- for k in self.original_options.get(cmd).options # type: ignore
+ k: self.original_options.get(cmd).options[k] # ty: ignore[unresolved-attribute]
+ for k in self.original_options.get(cmd).options # ty: ignore[unresolved-attribute]
if k not in self.flags_processed
}
else:
@@ -372,12 +338,12 @@ class NestedCompleter(Completer):
}
command = self.options.get(cmd)
- options = command.options if command else {} # type: ignore
+ options = command.options if command else {} # ty: ignore[unresolved-attribute]
command_options = [f"{cmd} {opt}" for opt in options]
text_list = [text in val for val in command_options]
if cmd and cmd in self.options and text_list:
completer = WordCompleter(
- list(self.options.get(cmd).options.keys()), # type: ignore
+ list(self.options.get(cmd).options.keys()), # ty: ignore[unresolved-attribute]
ignore_case=self.ignore_case,
)
elif bool([val for val in self.options if text in val]):
@@ -385,7 +351,6 @@ class NestedCompleter(Completer):
list(self.options.keys()), ignore_case=self.ignore_case
)
else:
- # The user has delete part of the first command and we need to reset options
if bool([val for val in self.original_options if text in val]):
self.options = self.original_options
self.flags_processed = list()
@@ -393,7 +358,6 @@ class NestedCompleter(Completer):
list(self.options.keys()), ignore_case=self.ignore_case
)
- # This is a WordCompleter
yield from completer.get_completions(document, complete_event)
diff --git a/cli/openbb_cli/config/constants.py b/cli/openbb_cli/config/constants.py
index adac9bfd6f2..b1d4a423bf5 100644
--- a/cli/openbb_cli/config/constants.py
+++ b/cli/openbb_cli/config/constants.py
@@ -2,7 +2,6 @@
from pathlib import Path
-# Paths
HOME_DIRECTORY = Path.home()
REPOSITORY_DIRECTORY = Path(__file__).parent.parent.parent.parent
SRC_DIRECTORY = Path(__file__).parent.parent
diff --git a/cli/openbb_cli/config/loader.py b/cli/openbb_cli/config/loader.py
new file mode 100644
index 00000000000..3861089e6cc
--- /dev/null
+++ b/cli/openbb_cli/config/loader.py
@@ -0,0 +1,451 @@
+"""Layered configuration loader for openbb-cli."""
+
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+from typing import Any
+
+if sys.version_info >= (3, 11): # pragma: no cover
+ import tomllib
+else: # pragma: no cover
+ import tomli as tomllib # ty: ignore[unresolved-import]
+
+DEFAULT_CONFIG_NAMES: tuple[str, ...] = ("openbb.toml", ".openbb.toml")
+PYPROJECT_NAME = "pyproject.toml"
+PYPROJECT_TABLE: tuple[str, ...] = ("tool", "openbb-cli")
+EXPLICIT_CONFIG_ENV = "OPENBB_CLI_CONFIG"
+EXPLICIT_ENV_FILE_ENV = "OPENBB_CLI_ENV_FILE"
+
+USER_OPENBB_DIR = Path.home() / ".openbb_platform"
+USER_OPENBB_TOML_NAMES: tuple[str, ...] = ("openbb.toml", ".openbb.toml")
+USER_OPENBB_ENV_NAME = ".env"
+
+
+def _walk_up(start: Path | None = None):
+ cur = (start or Path.cwd()).resolve()
+ yield cur
+ yield from cur.parents
+
+
+def _read_toml(path: Path) -> dict[str, Any]:
+ try:
+ with path.open("rb") as fh:
+ return tomllib.load(fh)
+ except (OSError, tomllib.TOMLDecodeError):
+ return {}
+
+
+def _find_first(start: Path | None, names: tuple[str, ...]) -> Path | None:
+ for parent in _walk_up(start):
+ for name in names:
+ candidate = parent / name
+ if candidate.is_file():
+ return candidate
+ return None
+
+
+def _find_pyproject_section(start: Path | None = None) -> dict[str, Any]:
+ """Find the nearest ancestor ``pyproject.toml`` with ``[tool.openbb-cli]``."""
+ py = _find_first(start, (PYPROJECT_NAME,))
+ if py is None:
+ return {}
+ data = _read_toml(py)
+ node: Any = data
+ for key in PYPROJECT_TABLE:
+ if not isinstance(node, dict) or key not in node:
+ return {}
+ node = node[key]
+ return node if isinstance(node, dict) else {}
+
+
+def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> None:
+ for k, v in override.items():
+ if k in base and isinstance(base[k], dict) and isinstance(v, dict):
+ _deep_merge(base[k], v)
+ else:
+ base[k] = v
+
+
+def _normalize_keys(d: dict[str, Any]) -> dict[str, Any]:
+ """Normalize top-level kebab-case keys to snake_case for argparse ``dest`` matching."""
+ return {k.replace("-", "_"): v for k, v in d.items()}
+
+
+def _user_global_toml() -> Path | None:
+ """Locate ``~/.openbb_platform/openbb.toml`` (or the dotted variant)."""
+ if not USER_OPENBB_DIR.is_dir():
+ return None
+ for name in USER_OPENBB_TOML_NAMES:
+ candidate = USER_OPENBB_DIR / name
+ if candidate.is_file():
+ return candidate
+ return None
+
+
+def load_config(
+ explicit_path: str | os.PathLike[str] | None = None,
+ *,
+ start: Path | None = None,
+) -> dict[str, Any]:
+ """Resolve the layered openbb-cli configuration.
+
+ Parameters
+ ----------
+ explicit_path : str or os.PathLike, optional
+ Explicit config path; falls back to ``$OPENBB_CLI_CONFIG``.
+ start : Path, optional
+ Directory to begin the walk-up search from.
+
+ Returns
+ -------
+ dict[str, Any]
+ Merged config with normalized top-level keys; ``{}`` when no layer found.
+ """
+ layers: list[dict[str, Any]] = [_find_pyproject_section(start)]
+ user_toml = _user_global_toml()
+ if user_toml is not None:
+ layers.append(_read_toml(user_toml))
+ project_toml = _find_first(start, DEFAULT_CONFIG_NAMES)
+ if project_toml is not None:
+ layers.append(_read_toml(project_toml))
+ explicit = explicit_path or os.environ.get(EXPLICIT_CONFIG_ENV)
+ if explicit:
+ path = Path(explicit).expanduser()
+ if path.is_file():
+ layers.append(_read_toml(path))
+ merged: dict[str, Any] = {}
+ for layer in layers:
+ _deep_merge(merged, _normalize_keys(layer))
+ return merged
+
+
+def render_config_template(active: dict[str, Any] | None = None) -> str:
+ """Render a documented TOML template covering every supported setting."""
+ a = active or {}
+ headers = a.get("headers") or {}
+ query = a.get("query") or {}
+
+ def line(key: str, value: Any, doc: str, env: str, cli: str) -> str:
+ commented = "# " if value in (None, "", []) else ""
+ rendered = _toml_quote(value) if value not in (None, "", []) else '""'
+ return (
+ f"# {doc}\n"
+ f"# env: {env}\n"
+ f"# flag: {cli}\n"
+ f"{commented}{key} = {rendered}\n\n"
+ )
+
+ parts: list[str] = [
+ "# openbb-cli configuration template.\n"
+ "#\n"
+ "# Drop this file at any of the following discovery locations:\n"
+ "# * ~/.openbb_platform/openbb.toml (user-global)\n"
+ "# * ./openbb.toml or ./.openbb.toml (project-local; walks up from CWD)\n"
+ "# * [tool.openbb-cli] in pyproject.toml (ships with a dev project)\n"
+ "# * --config PATH or $OPENBB_CLI_CONFIG (explicit, swappable)\n"
+ "#\n"
+ "# Resolution order (lowest โ highest priority):\n"
+ "# defaults -> pyproject -> ~/.openbb_platform/openbb.toml -> ./openbb.toml\n"
+ "# -> --config -> .env files -> OPENBB_* env vars -> CLI flags\n"
+ "#\n"
+ "# Lines starting with # are commented out. Either uncomment to set a value\n"
+ "# (and the matching env/CLI override still wins), or leave commented to\n"
+ "# fall through to the next layer.\n\n",
+ ]
+
+ parts.append(
+ line(
+ "server",
+ a.get("server"),
+ "HTTP backend URL. When set, dispatch goes via openbb-platform-api over HTTP\n"
+ "# instead of the in-process LocalDispatcher.",
+ "OPENBB_SERVER_URL",
+ "--server",
+ )
+ )
+ parts.append(
+ line(
+ "spec",
+ a.get("spec"),
+ "Path to a precomputed .spec file (built via --generate-spec). When set, dispatch\n"
+ "# skips the OpenAPI fetch + parse on every call.",
+ "OPENBB_SPEC_PATH",
+ "--spec",
+ )
+ )
+ parts.append(
+ line(
+ "openapi-path",
+ a.get("openapi_path"),
+ "Path (or full URL) to the OpenAPI document on the server. Defaults to\n"
+ "# /openapi.json. Servers that publish under a different name (e.g. NY Fed at\n"
+ "# /static/docs/markets-api.yml) need this.",
+ "(none)",
+ "--openapi-path",
+ )
+ )
+ parts.append(
+ line(
+ "header-file",
+ a.get("header_file"),
+ "JSON object on disk supplying additional headers. --header flags and the\n"
+ "# [headers] table below take precedence on conflicts.",
+ "OPENBB_HEADER_FILE",
+ "--header-file",
+ )
+ )
+ parts.append(
+ line(
+ "query-param-file",
+ a.get("query_param_file"),
+ "JSON object on disk supplying additional query params. --query-param flags,\n"
+ "# OPENBB_HTTP_QUERY_* env vars, and the [query] table below take precedence.",
+ "OPENBB_QUERY_PARAM_FILE",
+ "--query-param-file",
+ )
+ )
+ parts.append(
+ line(
+ "output",
+ a.get("output"),
+ "Output spec path for --generate-spec. Default: openbb.spec",
+ "(none)",
+ "--output",
+ )
+ )
+ parts.append(
+ line(
+ "batch-concurrency",
+ a.get("batch_concurrency"),
+ "Maximum number of concurrent in-flight dispatches in --batch mode.\n"
+ "# Higher values raise throughput against fast servers; lower values give\n"
+ "# back-pressure when an upstream rate-limits. Default: 8.",
+ "OPENBB_CLI_BATCH_CONCURRENCY",
+ "--batch-concurrency",
+ )
+ )
+ parts.append(
+ "# โโ REPL display preferences (most-tweaked subset of [settings]) โโโโโโโโโ\n"
+ "# These are the four interactive-output knobs users hit constantly. Setting\n"
+ "# them at the top level overrides anything in the [settings] block below.\n"
+ "# Every other Settings field is still available under [settings].\n\n"
+ )
+ parts.append(
+ line(
+ "output-mode",
+ a.get("output_mode"),
+ "Output display mode for command results: rich (terminal table with\n"
+ "# colors), json (NDJSON-friendly), tsv (line-oriented plain text), or\n"
+ "# html (browser viewer). Defaults to ``tsv`` for non-TTY shells and\n"
+ "# auto-flips to ``rich`` when ``-i`` is used.",
+ "OPENBB_OUTPUT_MODE",
+ "(/settings/ output)",
+ )
+ )
+ parts.append(
+ line(
+ "flair",
+ a.get("flair"),
+ "Emoji flair shown in the REPL prompt.",
+ "OPENBB_FLAIR",
+ "(/settings/ flair)",
+ )
+ )
+ parts.append(
+ line(
+ "timezone",
+ a.get("timezone"),
+ "Time zone displayed in the REPL prompt and applied to date-typed\n"
+ "# outputs.",
+ "OPENBB_TIMEZONE",
+ "(/settings/ timezone)",
+ )
+ )
+ parts.append(
+ line(
+ "rich-style",
+ a.get("rich_style"),
+ "Rich theme name. Looks up ``.richstyle.json`` from the styles\n"
+ "# asset directory; ``dark`` and ``light`` ship out of the box.",
+ "OPENBB_RICH_STYLE",
+ "(/settings/ console_style)",
+ )
+ )
+
+ parts.append(
+ "# Custom HTTP headers sent on every dispatched request and on the OpenAPI fetch.\n"
+ "# Repeat the table to add more entries. --header flags and --header-file still\n"
+ "# override these on conflicts.\n"
+ )
+ if headers:
+ parts.append("[headers]\n")
+ for k, v in headers.items():
+ parts.append(f"{_toml_quote(k)} = {_toml_quote(v)}\n")
+ parts.append("\n")
+ else:
+ parts.append("# [headers]\n")
+ parts.append('# Authorization = "Bearer ..."\n')
+ parts.append('# "X-Tenant" = "acme"\n\n')
+
+ parts.append(
+ "# Query parameters injected on every request. Useful for APIs (e.g.\n"
+ "# https://api.congress.gov) that authenticate via ?api_key=... on every call.\n"
+ "# OPENBB_HTTP_QUERY_* env vars, --query-param flags, and --query-param-file\n"
+ "# still override these on conflicts.\n"
+ )
+ if query:
+ parts.append("[query]\n")
+ for k, v in query.items():
+ parts.append(f"{_toml_quote(k)} = {_toml_quote(v)}\n")
+ parts.append("\n")
+ else:
+ parts.append("# [query]\n")
+ parts.append('# api_key = "..."\n\n')
+
+ parts.append(_render_settings_section(a.get("settings") or {}))
+
+ return "".join(parts)
+
+
+_SETTINGS_INTERNAL_FIELDS: frozenset[str] = frozenset(
+ {
+ "VERSION",
+ "PREVIOUS_USE",
+ "DEV_BACKEND",
+ }
+)
+_SETTINGS_DEV_FIELDS: tuple[str, ...] = (
+ "DEBUG_MODE",
+ "TEST_MODE",
+)
+
+
+def _render_settings_section(active: dict[str, Any]) -> str:
+ """Render the ``[settings]`` table from the live ``Settings`` model."""
+ from openbb_cli.models.settings import Settings
+
+ out: list[str] = [
+ "# Interactive REPL / display settings โ applied via OPENBB_* env vars\n"
+ "# under the hood. Real shell exports always win; --config / project /\n"
+ "# user-global TOMLs cascade in the documented order.\n"
+ "[settings]\n"
+ ]
+ field_items = sorted(
+ Settings.model_fields.items(),
+ key=lambda kv: kv[0],
+ )
+ for name, field in field_items:
+ if name in _SETTINGS_INTERNAL_FIELDS:
+ continue
+ extra = field.json_schema_extra or {}
+ is_documented = isinstance(extra, dict) and "command" in extra
+ is_dev_flag = name in _SETTINGS_DEV_FIELDS
+ if not is_documented and not is_dev_flag:
+ continue
+ kebab = name.lower().replace("_", "-")
+ active_value = active.get(kebab) if isinstance(active, dict) else None
+ if active_value is None and isinstance(active, dict):
+ active_value = active.get(name.lower())
+ env_key = f"OPENBB_{name}"
+ rendered_default = (
+ _toml_quote(field.default) if field.default is not None else '""'
+ )
+ rendered_active = (
+ _toml_quote(active_value) if active_value is not None else None
+ )
+ commented = "# " if rendered_active is None else ""
+ value = rendered_active if rendered_active is not None else rendered_default
+ out.append(
+ f"# {field.description or name}\n"
+ f"# env: {env_key}\n"
+ f"# default: {rendered_default}\n"
+ f"{commented}{kebab} = {value}\n\n"
+ )
+ return "".join(out)
+
+
+def _toml_quote(value: Any) -> str:
+ """Render a Python value as a TOML literal."""
+ if isinstance(value, str):
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
+ if isinstance(value, bool):
+ return "true" if value else "false"
+ if isinstance(value, (int, float)):
+ return str(value)
+ return '"' + str(value) + '"'
+
+
+_TOP_LEVEL_SETTINGS_PROMOTIONS: dict[str, str] = {
+ "output_mode": "OPENBB_OUTPUT_MODE",
+ "flair": "OPENBB_FLAIR",
+ "timezone": "OPENBB_TIMEZONE",
+ "rich_style": "OPENBB_RICH_STYLE",
+}
+
+
+def apply_settings_to_env(
+ config: dict[str, Any] | None,
+) -> list[str]:
+ """Inject ``[settings]`` and promoted top-level keys into ``os.environ`` as ``OPENBB_*``.
+
+ Returns
+ -------
+ list[str]
+ Names of the env vars actually set.
+ """
+ if not config:
+ return []
+ applied: list[str] = []
+
+ def _set(env_key: str, value: Any) -> None:
+ if env_key in os.environ:
+ return
+ if isinstance(value, bool):
+ os.environ[env_key] = "True" if value else "False"
+ else:
+ os.environ[env_key] = str(value)
+ applied.append(env_key)
+
+ for key, value in (config.get("settings") or {}).items():
+ _set("OPENBB_" + key.replace("-", "_").upper(), value)
+
+ for key, env_key in _TOP_LEVEL_SETTINGS_PROMOTIONS.items():
+ if key in config:
+ os.environ.pop(env_key, None)
+ _set(env_key, config[key])
+
+ return applied
+
+
+def load_env_files(
+ explicit_path: str | os.PathLike[str] | None = None,
+) -> list[Path]:
+ """Apply ``.env`` files into ``os.environ`` for subsequent env-var lookups.
+
+ Returns
+ -------
+ list[Path]
+ Files actually loaded.
+ """
+ from dotenv import dotenv_values
+
+ candidates: list[Path] = []
+ user_env = USER_OPENBB_DIR / USER_OPENBB_ENV_NAME
+ if user_env.is_file():
+ candidates.append(user_env)
+ explicit = explicit_path or os.environ.get(EXPLICIT_ENV_FILE_ENV)
+ if explicit:
+ path = Path(explicit).expanduser()
+ if path.is_file():
+ candidates.append(path)
+
+ loaded: list[Path] = []
+ for path in candidates:
+ for k, v in dotenv_values(path).items():
+ if v is None:
+ continue
+ os.environ.setdefault(k, v)
+ loaded.append(path)
+ return loaded
diff --git a/cli/openbb_cli/config/menu_text.py b/cli/openbb_cli/config/menu_text.py
index 9ebf1aa84f4..830a6e2a434 100644
--- a/cli/openbb_cli/config/menu_text.py
+++ b/cli/openbb_cli/config/menu_text.py
@@ -3,12 +3,6 @@
__docformat__ = "numpy"
-from openbb import obb
-
-# https://rich.readthedocs.io/en/stable/appendix/colors.html#appendix-colors
-# https://rich.readthedocs.io/en/latest/highlighting.html#custom-highlighters
-
-
RICH_TAGS = [
"[menu]",
"[/menu]",
@@ -53,37 +47,18 @@ class MenuText:
List
The list of providers for the given command.
"""
- command_reference = obb.reference.get("paths", {}).get(command_path, {}) # type: ignore
+ try:
+ from openbb import obb
+ except ImportError:
+ return []
+ command_reference = obb.reference.get("paths", {}).get(command_path, {}) # ty: ignore[unresolved-attribute]
if command_reference:
providers = list(command_reference["parameters"].keys())
return [provider for provider in providers if provider != "standard"]
return []
def _format_cmd_name(self, name: str) -> str:
- """Truncate command name length if it is too long."""
- if len(name) > self.CMD_NAME_LENGTH:
- new_name = name[: self.CMD_NAME_LENGTH]
-
- if "_" in name:
- name_split = name.split("_")
-
- new_name = (
- "_".join(name_split[:2]) if len(name_split) > 2 else name_split[0]
- )
-
- if len(new_name) > self.CMD_NAME_LENGTH:
- new_name = new_name[: self.CMD_NAME_LENGTH]
-
- if new_name != name:
- self.warnings.append(
- {
- "warning": "Command name too long",
- "actual command": f"`{name}`",
- "displayed command": f"`{new_name}`",
- }
- )
- name = new_name
-
+ """Return command name as-is without truncation."""
return name
def _format_cmd_description(
diff --git a/cli/openbb_cli/config/setup.py b/cli/openbb_cli/config/setup.py
index 4e682a535e8..23a469a9655 100644
--- a/cli/openbb_cli/config/setup.py
+++ b/cli/openbb_cli/config/setup.py
@@ -6,6 +6,6 @@ from openbb_cli.config.constants import ENV_FILE_SETTINGS, SETTINGS_DIRECTORY
def bootstrap():
- """Setup pre-launch configurations for the CLI."""
+ """Set up pre-launch configurations for the CLI."""
SETTINGS_DIRECTORY.mkdir(parents=True, exist_ok=True)
Path(ENV_FILE_SETTINGS).touch(exist_ok=True)
diff --git a/cli/openbb_cli/config/style.py b/cli/openbb_cli/config/style.py
index 0dcb9d9493f..7b34959d3ea 100644
--- a/cli/openbb_cli/config/style.py
+++ b/cli/openbb_cli/config/style.py
@@ -1,6 +1,5 @@
"""Chart and style helpers for Plotly."""
-# pylint: disable=C0302,R0902,W3301
import json
from pathlib import Path
from typing import Any
@@ -13,12 +12,7 @@ console = Console()
class Style:
- """The class that helps with handling of style configurations.
-
- It serves styles for 2 libraries. For `Plotly` this class serves absolute paths
- to the .pltstyle files. For `Plotly` and `Rich` this class serves custom
- styles as python dictionaries.
- """
+ """Handle style configurations for Plotly and Rich."""
STYLES_REPO = STYLES_DIRECTORY
@@ -65,15 +59,10 @@ class Style:
def _from_directory(self, folder: Path | None) -> None:
"""Load custom styles from folder.
- Parses the styles/default and styles/user folders and loads style files.
- To be recognized files need to follow a naming convention:
- *.pltstyle - plotly stylesheets
- *.richstyle.json - rich stylesheets
-
Parameters
----------
- folder : str
- Path to the folder containing the stylesheets
+ folder : Path
+ Path to the folder containing the stylesheets.
"""
if not folder or not folder.exists():
return
@@ -95,9 +84,7 @@ class Style:
with open(file) as f:
json_style: dict = json.load(f)
for key, value in json_style.items():
- json_style[key] = value.replace(
- " ", ""
- ) # remove whitespaces so Rich can parse it
+ json_style[key] = value.replace(" ", "")
return json_style
@property
diff --git a/cli/openbb_cli/controllers/base_controller.py b/cli/openbb_cli/controllers/base_controller.py
index a6b858d1686..1237a4f5e6b 100644
--- a/cli/openbb_cli/controllers/base_controller.py
+++ b/cli/openbb_cli/controllers/base_controller.py
@@ -11,6 +11,9 @@ from pathlib import Path
from typing import Any, Literal
import pandas as pd
+from prompt_toolkit.formatted_text import HTML
+from prompt_toolkit.styles import Style
+
from openbb_cli.config.completer import NestedCompleter
from openbb_cli.config.constants import SCRIPT_TAGS
from openbb_cli.controllers.choices import build_controller_choice_map
@@ -20,28 +23,20 @@ from openbb_cli.controllers.utils import (
get_flair_and_username,
handle_obbject_display,
parse_unknown_args_to_dict,
- print_rich_table,
system_clear,
validate_register_key,
)
from openbb_cli.session import Session
-from prompt_toolkit.formatted_text import HTML
-from prompt_toolkit.styles import Style
-
-# pylint: disable=C0301,C0302,R0902,global-statement,too-many-boolean-expressions
-# pylint: disable=R0912
controllers: dict[str, Any] = {}
session = Session()
-# TODO: We should try to avoid these global variables
RECORD_SESSION = False
SESSION_RECORDED = list()
SESSION_RECORDED_NAME = ""
SESSION_RECORDED_DESCRIPTION = ""
SESSION_RECORDED_TAGS = ""
-SESSION_RECORDED_PUBLIC = False
class BaseController(metaclass=ABCMeta):
@@ -62,6 +57,7 @@ class BaseController(metaclass=ABCMeta):
"reset",
"stop",
"results",
+ "load",
]
CHOICES_COMMANDS: list[str] = []
@@ -87,11 +83,10 @@ class BaseController(metaclass=ABCMeta):
def __init__(self, queue: list[str] | None = None) -> None:
"""Create the base class for any controller in the codebase.
- Used to simplify the creation of menus.
-
- queue: List[str]
- The current queue of jobs to process separated by "/"
- E.g. /stocks/load gme/dps/sidtc/../exit
+ Parameters
+ ----------
+ queue : list[str] | None
+ The current queue of jobs to process separated by "/".
"""
self.check_path()
self.path = [x for x in self.PATH.split("/") if x != ""]
@@ -113,12 +108,61 @@ class BaseController(metaclass=ABCMeta):
add_help=False,
prog=self.path[-1] if self.PATH != "/" else "cli",
)
- self.parser.exit_on_error = False # type: ignore
+ self.parser.exit_on_error = False
self.parser.add_argument("cmd", choices=self.controller_choices)
def update_completer(self, choices) -> None:
"""Update the completer with new choices."""
if session.prompt_session and session.settings.USE_PROMPT_TOOLKIT:
+ from openbb_cli.controllers.utils import get_data_files_for_completion
+
+ if "load" not in choices:
+ choices["load"] = {}
+
+ data_files = get_data_files_for_completion()
+ if data_files:
+ file_completions = {file: None for file in data_files}
+ choices["load"]["--file"] = file_completions
+ choices["load"]["-f"] = file_completions
+ choices["load"]["--sheet-name"] = None
+ choices["load"]["--register_key"] = None
+ choices["load"]["--help"] = None
+ choices["load"]["-h"] = "--help"
+
+ if "results" not in choices:
+ choices["results"] = {}
+
+ registry_all = session.obbject_registry.all
+ index_completions = {str(idx): None for idx in registry_all}
+ key_completions = {
+ (data.get("extra") or {}).get("register_key"): None
+ for data in registry_all.values()
+ if (data.get("extra") or {}).get("register_key")
+ }
+
+ choices["results"]["--index"] = (
+ index_completions if index_completions else None
+ )
+ choices["results"]["-i"] = "--index"
+ choices["results"]["--key"] = key_completions if key_completions else None
+ choices["results"]["-k"] = "--key"
+ choices["results"]["--chart"] = None
+ choices["results"]["-c"] = "--chart"
+ choices["results"]["--export"] = {
+ "csv": None,
+ "json": None,
+ "xlsx": None,
+ "png": None,
+ "jpg": None,
+ "db": None,
+ "sqlite": None,
+ "sqlite3": None,
+ }
+ choices["results"]["-e"] = "--export"
+ choices["results"]["--sheet-name"] = None
+ choices["results"]["--help"] = None
+ choices["results"]["-h"] = "--help"
+
self.completer = NestedCompleter.from_nested_dict(choices)
def check_path(self) -> None:
@@ -128,9 +172,10 @@ class BaseController(metaclass=ABCMeta):
raise ValueError("Path must begin with a '/' character.")
if path[-1] != "/":
raise ValueError("Path must end with a '/' character.")
- if not re.match("^[a-z/]*$", path):
+ if not re.match(r"^[a-z0-9_\-/]*$", path):
raise ValueError(
- "Path must only contain lowercase letters and '/' characters."
+ "Path must only contain lowercase letters, digits, '_', '-', "
+ "and '/' characters."
)
def load_class(self, class_ins, *args, **kwargs):
@@ -141,6 +186,7 @@ class BaseController(metaclass=ABCMeta):
if class_ins.PATH in controllers and arguments == 1:
old_class = controllers[class_ins.PATH]
old_class.queue = self.queue
+ old_class.update_completer(old_class.choices_default)
return old_class.menu()
return class_ins(*args, **kwargs).menu()
@@ -149,10 +195,7 @@ class BaseController(metaclass=ABCMeta):
controllers[self.PATH] = self
def custom_reset(self) -> list[str]:
- """Implement custom reset.
-
- This will be replaced by any children with custom_reset functions.
- """
+ """Implement custom reset."""
return []
@abstractmethod
@@ -162,43 +205,68 @@ class BaseController(metaclass=ABCMeta):
def parse_input(self, an_input: str) -> list:
"""Parse controller input."""
- # The original regex has been improved to handle quoted strings.
- # It now splits by '/' only when it's not enclosed in single or double quotes.
- # This allows commands like: exe --file "folder with spaces/file.openbb"
- # or exe --file 'folder with spaces/file.openbb'
+ file_flag = r"(\ -f |\ --file )"
+ up_to = r".*?"
+ known_extensions = (
+ r"(\.(xlsx|csv|xls|tsv|json|yaml|ini|openbb|ipynb|db|sqlite|sqlite3))"
+ )
+ optional_args = r"(?:\ [^/]+)*?"
+ file_path_pattern = f"({file_flag}{up_to}{known_extensions}{optional_args})"
+
+ placeholders: dict[str, str] = {}
+ placeholder_count = 0
+
+ while True:
+ match = re.search(pattern=file_path_pattern, string=an_input)
+ if match is None:
+ break
+
+ placeholder = f"{{placeholder{placeholder_count}}}"
+ placeholders[placeholder] = an_input[match.span()[0] : match.span()[1]]
+ an_input = (
+ an_input[: match.span()[0]] + placeholder + an_input[match.span()[1] :]
+ )
+ placeholder_count += 1
+
commands = re.split(r"/(?=(?:[^\"']*[\"'][^\"']*[\"'])*[^\"']*$)", an_input)
- # Remove empty strings from the list of commands
- return [cmd.strip() for cmd in commands if cmd.strip()]
+
+ result = []
+ for cmd in commands:
+ cleaned = cmd.strip()
+ if cleaned:
+ for placeholder, original in placeholders.items():
+ cleaned = cleaned.replace(placeholder, original)
+ result.append(cleaned)
+
+ if an_input.startswith("/") and (not result or result[0] != "home"):
+ result.insert(0, "home")
+
+ return result
def switch(self, an_input: str) -> list[str]:
"""Process and dispatch input.
Returns
- ----------
- List[str]
- list of commands in the queue to execute
+ -------
+ list[str]
+ List of commands in the queue to execute.
"""
actions = self.parse_input(an_input)
if an_input and an_input != "reset":
session.console.print()
- # Empty command
if len(actions) == 0:
pass
- # Navigation slash is being used first split commands
elif len(actions) > 1:
- # Absolute path is specified
- if not actions[0]:
+ if not actions[0]: # pragma: no cover
actions[0] = "home"
- # Add all instructions to the queue
for cmd in actions[::-1]:
if cmd:
self.queue.insert(0, cmd)
- # Single command fed, process
else:
try:
known_args, other_args = self.parser.parse_known_args(
@@ -210,7 +278,6 @@ class BaseController(metaclass=ABCMeta):
if RECORD_SESSION:
SESSION_RECORDED.append(an_input)
- # Redirect commands to their correct functions
if known_args.cmd:
if known_args.cmd in ("..", "q"):
known_args.cmd = "quit"
@@ -260,18 +327,13 @@ class BaseController(metaclass=ABCMeta):
self.queue.insert(0, "quit")
def call_exit(self, _) -> None:
- # Not sure how to handle controller loading here
"""Process exit cli command."""
self.save_class()
for _ in range(self.PATH.count("/")):
self.queue.insert(0, "quit")
def call_reset(self, _) -> None:
- """Process reset command.
-
- If you would like to have customization in the reset process define a method
- `custom_reset` in the child class.
- """
+ """Process reset command."""
self.save_class()
if self.PATH != "/":
if self.custom_reset():
@@ -334,15 +396,6 @@ class BaseController(metaclass=ABCMeta):
default="",
nargs="+",
)
- parser.add_argument(
- "-p",
- "--public",
- dest="public",
- action="store_true",
- help="Whether the routine should be public or not",
- default=False,
- )
-
if other_args and "-" not in other_args[0][0]:
other_args.insert(0, "-n")
@@ -388,7 +441,6 @@ class BaseController(metaclass=ABCMeta):
)
return
- # Check if title has a valid format
title = " ".join(ns_parser.name) if ns_parser.name else ""
pattern = re.compile(r"^[a-zA-Z0-9\s]+$")
if not pattern.match(title):
@@ -401,7 +453,6 @@ class BaseController(metaclass=ABCMeta):
global SESSION_RECORDED_NAME # noqa: PLW0603
global SESSION_RECORDED_DESCRIPTION # noqa: PLW0603
global SESSION_RECORDED_TAGS # noqa: PLW0603
- global SESSION_RECORDED_PUBLIC # noqa: PLW0603
RECORD_SESSION = True
SESSION_RECORDED_NAME = title
@@ -414,8 +465,6 @@ class BaseController(metaclass=ABCMeta):
SESSION_RECORDED_TAGS += "," + tag2 if tag2 else ""
SESSION_RECORDED_TAGS += "," + tag3 if tag3 else ""
- SESSION_RECORDED_PUBLIC = ns_parser.public
-
session.console.print(
f"[green]The routine '{title}' is successfully being recorded.[/green]"
)
@@ -431,7 +480,6 @@ class BaseController(metaclass=ABCMeta):
prog="stop",
description="Stop recording session into .openbb routine file",
)
- # This is only for auto-completion purposes
_, _ = self.parse_simple_args(parser, other_args)
if "-h" not in other_args and "--help" not in other_args:
@@ -457,7 +505,6 @@ class BaseController(metaclass=ABCMeta):
title_for_local_storage,
)
- # If file already exists, add a timestamp to the name
if os.path.isfile(routine_file):
i = session.console.input(
"A local routine with the same name already exists, do you want to override it? (y/n): "
@@ -481,7 +528,6 @@ class BaseController(metaclass=ABCMeta):
f"[yellow]The routine name has been updated to '{new_name}'[/yellow]\n"
)
- # Writing to file
Path(os.path.dirname(routine_file)).mkdir(parents=True, exist_ok=True)
with open(routine_file, "w") as file1:
@@ -495,14 +541,12 @@ class BaseController(metaclass=ABCMeta):
"\n\n",
]
lines += [c + "\n" for c in SESSION_RECORDED[:-1]]
- # Writing data to a file
file1.writelines(lines)
session.console.print(
f"[green]Your routine has been recorded and saved here: {routine_file}[/green]\n"
)
- # Clear session to be recorded again
RECORD_SESSION = False
SESSION_RECORDED = list()
@@ -516,17 +560,19 @@ class BaseController(metaclass=ABCMeta):
"'OBBjects' where all execution results are stored. "
"It is organized as a stack, with the most recent result at index 0.",
)
- parser.add_argument("--index", dest="index", help="Index of the result.")
- parser.add_argument("--key", dest="key", help="Key of the result.")
+ parser.add_argument("-i", "--index", dest="index", help="Index of the result.")
+ parser.add_argument("-k", "--key", dest="key", help="Key of the result.")
parser.add_argument(
"--chart", action="store_true", dest="chart", help="Display chart."
)
parser.add_argument(
"--export",
default="",
- type=check_file_type_saved(["csv", "json", "xlsx", "png", "jpg"]),
+ type=check_file_type_saved(
+ ["csv", "json", "xlsx", "png", "jpg", "db", "sqlite", "sqlite3"]
+ ),
dest="export",
- help="Export raw data into csv, json, xlsx and figure into png or jpg.",
+ help="Export raw data into csv, json, xlsx, db/sqlite and figure into png or jpg.",
nargs="+",
)
parser.add_argument(
@@ -547,34 +593,35 @@ class BaseController(metaclass=ABCMeta):
results = session.obbject_registry.all
if results:
df = pd.DataFrame.from_dict(results, orient="index")
- print_rich_table(
- df,
- show_index=True,
- index_name="stack index",
+ session.output_adapter.display(
+ data=df,
title="OBBject Results",
+ export=False,
+ chart=False,
)
else:
session.console.print("[info]No results found.[/info]")
elif ns_parser.index:
try:
index = int(ns_parser.index)
- obbject = session.obbject_registry.get(index)
- if obbject:
- handle_obbject_display(
- obbject=obbject,
- chart=ns_parser.chart,
- export=ns_parser.export,
- sheet_name=ns_parser.sheet_name,
- **kwargs,
- )
- else:
- session.console.print(
- f"[info]No result found at index {index}.[/info]"
- )
except ValueError:
session.console.print(
f"[red]Index must be an integer, not '{ns_parser.index}'.[/red]"
)
+ return
+ obbject = session.obbject_registry.get(index)
+ if obbject:
+ handle_obbject_display(
+ obbject=obbject,
+ chart=ns_parser.chart,
+ export=ns_parser.export,
+ sheet_name=ns_parser.sheet_name,
+ **kwargs,
+ )
+ else:
+ session.console.print(
+ f"[info]No result found at index {index}.[/info]"
+ )
elif ns_parser.key:
obbject = session.obbject_registry.get(ns_parser.key)
if obbject:
@@ -590,6 +637,295 @@ class BaseController(metaclass=ABCMeta):
f"[info]No result found with key '{ns_parser.key}'.[/info]"
)
+ def call_load(self, other_args: list[str]): # noqa: PLR0912
+ """Load data from CSV, JSON, or Excel file."""
+ parser = argparse.ArgumentParser(
+ add_help=False,
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ prog="load",
+ description="Load data from a CSV, JSON, or Excel file in OpenBBUserData folder. "
+ "The loaded data will be added to the results cache.",
+ )
+ parser.add_argument(
+ "-f",
+ "--file",
+ dest="file",
+ type=str,
+ required=True,
+ help="Path to file relative to OpenBBUserData folder.",
+ )
+ parser.add_argument(
+ "--sheet-name",
+ dest="sheet_name",
+ type=str,
+ default=None,
+ help="Name of excel sheet to load. Only for .xlsx files. If not provided, loads first sheet.",
+ )
+ parser.add_argument(
+ "--register_key",
+ dest="register_key",
+ default="",
+ help="Key to reference data in the OBBject registry.",
+ type=validate_register_key,
+ )
+
+ ns_parser = self.parse_simple_args(parser, other_args)[0]
+
+ if ns_parser and ns_parser.file:
+ from openbb_core.app.model.obbject import OBBject
+
+ file_path_str = ns_parser.file
+
+ user_data_dir = Path(session.user.preferences.data_directory)
+ file_path = user_data_dir / file_path_str
+
+ if not file_path.exists():
+ session.console.print(f"[red]File not found: {file_path}[/red]")
+ return
+
+ try:
+ file_ext = file_path.suffix.lower()
+
+ if file_ext == ".csv":
+ df = pd.read_csv(file_path, index_col=None)
+ df = df.loc[:, ~df.columns.str.startswith("Unnamed:")]
+
+ obbject = OBBject(results=df)
+
+ command = f"/load -f {file_path_str}"
+ obbject.extra["command"] = command
+
+ if ns_parser.register_key:
+ if (
+ ns_parser.register_key
+ not in session.obbject_registry.obbject_keys
+ ):
+ obbject.extra["register_key"] = ns_parser.register_key
+ else:
+ session.console.print(
+ f"[yellow]Key `{ns_parser.register_key}` already exists in the registry. "
+ "The `OBBject` was kept without the key.[/yellow]"
+ )
+
+ if session.max_obbjects_exceeded():
+ session.obbject_registry.remove()
+ session.console.print(
+ "[yellow]Maximum number of OBBjects reached. The oldest entry was removed.[/yellow]"
+ )
+
+ if session.obbject_registry.register(obbject):
+ session.console.print(
+ f"[green]Successfully loaded {len(df)} rows from {file_path.name}[/green]"
+ )
+
+ if hasattr(self, "_link_obbject_to_data_processing_commands"):
+ self._link_obbject_to_data_processing_commands() # ty: ignore[call-non-callable]
+ self.update_completer(self.choices_default)
+
+ session.output_adapter.display(
+ data=obbject,
+ title=f"Loaded: {file_path_str}",
+ export=False,
+ chart=False,
+ )
+ else:
+ session.console.print(
+ "[yellow]Failed to register OBBject in registry.[/yellow]"
+ )
+
+ elif file_ext == ".json":
+ df = pd.read_json(file_path)
+
+ obbject = OBBject(results=df)
+ command = f"/load -f {file_path_str}"
+ obbject.extra["command"] = command
+
+ if ns_parser.register_key:
+ if (
+ ns_parser.register_key
+ not in session.obbject_registry.obbject_keys
+ ):
+ obbject.extra["register_key"] = ns_parser.register_key
+ else:
+ session.console.print(
+ f"[yellow]Key `{ns_parser.register_key}` already exists in the registry. "
+ "The `OBBject` was kept without the key.[/yellow]"
+ )
+
+ if session.max_obbjects_exceeded():
+ session.obbject_registry.remove()
+ session.console.print(
+ "[yellow]Maximum number of OBBjects reached. The oldest entry was removed.[/yellow]"
+ )
+
+ if session.obbject_registry.register(obbject):
+ session.console.print(
+ f"[green]Successfully loaded {len(df)} rows from {file_path.name}[/green]"
+ )
+
+ if hasattr(self, "_link_obbject_to_data_processing_commands"):
+ self._link_obbject_to_data_processing_commands() # ty: ignore[call-non-callable]
+ self.update_completer(self.choices_default)
+
+ session.output_adapter.display(
+ data=obbject,
+ title=f"Loaded: {file_path_str}",
+ export=False,
+ chart=False,
+ )
+ else:
+ session.console.print(
+ "[yellow]Failed to register OBBject in registry.[/yellow]"
+ )
+
+ elif file_ext in [".xlsx", ".xls"]:
+ sheet_name = ns_parser.sheet_name if ns_parser.sheet_name else 0
+ df = pd.read_excel(file_path, sheet_name=sheet_name, index_col=None)
+ df = df.loc[:, ~df.columns.str.startswith("Unnamed:")]
+
+ obbject = OBBject(results=df)
+ command = f"/load -f {file_path_str}"
+ if ns_parser.sheet_name:
+ command += f" --sheet-name {ns_parser.sheet_name}"
+ obbject.extra["command"] = command
+
+ if ns_parser.register_key:
+ if (
+ ns_parser.register_key
+ not in session.obbject_registry.obbject_keys
+ ):
+ obbject.extra["register_key"] = ns_parser.register_key
+ else:
+ session.console.print(
+ f"[yellow]Key `{ns_parser.register_key}` already exists in the registry. "
+ "The `OBBject` was kept without the key.[/yellow]"
+ )
+
+ if session.max_obbjects_exceeded():
+ session.obbject_registry.remove()
+ session.console.print(
+ "[yellow]Maximum number of OBBjects reached. The oldest entry was removed.[/yellow]"
+ )
+
+ if session.obbject_registry.register(obbject):
+ session.console.print(
+ f"[green]Successfully loaded {len(df)} rows from {file_path.name}[/green]"
+ )
+
+ if hasattr(self, "_link_obbject_to_data_processing_commands"):
+ self._link_obbject_to_data_processing_commands() # ty: ignore[call-non-callable]
+ self.update_completer(self.choices_default)
+
+ session.output_adapter.display(
+ data=obbject,
+ title=f"Loaded: {file_path_str}",
+ export=False,
+ chart=False,
+ )
+ else:
+ session.console.print(
+ "[yellow]Failed to register OBBject in registry.[/yellow]"
+ )
+
+ elif file_ext in [".db", ".sqlite", ".sqlite3"]:
+ import sqlite3
+
+ from openbb_cli.controllers.utils import SQLiteTable
+
+ conn = sqlite3.connect(file_path)
+ try:
+ cursor = conn.cursor()
+ cursor.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
+ )
+ tables = [row[0] for row in cursor.fetchall()]
+
+ if not tables:
+ session.console.print(
+ f"[yellow]No tables found in database: {file_path.name}[/yellow]"
+ )
+ return
+
+ loaded_count = 0
+ for table_name in tables:
+ quoted = '"' + table_name.replace('"', '""') + '"'
+ cursor.execute(f"SELECT COUNT(*) FROM {quoted}") # noqa: S608
+ row_count = cursor.fetchone()[0]
+
+ sqlite_table = SQLiteTable(
+ db_path=str(file_path),
+ table_name=table_name,
+ row_count=row_count,
+ )
+
+ obbject = OBBject(results=sqlite_table)
+ command = f"/load -f {file_path_str} --table {table_name}"
+ obbject.extra["command"] = command
+
+ base_name = file_path.stem
+ auto_key = f"{base_name}_{table_name}"
+
+ if ns_parser.register_key and len(tables) == 1:
+ if (
+ ns_parser.register_key
+ not in session.obbject_registry.obbject_keys
+ ):
+ obbject.extra["register_key"] = (
+ ns_parser.register_key
+ )
+ else:
+ session.console.print(
+ f"[yellow]Key `{ns_parser.register_key}` already exists. "
+ f"Using auto-generated key: {auto_key}[/yellow]"
+ )
+ obbject.extra["register_key"] = auto_key
+ elif auto_key not in session.obbject_registry.obbject_keys:
+ obbject.extra["register_key"] = auto_key
+ else:
+ session.console.print(
+ f"[yellow]Key `{auto_key}` already exists in the registry. "
+ "The `OBBject` was kept without the key.[/yellow]"
+ )
+
+ if session.max_obbjects_exceeded():
+ session.obbject_registry.remove()
+ session.console.print(
+ "[yellow]Maximum number of OBBjects reached. The oldest entry was removed.[/yellow]"
+ )
+
+ if session.obbject_registry.register(obbject):
+ loaded_count += 1
+ cursor.execute(f"PRAGMA table_info({table_name})")
+ columns = [col[1] for col in cursor.fetchall()]
+
+ session.console.print(
+ f"[green]Loaded table '{table_name}': {row_count} rows, "
+ f"{len(columns)} columns (lazy)[/green]"
+ )
+
+ if loaded_count > 0:
+ session.console.print(
+ f"[green]Successfully loaded {loaded_count} table(s) from {file_path.name}[/green]"
+ )
+
+ if hasattr(
+ self, "_link_obbject_to_data_processing_commands"
+ ):
+ self._link_obbject_to_data_processing_commands() # ty: ignore[call-non-callable]
+ self.update_completer(self.choices_default)
+ finally:
+ conn.close()
+
+ else:
+ session.console.print(
+ f"[red]Unsupported file type: {file_ext}. "
+ "Supported: .csv, .json, .xlsx, .xls, .db, .sqlite, .sqlite3[/red]"
+ )
+ return
+
+ except Exception as e:
+ session.console.print(f"[red]Error loading file: {e}[/red]")
+
@staticmethod
def parse_simple_args(
parser: argparse.ArgumentParser,
@@ -600,19 +936,19 @@ class BaseController(metaclass=ABCMeta):
Parameters
----------
- parser: argparse.ArgumentParser
- Parser with predefined arguments
- other_args: List[str]
- List of arguments to parse
- unknown_args: bool
- Flag to indicate if unknown arguments should be returned
+ parser : argparse.ArgumentParser
+ Parser with predefined arguments.
+ other_args : list[str]
+ List of arguments to parse.
+ unknown_args : bool
+ Flag to indicate if unknown arguments should be returned.
Returns
-------
- ns_parser: argparse.Namespace
- Namespace with parsed arguments
- l_unknown_args: List[str]
- List of unknown arguments
+ ns_parser : argparse.Namespace
+ Namespace with parsed arguments.
+ l_unknown_args : list[str]
+ List of unknown arguments.
"""
parser.add_argument(
"-h", "--help", action="store_true", help="show this help message"
@@ -624,7 +960,6 @@ class BaseController(metaclass=ABCMeta):
try:
ns_parser, l_unknown_args = parser.parse_known_args(other_args)
except SystemExit:
- # In case the command has required argument that isn't specified
session.console.print("\n")
return None, None
@@ -640,7 +975,7 @@ class BaseController(metaclass=ABCMeta):
return ns_parser, l_unknown_args
@classmethod
- def parse_known_args_and_warn( # pylint: disable=R0917
+ def parse_known_args_and_warn(
cls,
parser: argparse.ArgumentParser,
other_args: list[str],
@@ -654,21 +989,21 @@ class BaseController(metaclass=ABCMeta):
Parameters
----------
- parser: argparse.ArgumentParser
- Parser with predefined arguments
- other_args: List[str]
- list of arguments to parse
- export_allowed: Literal["no_export", "raw_data_only", "figures_only", "raw_data_and_figures"]
- Export options
- raw: bool
- Add the --raw flag
- limit: int
- Add a --limit flag with this number default
+ parser : argparse.ArgumentParser
+ Parser with predefined arguments.
+ other_args : list[str]
+ List of arguments to parse.
+ export_allowed : Literal["no_export", "raw_data_only", "figures_only", "raw_data_and_figures"]
+ Export options.
+ raw : bool
+ Add the --raw flag.
+ limit : int
+ Add a --limit flag with this number default.
Returns
- ----------
- ns_parser:
- Namespace with parsed arguments
+ -------
+ ns_parser
+ Namespace with parsed arguments.
"""
parser.add_argument(
"-h", "--help", action="store_true", help="show this help message"
@@ -685,10 +1020,17 @@ class BaseController(metaclass=ABCMeta):
choices_export = ["png", "jpg"]
help_export = "Export figure into png or jpg."
else:
- choices_export = ["csv", "json", "xlsx", "png", "jpg"]
- help_export = (
- "Export raw data into csv, json, xlsx and figure into png or jpg."
- )
+ choices_export = [
+ "csv",
+ "json",
+ "xlsx",
+ "png",
+ "jpg",
+ "db",
+ "sqlite",
+ "sqlite3",
+ ]
+ help_export = "Export raw data into csv, json, xlsx, db/sqlite and figure into png or jpg."
parser.add_argument(
"--export",
@@ -699,7 +1041,6 @@ class BaseController(metaclass=ABCMeta):
nargs="+",
)
- # If excel is an option, add the sheet name
if export_allowed in [
"raw_data_only",
"raw_data_and_figures",
@@ -754,24 +1095,15 @@ class BaseController(metaclass=ABCMeta):
return None
try:
- # Determine the index of the routine arguments
routine_args_index = next(
(
i + 1
for i, arg in enumerate(other_args)
if arg in ("-i", "--input")
- and "routine_args"
- in [
- action.dest
- for action in parser._actions # pylint: disable=protected-access
- ]
+ and "routine_args" in [action.dest for action in parser._actions]
),
-1,
)
- # Collect indices whose values should NOT be comma-split because
- # the provider may accept a comma-separated string (e.g. --symbol
- # AAPL,MSFT). Handles --flag value, --flag=value, -f value, and
- # multi-value flags (nargs="+" / nargs="*" / nargs=N).
no_split_indices: set[int] = set()
if 0 <= routine_args_index < len(other_args):
no_split_indices.add(routine_args_index)
@@ -779,38 +1111,31 @@ class BaseController(metaclass=ABCMeta):
for i, arg in enumerate(other_args):
if not arg.startswith("-"):
continue
- # Handle --flag=value by extracting the flag portion.
flag_part = arg.split("=", 1)[0] if "=" in arg else arg
- for action in parser._actions: # pylint: disable=protected-access
+ for action in parser._actions:
if flag_part in action.option_strings and action.nargs != 0:
if "=" in arg:
- # Value is embedded in the same token.
no_split_indices.add(i)
elif action.nargs in ("+", "*") or (
isinstance(action.nargs, int) and action.nargs > 1
):
- # Multi-value flag: protect all consecutive
- # non-flag tokens after the flag.
j = i + 1
while j < len(other_args) and not other_args[j].startswith(
"-"
):
no_split_indices.add(j)
j += 1
- # Single-value flag: protect the next token.
elif i + 1 < len(other_args):
no_split_indices.add(i + 1)
break
- # Split comma-separated arguments only for positional / unflagged values.
other_args = [
part
for index, arg in enumerate(other_args)
for part in ([arg] if index in no_split_indices else arg.split(","))
]
- # Check if the action has optional choices, if yes, remove them
- for action in parser._actions: # pylint: disable=protected-access
+ for action in parser._actions:
if getattr(action, "optional_choices", None):
action.choices = None
@@ -825,8 +1150,6 @@ class BaseController(metaclass=ABCMeta):
)
except SystemExit:
- # In case the command has required argument that isn't specified
-
return None
if l_unknown_args:
@@ -841,12 +1164,9 @@ class BaseController(metaclass=ABCMeta):
an_input = "HELP_ME"
while True:
- # There is a command in the queue
if self.queue and len(self.queue) > 0:
if self.queue[0] in ("q", "..", "quit"):
self.save_class()
- # Go back to the root in order to go to the right directory because
- # there was a jump between indirect menus
if custom_path_menu_above:
self.queue.insert(1, custom_path_menu_above)
@@ -857,11 +1177,9 @@ class BaseController(metaclass=ABCMeta):
return ["help"]
return []
- # Consume 1 element from the queue
an_input = self.queue[0]
self.queue = self.queue[1:]
- # Print location because this was an instruction and we want user to know the action
if (
an_input
and an_input not in ("home", "help")
@@ -871,16 +1189,13 @@ class BaseController(metaclass=ABCMeta):
f"{get_flair_and_username()} {self.PATH} $ {an_input}"
)
- # Get input command from user
else:
- # Display help menu when entering on this menu from a level above
if an_input == "HELP_ME":
self.print_help()
try:
prompt_session = session.prompt_session
if prompt_session and settings.USE_PROMPT_TOOLKIT:
- # Check if toolbar hint was enabled
if settings.TOOLBAR_HINT:
an_input = prompt_session.prompt(
f"{get_flair_and_username()} {self.PATH} $ ",
@@ -904,19 +1219,15 @@ class BaseController(metaclass=ABCMeta):
completer=self.completer,
search_ignore_case=True,
)
- # Get input from user without auto-completion
else:
an_input = input(f"{get_flair_and_username()} {self.PATH} $ ")
except (KeyboardInterrupt, EOFError):
- # Exit in case of keyboard interrupt
an_input = "exit"
try:
- # Allow user to go back to root
an_input = "home" if an_input == "/" else an_input
- # Process the input command
self.queue = self.switch(an_input)
except SystemExit:
@@ -934,7 +1245,7 @@ class BaseController(metaclass=ABCMeta):
candidate_input = (
f"{similar_cmd[0]} {' '.join(an_input.split(' ')[1:])}"
)
- if candidate_input == an_input:
+ if candidate_input == an_input: # pragma: no cover
an_input = ""
self.queue = []
session.console.print("\n")
diff --git a/cli/openbb_cli/controllers/base_platform_controller.py b/cli/openbb_cli/controllers/base_platform_controller.py
index 0c615b12717..ba0e0fa396c 100644
--- a/cli/openbb_cli/controllers/base_platform_controller.py
+++ b/cli/openbb_cli/controllers/base_platform_controller.py
@@ -1,24 +1,28 @@
"""Platform Equity Controller."""
import os
-from functools import partial, update_wrapper
+from functools import lru_cache, partial, update_wrapper
from types import MethodType
+from typing import Any
import pandas as pd
-from openbb import obb
-from openbb_charting.core.openbb_figure import OpenBBFigure
-from openbb_cli.argparse_translator.argparse_class_processor import (
- ArgparseClassProcessor,
-)
+
from openbb_cli.config.menu_text import MenuText
from openbb_cli.controllers.base_controller import BaseController
from openbb_cli.controllers.utils import export_data, print_rich_table
from openbb_cli.session import Session
-from openbb_core.app.model.obbject import OBBject
session = Session()
+@lru_cache(maxsize=1)
+def _OBBject() -> type:
+ """Return ``OBBject`` lazily."""
+ from openbb_core.app.model.obbject import OBBject
+
+ return OBBject
+
+
class DummyTranslation:
"""Dummy Translation for testing."""
@@ -33,36 +37,62 @@ class PlatformController(BaseController):
CHOICES_GENERATION = True
- def __init__( # pylint: disable=too-many-positional-arguments
+ _factory_backend: Any = None
+ _factory_translators: dict[str, Any] | None = None
+ _factory_paths: dict[str, str] | None = None
+
+ def __init__(
self,
name: str,
parent_path: list[str],
platform_target: type | None = None,
queue: list[str] | None = None,
translators: dict | None = None,
+ paths: dict[str, str] | None = None,
):
"""Construct a Platform based Controller."""
self.PATH = f"/{'/'.join(parent_path)}/{name}/" if parent_path else f"/{name}/"
super().__init__(queue)
self._name = name
- if not (platform_target or translators):
- raise ValueError("Either platform_target or translators must be provided.")
+ if (
+ translators is None
+ and platform_target is None
+ and self._factory_translators is not None
+ ):
+ translators = self._factory_translators
+ if paths is None and self._factory_paths is not None:
+ paths = self._factory_paths
+ self._translated_target = DummyTranslation()
+ elif platform_target is not None:
+ from openbb import obb
- self._translated_target = (
- ArgparseClassProcessor(
- target_class=platform_target,
- reference=obb.reference["paths"], # type: ignore
+ from openbb_cli.argparse_translator.argparse_class_processor import (
+ ArgparseClassProcessor,
)
- if platform_target
- else DummyTranslation()
- )
+
+ self._translated_target = ArgparseClassProcessor(
+ target_class=platform_target,
+ reference=obb.reference["paths"], # ty: ignore[not-subscriptable]
+ )
+ elif translators is not None:
+ self._translated_target = DummyTranslation()
+ else:
+ raise ValueError(
+ "PlatformController needs one of: factory-injected translators, "
+ "platform_target, or translators=...; got none."
+ )
+
self.translators = (
translators
if translators is not None
else getattr(self._translated_target, "translators", {})
)
- self.paths = getattr(self._translated_target, "paths", {})
+ self.paths = (
+ paths
+ if paths is not None
+ else getattr(self._translated_target, "paths", {})
+ )
if self.translators:
self._link_obbject_to_data_processing_commands()
@@ -73,9 +103,8 @@ class PlatformController(BaseController):
def _link_obbject_to_data_processing_commands(self):
"""Link data processing commands to OBBject registry."""
for _, trl in self.translators.items():
- for action in trl._parser._actions: # pylint: disable=protected-access
+ for action in trl._parser._actions:
if action.dest == "data":
- # Generate choices by combining indexed and key-based choices
action.choices = [
"OBB" + str(i)
for i in range(len(session.obbject_registry.obbjects))
@@ -98,7 +127,7 @@ class PlatformController(BaseController):
ns_parser.data in session.obbject_registry.obbject_keys
):
obbject = session.obbject_registry.get(ns_parser.data)
- if obbject and isinstance(obbject, OBBject):
+ if obbject and isinstance(obbject, _OBBject()):
setattr(ns_parser, "data", obbject.results)
return ns_parser
@@ -111,25 +140,25 @@ class PlatformController(BaseController):
sub_menu_translators = {}
choices_commands = []
+ sub_prefix = f"{self._name}_{path}_"
for translator_name, translator in self.translators.items():
- if f"{self._name}_{path}" in translator_name:
- new_name = translator_name.replace(f"{self._name}_{path}_", "")
+ if translator_name.startswith(sub_prefix):
+ new_name = translator_name[len(sub_prefix) :]
sub_menu_translators[new_name] = translator
choices_commands.append(new_name)
if translator_name in self.CHOICES_COMMANDS:
self.CHOICES_COMMANDS.remove(translator_name)
- # Create the sub controller as a new class
class_name = f"{self._name.capitalize()}{path.capitalize()}Controller"
SubController = type(
class_name,
(PlatformController,),
{
"CHOICES_GENERATION": True,
- # "CHOICES_MENUS": [],
"CHOICES_COMMANDS": choices_commands,
+ "_factory_backend": self._factory_backend,
},
)
@@ -143,7 +172,6 @@ class PlatformController(BaseController):
def _generate_commands(self):
"""Generate commands."""
for name, translator in self.translators.items():
- # Prepare the translator name to create a command call in the controller
new_name = name.replace(f"{self._name}_", "")
self._generate_command_call(name=new_name, translator=translator)
@@ -170,14 +198,13 @@ class PlatformController(BaseController):
obbject = translator.execute_func(parsed_args=ns_parser)
df: pd.DataFrame = pd.DataFrame()
- fig: OpenBBFigure | None = None
title = f"{self.PATH}{translator.func.__name__}"
if obbject:
if isinstance(obbject, list):
- obbject = OBBject(results=obbject)
+ obbject = _OBBject()(results=obbject)
- if isinstance(obbject, OBBject):
+ if isinstance(obbject, _OBBject()):
if (
session.max_obbjects_exceeded()
and obbject.results
@@ -188,9 +215,7 @@ class PlatformController(BaseController):
"[yellow]Maximum number of OBBjects reached. The oldest entry was removed.[yellow]"
)
- # use the obbject to store the command so we can display it later on results
obbject.extra["command"] = f"{title} {' '.join(other_args)}"
- # if there is a registry key in the parser, store to the obbject
if (
hasattr(ns_parser, "register_key")
and ns_parser.register_key
@@ -209,15 +234,11 @@ class PlatformController(BaseController):
)
if store_obbject:
- # store the obbject in the registry
register_result = session.obbject_registry.register(
obbject
)
- # we need to force to re-link so that the new obbject
- # is immediately available for data processing commands
self._link_obbject_to_data_processing_commands()
- # also update the completer
self.update_completer(self.choices_default)
if (
@@ -228,22 +249,25 @@ class PlatformController(BaseController):
"Added `OBBject` to cached results."
)
- # making the dataframe available either for printing or exporting
- df = obbject.to_dataframe()
+ chart = hasattr(ns_parser, "chart") and ns_parser.chart
- if hasattr(ns_parser, "chart") and ns_parser.chart:
- fig = obbject.chart.fig if obbject.chart else None
- if not export:
- obbject.show()
- elif session.settings.USE_INTERACTIVE_DF and not export:
- obbject.charting.table() # type: ignore[attr-defined]
- else:
- if isinstance(df.columns, pd.RangeIndex):
- df.columns = [str(i) for i in df.columns]
-
- print_rich_table(
- df=df, show_index=True, title=title, export=export
- )
+ if not export:
+ try:
+ session.output_adapter.display(
+ data=obbject,
+ title=title,
+ export=export,
+ chart=chart,
+ )
+ except Exception as e:
+ session.console.print(
+ f"[red]Display error: {e}[/red]"
+ )
+ if hasattr(obbject, "model_dump"):
+ results = obbject.model_dump(
+ exclude_unset=True, exclude_none=True
+ ).get("results")
+ session.console.print(results)
elif isinstance(obbject, dict):
df = pd.DataFrame.from_dict(obbject, orient="columns")
@@ -251,7 +275,7 @@ class PlatformController(BaseController):
df=df, show_index=True, title=title, export=export
)
- elif not isinstance(obbject, OBBject):
+ elif not isinstance(obbject, _OBBject()):
session.console.print(obbject)
if export and not df.empty:
@@ -259,6 +283,13 @@ class PlatformController(BaseController):
if sheet_name and isinstance(sheet_name, list):
sheet_name = sheet_name[0]
+ fig = None
+ if hasattr(ns_parser, "chart") and ns_parser.chart:
+ from contextlib import suppress
+
+ with suppress(Exception):
+ fig = obbject.chart.fig if obbject.chart else None
+
export_data(
export_type=",".join(ns_parser.export),
dir_path=os.path.dirname(os.path.abspath(__file__)),
@@ -274,11 +305,11 @@ class PlatformController(BaseController):
session.console.print(f"[red]{e}[/]\n")
return
- # Bind the method to the class
bound_method = MethodType(method, self)
- # Update the wrapper and set the attribute
- bound_method = update_wrapper(partial(bound_method, translator=translator), method) # type: ignore
+ bound_method = update_wrapper(
+ partial(bound_method, translator=translator), method
+ )
setattr(self, f"call_{name}", bound_method)
def _generate_controller_call(self, controller, name, parent_path, translators):
@@ -294,11 +325,9 @@ class PlatformController(BaseController):
queue=self.queue,
)
- # Bind the method to the class
bound_method = MethodType(method, self)
- # Update the wrapper and set the attribute
- bound_method = update_wrapper( # type: ignore
+ bound_method = update_wrapper(
partial(
bound_method,
name=name,
@@ -310,10 +339,28 @@ class PlatformController(BaseController):
)
setattr(self, f"call_{name}", bound_method)
+ def _get_reference_paths(self) -> dict[str, dict[str, Any]]:
+ """Return the reference path-description dict."""
+ if self._factory_backend is not None:
+ return self._factory_backend.reference_paths
+ from openbb_cli.backend import LocalBackend
+
+ return LocalBackend().reference_paths
+
+ def _get_reference_routers(self) -> dict[str, dict[str, Any]]:
+ """Return the reference router-description dict."""
+ if self._factory_backend is not None:
+ return self._factory_backend.reference_routers
+ from openbb_cli.backend import LocalBackend
+
+ return LocalBackend().reference_routers
+
def _get_command_description(self, command: str) -> str:
"""Get command description."""
command_description = (
- obb.reference["paths"].get(f"{self.PATH}{command}", {}).get("description", "") # type: ignore
+ self._get_reference_paths()
+ .get(f"{self.PATH}{command}", {})
+ .get("description", "")
)
if not command_description:
@@ -323,7 +370,7 @@ class PlatformController(BaseController):
if trl and hasattr(trl, "parser"):
command_description = trl.parser.description
- return command_description.split(".")[0].lower()
+ return (command_description or "").split(".")[0].lower()
def _get_menu_description(self, menu: str) -> str:
"""Get menu description."""
@@ -338,12 +385,13 @@ class PlatformController(BaseController):
return commands
menu_description = (
- obb.reference["routers"].get(f"{self.PATH}{menu}", {}).get("description", "") # type: ignore
+ self._get_reference_routers()
+ .get(f"{self.PATH}{menu}", {})
+ .get("description", "")
) or ""
if menu_description:
return menu_description.split(".")[0].lower()
- # If no description is found, return the sub menu commands
return ", ".join(_get_sub_menu_commands())
def print_help(self):
@@ -371,8 +419,9 @@ class PlatformController(BaseController):
for key, value in list(session.obbject_registry.all.items())[
: session.settings.N_TO_DISPLAY_OBBJECT_REGISTRY
]:
+ command = (value.get("extra") or {}).get("command", "")
mt.add_raw(
- f"[yellow]OBB{key}[/yellow]: {value['command']}",
+ f"[yellow]OBB{key}[/yellow]: {command}",
left_spacing=True,
)
diff --git a/cli/openbb_cli/controllers/choices.py b/cli/openbb_cli/controllers/choices.py
index 9eb0e277ae9..ffe10cb1145 100644
--- a/cli/openbb_cli/controllers/choices.py
+++ b/cli/openbb_cli/controllers/choices.py
@@ -1,4 +1,4 @@
-"""This module contains functions to build the choice map for the controllers."""
+"""Functions to build the choice map for the controllers."""
from argparse import SUPPRESS, ArgumentParser
from collections.abc import Callable
@@ -18,8 +18,8 @@ from openbb_cli.session import Session
session = Session()
-def __mock_parse_known_args_and_warn( # pylint: disable=R0917
- controller, # pylint: disable=unused-argument
+def __mock_parse_known_args_and_warn(
+ controller,
parser: ArgumentParser,
other_args: list[str],
export_allowed: Literal[
@@ -30,21 +30,18 @@ def __mock_parse_known_args_and_warn( # pylint: disable=R0917
) -> None:
"""Add arguments.
- Add the arguments that would have normally added by :
- - openbb_cli.base_controller.BaseController.parse_known_args_and_warn
-
Parameters
----------
- parser: argparse.ArgumentParser
- Parser with predefined arguments
- other_args: List[str]
- list of arguments to parse
- export_allowed: Literal["no_export", "raw_data_only", "figures_only", "raw_data_and_figures"]
- Export options
- raw: bool
- Add the --raw flag
- limit: int
- Add a --limit flag with this number default
+ parser : argparse.ArgumentParser
+ Parser with predefined arguments.
+ other_args : list[str]
+ List of arguments to parse.
+ export_allowed : Literal["no_export", "raw_data_only", "figures_only", "raw_data_and_figures"]
+ Export options.
+ raw : bool
+ Add the --raw flag.
+ limit : int
+ Add a --limit flag with this number default.
"""
_ = other_args
@@ -114,15 +111,12 @@ def __mock_parse_known_args_and_warn( # pylint: disable=R0917
def __mock_parse_simple_args(parser: ArgumentParser, other_args: list[str]) -> tuple:
"""Add arguments.
- Add the arguments that would have normally added by:
- - openbb_cli.parent_classes.BaseController.parse_simple_args
-
Parameters
----------
- parser: argparse.ArgumentParser
- Parser with predefined arguments
- other_args: List[str]
- List of arguments to parse
+ parser : argparse.ArgumentParser
+ Parser with predefined arguments.
+ other_args : list[str]
+ List of arguments to parse.
"""
parser.add_argument(
"-h", "--help", action="store_true", help="show this help message"
@@ -136,14 +130,15 @@ def __get_command_func(controller, command: str):
Parameters
----------
- controller: BaseController
+ controller : BaseController
Instance of the CLI Controller.
- command: str
- A name from controller.CHOICES_COMMANDS
+ command : str
+ A name from controller.CHOICES_COMMANDS.
Returns
-------
- Callable: Command function.
+ Callable
+ Command function.
"""
if command not in controller.CHOICES_COMMANDS:
raise AttributeError(
@@ -163,20 +158,17 @@ def __get_command_func(controller, command: str):
def contains_functions_to_patch(command_func: Callable) -> bool:
"""Check command function.
- Check if a `command_func` actually contains the functions we want to mock, i.e.:
- - parse_simple_args
- - parse_known_args_and_warn
-
Parameters
----------
- command_func: Callable
+ command_func : Callable
Function to check.
Returns
-------
- bool: Whether or not `command_func` contains the mocked functions.
+ bool
+ Whether or not `command_func` contains the mocked functions.
"""
- co_names = command_func.__code__.co_names
+ co_names = command_func.__code__.co_names # ty: ignore[unresolved-attribute]
return bool(
"parse_simple_args" in co_names or "parse_known_args_and_warn" in co_names
@@ -187,21 +179,15 @@ def contains_functions_to_patch(command_func: Callable) -> bool:
def __patch_controller_functions(controller):
"""Patch controller functions.
- Patch the following function from a BaseController instance:
- - parse_simple_args
- - parse_known_args_and_warn
-
- These functions take an 'argparse.ArgumentParser' object as parameter.
- We want to intercept this 'argparse.ArgumentParser' object.
-
Parameters
----------
- controller: BaseController
+ controller : BaseController
BaseController object that needs to be patched.
Returns
-------
- List[Callable]: List of mocked functions.
+ list[Callable]
+ List of mocked functions.
"""
bound_mock_parse_known_args_and_warn = MethodType(
__mock_parse_known_args_and_warn,
@@ -234,12 +220,13 @@ def __patch_controller_functions(controller):
for patcher in patcher_list:
patched_function_list.append(patcher.start())
- yield patched_function_list
-
- if not session.settings.DEBUG_MODE:
- rich.stop()
- for patcher in patcher_list:
- patcher.stop()
+ try:
+ yield patched_function_list
+ finally:
+ if not session.settings.DEBUG_MODE:
+ rich.stop()
+ for patcher in patcher_list:
+ patcher.stop()
def _get_argument_parser(
@@ -248,21 +235,17 @@ def _get_argument_parser(
) -> ArgumentParser:
"""Intercept the ArgumentParser instance from the command function.
- A command function being a function starting with `call_`, like:
- - call_help
- - call_overview
- - call_load
-
Parameters
----------
- controller: BaseController
+ controller : BaseController
Instance of the CLI Controller.
- command: str
+ command : str
A name from `controller.CHOICES_COMMANDS`.
Returns
-------
- ArgumentParser: ArgumentParser instance from the command function.
+ ArgumentParser
+ ArgumentParser instance from the command function.
"""
command_func: Callable = __get_command_func(controller=controller, command=command)
@@ -292,14 +275,13 @@ def _get_argument_parser(
" - parse_known_args_and_warn\n"
)
- # pylint: disable=possibly-used-before-assignment
return argument_parser
def _build_command_choice_map(argument_parser: ArgumentParser) -> dict:
"""Build the choice map for a command."""
choice_map: dict = {}
- for action in argument_parser._actions: # pylint: disable=protected-access
+ for action in argument_parser._actions:
if action.help == SUPPRESS:
continue
if len(action.option_strings) == 1:
diff --git a/cli/openbb_cli/controllers/cli_controller.py b/cli/openbb_cli/controllers/cli_controller.py
index 395315d78b1..c04febc92d9 100644
--- a/cli/openbb_cli/controllers/cli_controller.py
+++ b/cli/openbb_cli/controllers/cli_controller.py
@@ -1,16 +1,11 @@
-#!/usr/bin/env python
"""Main CLI Module."""
-# pylint: disable=too-many-public-methods,import-outside-toplevel, too-many-function-args
-# pylint: disable=too-many-branches,no-member,C0302,too-many-return-statements, inconsistent-return-statements
-
import argparse
import contextlib
import difflib
import os
import re
import sys
-import time
import webbrowser
from datetime import datetime
from functools import partial, update_wrapper
@@ -19,8 +14,10 @@ from types import MethodType
from typing import Any
import pandas as pd
-import requests
-from openbb import obb
+from prompt_toolkit.formatted_text import HTML
+from prompt_toolkit.styles import Style
+
+from openbb_cli.backend import Backend, LocalBackend
from openbb_cli.config import constants
from openbb_cli.config.constants import (
ASSETS_DIRECTORY,
@@ -46,45 +43,82 @@ from openbb_cli.controllers.utils import (
welcome_message,
)
from openbb_cli.session import Session
-from prompt_toolkit.formatted_text import HTML
-from prompt_toolkit.styles import Style
-from pydantic import BaseModel
-PLATFORM_ROUTERS = {
- d: "menu" if not isinstance(getattr(obb, d), BaseModel) else "command"
- for d in dir(obb)
- if "_" not in d
-}
NON_DATA_ROUTERS = ["coverage", "reference", "system", "user"]
DATA_PROCESSING_ROUTERS = ["technical", "quantitative", "econometrics"]
env_file = str(ENV_FILE_SETTINGS)
session = Session()
+def _result_to_obbject(result: Any, router: str, other_args: list[str]) -> Any:
+ """Lift a spec-mode dispatcher result to an ``OBBject`` for registry insert."""
+ try:
+ from openbb_core.app.model.obbject import OBBject
+ except ImportError:
+ return None
+ if isinstance(result, OBBject):
+ obbject = result
+ elif isinstance(result, (list, dict)):
+ try:
+ obbject = OBBject(results=result)
+ obbject._route = "/" + router.replace(".", "/").strip("/")
+ except Exception: # noqa: BLE001
+ return None
+ else:
+ return None
+ obbject.extra["command"] = f"/{router} {' '.join(other_args)}".strip()
+ return obbject
+
+
+def _params_to_completions(parameters: list[dict[str, Any]]) -> dict[str, Any]:
+ """Translate normalized spec parameters into a NestedCompleter dict."""
+ out: dict[str, Any] = {}
+ for p in parameters or []:
+ name = p.get("name")
+ if not name:
+ continue
+ flag = f"--{name}"
+ choices_list = p.get("choices") or []
+ if choices_list:
+ out[flag] = {str(c): None for c in choices_list}
+ else:
+ out[flag] = None
+ out["--help"] = None
+ out["-h"] = "--help"
+ return out
+
+
class CLIController(BaseController):
"""CLI Controller class."""
- CHOICES_COMMANDS = ["record", "stop", "exe", "results"]
- CHOICES_MENUS = [
- "settings",
- ]
-
- for router, value in PLATFORM_ROUTERS.items():
- if value == "menu":
- CHOICES_MENUS.append(router)
- else:
- CHOICES_COMMANDS.append(router)
+ CHOICES_COMMANDS_BUILTIN = ["record", "stop", "exe", "results"]
+ CHOICES_MENUS_BUILTIN = ["settings", "user", "feature"]
PATH = "/"
CHOICES_GENERATION = False
- def __init__(self, jobs_cmds: list[str] | None = None):
+ def __init__(
+ self,
+ jobs_cmds: list[str] | None = None,
+ *,
+ backend: Backend | None = None,
+ ):
"""Construct CLI controller."""
self.ROUTINE_FILES: dict[str, Path] = dict()
- self.ROUTINE_DEFAULT_FILES: dict[str, Path] = dict()
- self.ROUTINE_PERSONAL_FILES: dict[str, Path] = dict()
self.ROUTINE_CHOICES: dict[str, Any] = dict()
+ self._backend: Backend = backend if backend is not None else LocalBackend()
+
+ platform_routers = self._backend.routers
+ menus = list(self.CHOICES_MENUS_BUILTIN)
+ commands = list(self.CHOICES_COMMANDS_BUILTIN)
+ for router, kind in platform_routers.items():
+ if router == "user":
+ continue
+ (menus if kind == "menu" else commands).append(router)
+ self.CHOICES_MENUS = menus
+ self.CHOICES_COMMANDS = commands
+
super().__init__(jobs_cmds)
self.queue: list[str] = list()
@@ -101,77 +135,175 @@ class CLIController(BaseController):
self.update_runtime_choices()
def _generate_platform_commands(self):
- """Generate Platform based commands/menus."""
+ """Generate Platform based commands/menus from the configured backend."""
+ backend = self._backend
def method_call_class(self, _, controller, name, parent_path, target):
self.queue = self.load_class(
controller, name, parent_path, target, self.queue
)
- # pylint: disable=unused-argument
- def method_call_command(self, _, router: str):
- """Call command."""
- mdl = getattr(obb, router)
+ def method_call_command_legacy(self, _, router: str):
+ """Display the static command-target metadata for in-process ``obb``."""
+ mdl = backend.get_command_target(router)
df = pd.DataFrame.from_dict(mdl.model_dump(), orient="index")
if isinstance(df.columns, pd.RangeIndex):
df.columns = [str(i) for i in df.columns]
return print_rich_table(df, show_index=True)
- for router, value in PLATFORM_ROUTERS.items():
- target = getattr(obb, router)
+ def method_call_command_spec(self, other_args: list[str], router: str):
+ """Dispatch a top-level command-typed router via SpecBackend."""
+ from openbb_cli.backend import SpecTranslator
+
+ spec_doc: dict[str, Any] = getattr(backend, "_spec", {})
+ cmd_spec = spec_doc.get("commands", {}).get(router)
+ if cmd_spec is None:
+ session.console.print(f"[red]Command not found in spec: {router}[/red]")
+ return
+ translator = SpecTranslator(
+ router, cmd_spec, getattr(backend, "_dispatcher", None)
+ )
+ ns_parser = self.parse_known_args_and_warn(
+ parser=translator.parser,
+ other_args=other_args,
+ export_allowed="raw_data_only",
+ )
+ if not ns_parser:
+ return
+ try:
+ result = translator.execute_func(ns_parser)
+ except Exception as exc: # noqa: BLE001
+ session.console.print(f"[red]error: {exc}[/red]")
+ return
+
+ obbject = _result_to_obbject(result, router, other_args)
+ if obbject is not None and obbject.results is not None:
+ if session.max_obbjects_exceeded():
+ session.obbject_registry.remove()
+ session.console.print(
+ "[yellow]Maximum number of OBBjects reached. "
+ "The oldest entry was removed.[/yellow]"
+ )
+ if session.obbject_registry.register(obbject) and (
+ session.settings.SHOW_MSG_OBBJECT_REGISTRY
+ ):
+ session.console.print("Added `OBBject` to cached results.")
+
+ payload: Any
+ if obbject is not None:
+ payload = obbject.results
+ elif isinstance(result, dict):
+ payload = result.get("results", result)
+ else:
+ payload = result
+ if isinstance(payload, dict):
+ df = pd.DataFrame([payload])
+ elif isinstance(payload, list):
+ df = pd.DataFrame(payload)
+ else:
+ df = pd.DataFrame({"value": [payload]})
+ session.output_adapter.display(df, title=f"/{router}")
+
+ is_spec_backend = hasattr(backend, "_spec") and hasattr(backend, "_dispatcher")
+ method_call_command: Any = (
+ method_call_command_spec if is_spec_backend else method_call_command_legacy
+ )
+
+ spec_commands: dict[str, Any] = (
+ getattr(backend, "_spec", {}).get("commands", {}) if is_spec_backend else {}
+ )
+
+ def method_call_menu_or_leaf(
+ self,
+ other_args: list[str],
+ controller,
+ name: str,
+ parent_path: list[str],
+ target,
+ router: str,
+ ):
+ """Hybrid for routers that have both a leaf path and nested commands."""
+ if other_args:
+ method_call_command_spec(self, other_args, router)
+ return
+ self.queue = self.load_class(
+ controller, name, parent_path, target, self.queue
+ )
+
+ for router, value in backend.routers.items():
+ if router == "user":
+ continue
+
+ has_leaf = router in spec_commands
if value == "menu":
- pcf = PlatformControllerFactory(
- target,
- reference=obb.reference["paths"], # type: ignore
- )
+ pcf = PlatformControllerFactory(backend=backend, router_name=router)
DynamicController = pcf.create()
- # Bind the method to the class
- bound_method = MethodType(method_call_class, self)
-
- # Update the wrapper and set the attribute
- bound_method = update_wrapper( # type: ignore
- partial(
- bound_method,
- controller=DynamicController,
- name=router,
- target=target,
- parent_path=self.path,
- ),
- method_call_class,
- )
+ if has_leaf:
+ bound_method = MethodType(method_call_menu_or_leaf, self)
+ bound_method = update_wrapper(
+ partial(
+ bound_method,
+ controller=DynamicController,
+ name=router,
+ target=None,
+ parent_path=self.path,
+ router=router,
+ ),
+ method_call_menu_or_leaf,
+ )
+ else:
+ bound_method = MethodType(method_call_class, self)
+ bound_method = update_wrapper(
+ partial(
+ bound_method,
+ controller=DynamicController,
+ name=router,
+ target=None,
+ parent_path=self.path,
+ ),
+ method_call_class,
+ )
else:
bound_method = MethodType(method_call_command, self)
- bound_method = update_wrapper( # type: ignore
+ bound_method = update_wrapper(
partial(bound_method, router=router),
- method_call_command,
+ method_call_command, # ty: ignore[invalid-argument-type]
)
setattr(self, f"call_{router}", bound_method)
+ def _spec_command_completions(self) -> dict[str, dict[str, Any]]:
+ """Build per-command completion choices from the SpecBackend's metadata."""
+ out: dict[str, dict[str, Any]] = {}
+ backend = getattr(self, "_backend", None)
+ if backend is None:
+ return out
+ spec_doc = getattr(backend, "_spec", None)
+ if not isinstance(spec_doc, dict):
+ return out
+ commands = spec_doc.get("commands", {})
+ for router in backend.routers:
+ if router not in self.controller_choices:
+ continue
+ cmd_spec = commands.get(router)
+ if not cmd_spec:
+ continue
+ out[router] = _params_to_completions(cmd_spec.get("parameters", []))
+ return out
+
def update_runtime_choices(self):
"""Update runtime choices."""
routines_directory = Path(session.user.preferences.export_directory, "routines")
if session.prompt_session and session.settings.USE_PROMPT_TOOLKIT:
- # choices: dict = self.choices_default
- choices: dict = {c: {} for c in self.controller_choices} # type: ignore
+ choices: dict = {c: {} for c in self.controller_choices}
+ choices.update(self._spec_command_completions())
self.ROUTINE_FILES = {
- filepath.name: filepath for filepath in routines_directory.rglob("*.openbb") # type: ignore
- }
- self.ROUTINE_DEFAULT_FILES = {
- filepath.name: filepath # type: ignore
- for filepath in Path(routines_directory / "hub" / "default").rglob(
- "*.openbb"
- )
- }
- self.ROUTINE_PERSONAL_FILES = {
- filepath.name: filepath # type: ignore
- for filepath in Path(routines_directory / "hub" / "personal").rglob(
- "*.openbb"
- )
+ filepath.name: filepath
+ for filepath in routines_directory.rglob("*.openbb")
}
choices["exe"] = {
@@ -183,7 +315,6 @@ class CLIController(BaseController):
"-e": "--example",
"--input": None,
"-i": "--input",
- "--url": None,
"--help": None,
"-h": "--help",
}
@@ -192,8 +323,6 @@ class CLIController(BaseController):
"-n": "--name",
"--description": None,
"-d": "--description",
- "--public": None,
- "-p": "--public",
"--tag1": {c: None for c in constants.SCRIPT_TAGS},
"--tag2": {c: None for c in constants.SCRIPT_TAGS},
"--tag3": {c: None for c in constants.SCRIPT_TAGS},
@@ -201,14 +330,47 @@ class CLIController(BaseController):
"-h": "--help",
}
choices["stop"] = {"--help": None, "-h": "--help"}
+
+ registry_all = session.obbject_registry.all
+ index_completions = {str(idx): None for idx in registry_all}
+ key_completions = {
+ (data.get("extra") or {}).get("register_key"): None
+ for data in registry_all.values()
+ if (data.get("extra") or {}).get("register_key")
+ }
+
choices["results"] = {
+ "--index": index_completions if index_completions else None,
+ "-i": "--index",
+ "--key": key_completions if key_completions else None,
+ "-k": "--key",
+ "--chart": None,
+ "-c": "--chart",
+ "--export": {
+ c: None
+ for c in [
+ "csv",
+ "json",
+ "xlsx",
+ "png",
+ "jpg",
+ "db",
+ "sqlite",
+ "sqlite3",
+ ]
+ },
+ "-e": "--export",
+ "--sheet-name": None,
+ "--help": None,
+ "-h": "--help",
+ }
+ choices["load"] = {
+ "--file": None,
+ "-f": "--file",
+ "--sheet-name": None,
+ "--register_key": None,
"--help": None,
"-h": "--help",
- "--export": {c: None for c in ["csv", "json", "xlsx", "png", "jpg"]},
- "--index": None,
- "--key": None,
- "--chart": None,
- "--sheet_name": None,
}
self.update_completer(choices)
@@ -221,6 +383,10 @@ class CLIController(BaseController):
"settings",
description="enable and disable feature flags, preferences and settings",
)
+ mt.add_menu(
+ "user",
+ description="view and set platform user preferences for the session",
+ )
mt.add_raw("\n")
mt.add_info("Record and execute your own .openbb routine scripts")
mt.add_cmd("record", description="start recording current session")
@@ -234,46 +400,58 @@ class CLIController(BaseController):
mt.add_raw("\n")
mt.add_info("Retrieve data from different asset classes and providers")
- for router, value in PLATFORM_ROUTERS.items():
+ platform_routers = self._backend.routers
+ reference_routers = self._backend.reference_routers
+ reference_paths = self._backend.reference_paths
+
+ def _menu_description(router: str) -> str:
+ """Return the menu's reference description (tolerant of trailing slash)."""
+ for key in (f"{self.PATH}{router}/", f"{self.PATH}{router}"):
+ desc = reference_routers.get(key, {}).get("description") or ""
+ if desc:
+ return desc.split(".")[0].lower()
+ return ""
+
+ def _command_description(router: str) -> str:
+ """Return the operation description for top-level command-typed routers."""
+ for key in (f"{self.PATH}{router}", f"{self.PATH}{router}/"):
+ desc = reference_paths.get(key, {}).get("description") or ""
+ if desc:
+ return desc.split(".")[0].lower()
+ return ""
+
+ for router, value in platform_routers.items():
if router in NON_DATA_ROUTERS or router in DATA_PROCESSING_ROUTERS:
continue
if value == "menu":
- menu_description = (
- obb.reference["routers"].get(f"{self.PATH}{router}", {}).get("description") # type: ignore
- ) or ""
- mt.add_menu(
- name=router,
- description=menu_description.split(".")[0].lower(),
- )
+ mt.add_menu(name=router, description=_menu_description(router))
else:
- mt.add_cmd(router)
+ mt.add_cmd(name=router, description=_command_description(router))
- if any(router in PLATFORM_ROUTERS for router in DATA_PROCESSING_ROUTERS):
+ if any(router in platform_routers for router in DATA_PROCESSING_ROUTERS):
mt.add_info("\nAnalyze and process previously obtained data")
- for router, value in PLATFORM_ROUTERS.items():
+ for router, value in platform_routers.items():
if router not in DATA_PROCESSING_ROUTERS:
continue
if value == "menu":
- menu_description = (
- obb.reference["routers"].get(f"{self.PATH}{router}", {}).get("description") # type: ignore
- ) or ""
- mt.add_menu(
- name=router,
- description=menu_description.split(".")[0].lower(),
- )
+ mt.add_menu(name=router, description=_menu_description(router))
else:
- mt.add_cmd(router)
+ mt.add_cmd(name=router, description=_command_description(router))
mt.add_raw("\n")
+ mt.add_info("Data manipulation and feature engineering")
+ mt.add_menu("feature", description="feature engineering and table operations")
+ mt.add_cmd("load", description="load data from file in OpenBBUserData")
mt.add_cmd("results")
if session.obbject_registry.obbjects:
mt.add_info("\nCached Results")
- for key, value in list(session.obbject_registry.all.items())[ # type: ignore
+ for key, value in list(session.obbject_registry.all.items())[
: session.settings.N_TO_DISPLAY_OBBJECT_REGISTRY
]:
+ command = (value.get("extra") or {}).get("command", "")
mt.add_raw(
- f"[yellow]OBB{key}[/yellow]: {value['command']}", # type: ignore[index]
+ f"[yellow]OBB{key}[/yellow]: {command}",
left_spacing=True,
)
@@ -288,9 +466,22 @@ class CLIController(BaseController):
self.queue = self.load_class(SettingsController, self.queue)
+ def call_user(self, _):
+ """Process user command."""
+ from openbb_cli.controllers.user_controller import UserController
+
+ self.queue = self.load_class(UserController, self.queue)
+
+ def call_feature(self, _):
+ """Process feature engineering command."""
+ from openbb_cli.controllers.feature_controller import (
+ FeatureController,
+ )
+
+ self.queue = self.load_class(FeatureController, self.queue)
+
def call_exe(self, other_args: list[str]):
"""Process exe command."""
- # Merge rest of string path to other_args and remove queue since it is a dir
other_args += self.queue
if not other_args:
@@ -313,9 +504,7 @@ class CLIController(BaseController):
required="-h" not in other_args
and "--help" not in other_args
and "-e" not in other_args
- and "--example" not in other_args
- and "--url" not in other_args
- and "my.openbb" not in other_args[0],
+ and "--example" not in other_args,
type=str,
nargs="+",
)
@@ -334,64 +523,18 @@ class CLIController(BaseController):
action="store_true",
default=False,
)
- parser.add_argument(
- "--url", help="URL to run openbb script from.", dest="url", type=str
- )
if other_args and "-" not in other_args[0][0]:
- if other_args[0].startswith("my.") or other_args[0].startswith("http"):
- other_args.insert(0, "--url")
- else:
- other_args.insert(0, "--file")
+ other_args.insert(0, "--file")
ns_parser = self.parse_known_args_and_warn(parser, other_args)
if ns_parser:
if ns_parser.example:
routine_path = ASSETS_DIRECTORY / "routines" / "routine_example.openbb"
- session.console.print( # TODO: Point to docs when ready
+ session.console.print(
"[info]Executing an example, please visit our docs to learn how to create your own script.[/info]\n"
)
- time.sleep(3)
- elif ns_parser.url:
- if not ns_parser.url.startswith(
- "https"
- ) and not ns_parser.url.startswith("http:"):
- url = "https://" + ns_parser.url
- elif ns_parser.url.startswith("http://"):
- url = ns_parser.url.replace("http://", "https://")
- else:
- url = ns_parser.url
- username = url.split("/")[-3]
- script_name = url.split("/")[-1]
- file_name = f"{username}_{script_name}.openbb"
- final_url = f"{url}?raw=true"
- response = requests.get(final_url, timeout=10)
- if response.status_code != 200:
- session.console.print(
- "[red]Could not find the requested script.[/red]"
- )
- return
- routine_text = response.json()["script"]
- file_path = Path(session.user.preferences.export_directory, "routines")
- routine_path = file_path / file_name
- with open(routine_path, "w") as file:
- file.write(routine_text)
- self.update_runtime_choices()
-
elif ns_parser.file:
- file_path = " ".join(ns_parser.file) # type: ignore
- # if string is not in this format "default/file.openbb" then check for files in ROUTINE_FILES
- full_path = file_path
- hub_routine = file_path.split("/") # type: ignore
- # Change with: my.openbb.co
- if hub_routine[0] == "default":
- routine_path = Path(
- self.ROUTINE_DEFAULT_FILES.get(hub_routine[1], full_path)
- )
- elif hub_routine[0] == "personal":
- routine_path = Path(
- self.ROUTINE_PERSONAL_FILES.get(hub_routine[1], full_path)
- )
- else:
- routine_path = Path(self.ROUTINE_FILES.get(file_path, full_path)) # type: ignore
+ file_path = " ".join(ns_parser.file)
+ routine_path = Path(self.ROUTINE_FILES.get(file_path, file_path))
else:
return
@@ -400,7 +543,6 @@ class CLIController(BaseController):
raw_lines = list(fp)
script_inputs = []
- # Capture ARGV either as list if args separated by commas or as single value
if routine_args := ns_parser.routine_args:
pattern = r"\[(.*?)\]"
matches = re.findall(pattern, routine_args)
@@ -417,9 +559,6 @@ class CLIController(BaseController):
raw_lines=raw_lines, script_inputs=script_inputs
)
- # If there err output is not an empty string then it means there was an
- # issue in parsing the routine and therefore we don't want to feed it
- # to the terminal
if err:
session.console.print(err)
return
@@ -434,7 +573,6 @@ class CLIController(BaseController):
if "export" in self.queue[0]:
export_path = self.queue[0].split(" ")[1]
- # If the path selected does not start from the user root, give relative location from root
if export_path[0] == "~":
export_path = export_path.replace(
"~", HOME_DIRECTORY.as_posix()
@@ -444,7 +582,6 @@ class CLIController(BaseController):
os.path.dirname(os.path.abspath(__file__)), export_path
)
- # Check if the directory exists
if os.path.isdir(export_path):
session.console.print(
f"Export data to be saved in the selected folder: '{export_path}'"
@@ -481,7 +618,6 @@ def handle_job_cmds(jobs_cmds: list[str] | None) -> list[str] | None:
os.path.dirname(os.path.abspath(__file__)), export_path
)
- # Check if the directory exists
if os.path.isdir(export_path):
session.console.print(
f"Export data to be saved in the selected folder: '{export_path}'"
@@ -494,11 +630,15 @@ def handle_job_cmds(jobs_cmds: list[str] | None) -> list[str] | None:
return jobs_cmds
-# pylint: disable=unused-argument
-def run_cli(jobs_cmds: list[str] | None = None, test_mode=False):
- """Run the CLI menu."""
+def run_cli(
+ jobs_cmds: list[str] | None = None,
+ test_mode=False,
+ *,
+ backend: Backend | None = None,
+):
+ """Run the CLI menu, optionally backed by a non-default ``Backend``."""
ret_code = 1
- t_controller = CLIController(jobs_cmds)
+ t_controller = CLIController(jobs_cmds, backend=backend)
an_input = ""
jobs_cmds = handle_job_cmds(jobs_cmds)
@@ -514,29 +654,22 @@ def run_cli(jobs_cmds: list[str] | None = None, test_mode=False):
t_controller.print_help()
while ret_code:
- # There is a command in the queue
if t_controller.queue and len(t_controller.queue) > 0:
- # If the command is quitting the menu we want to return in here
if t_controller.queue[0] in ("q", "..", "quit"):
print_goodbye()
break
- # Consume 1 element from the queue
an_input = t_controller.queue[0]
t_controller.queue = t_controller.queue[1:]
- # Print the current location because this was an instruction and we want user to know what was the action
if an_input and an_input.split(" ")[0] in t_controller.CHOICES_COMMANDS:
session.console.print(f"{get_flair_and_username()} / $ {an_input}")
- # Get input command from user
else:
try:
- # Get input from user using auto-completion
if session.prompt_session and session.settings.USE_PROMPT_TOOLKIT:
- # Check if toolbar hint was enabled
if session.settings.TOOLBAR_HINT:
- an_input = session.prompt_session.prompt( # type: ignore[union-attr]
+ an_input = session.prompt_session.prompt(
f"{get_flair_and_username()} / $ ",
completer=t_controller.completer,
search_ignore_case=True,
@@ -554,13 +687,12 @@ def run_cli(jobs_cmds: list[str] | None = None, test_mode=False):
),
)
else:
- an_input = session.prompt_session.prompt( # type: ignore[union-attr]
+ an_input = session.prompt_session.prompt(
f"{get_flair_and_username()} / $ ",
completer=t_controller.completer,
search_ignore_case=True,
)
- # Get input from user without auto-completion
else:
an_input = input(f"{get_flair_and_username()} / $ ")
@@ -569,14 +701,12 @@ def run_cli(jobs_cmds: list[str] | None = None, test_mode=False):
break
try:
- # Process the input command
t_controller.queue = t_controller.switch(an_input)
if an_input in ("q", "quit", "..", "exit", "e"):
print_goodbye()
break
- # Check if the user wants to reset application
if an_input in ("r", "reset") or t_controller.update_success:
reset(t_controller.queue if t_controller.queue else [])
break
@@ -593,7 +723,7 @@ def run_cli(jobs_cmds: list[str] | None = None, test_mode=False):
)
if similar_cmd:
an_input = similar_cmd[0]
- if " " in an_input:
+ if " " in an_input: # pragma: no cover
candidate_input = (
f"{similar_cmd[0]} {' '.join(an_input.split(' ')[1:])}"
)
@@ -617,7 +747,7 @@ def insert_start_slash(cmds: list[str]) -> list[str]:
return cmds
-def run_scripts( # pylint: disable=R0917
+def run_scripts(
path: Path,
test_mode: bool = False,
verbose: bool = False,
@@ -629,19 +759,18 @@ def run_scripts( # pylint: disable=R0917
Parameters
----------
- path : str
- The location of the .openbb file
+ path : Path
+ The location of the .openbb file.
test_mode : bool
- Whether the CLI is in test mode
+ Whether the CLI is in test mode.
verbose : bool
- Whether to run tests in verbose mode
- routines_args : List[str]
+ Whether to run tests in verbose mode.
+ routines_args : list[str] | None
One or multiple inputs to be replaced in the routine and separated by commas.
- E.g. GME,AMC,BTC-USD
- special_arguments: Optional[Dict[str, str]]
- Replace `${key=default}` with `value` for every key in the dictionary
- output: bool
- Whether to log tests to txt files
+ special_arguments : dict[str, str] | None
+ Replace `${key=default}` with `value` for every key in the dictionary.
+ output : bool
+ Whether to log tests to txt files.
"""
if not path.exists():
session.console.print(f"File '{path}' doesn't exist. Launching base CLI.\n")
@@ -661,13 +790,12 @@ def run_scripts( # pylint: disable=R0917
for i, arg in enumerate(routines_args):
templine = templine.replace(f"$ARGV[{i}]", arg)
lines.append(templine)
- # Handle new testing arguments:
elif special_arguments:
lines = []
for line in raw_lines:
new_line = re.sub(
r"\${[^{]+=[^{]+}",
- lambda x: replace_dynamic(x, special_arguments), # type: ignore
+ lambda x: replace_dynamic(x, special_arguments),
line,
)
lines.append(new_line)
@@ -678,7 +806,6 @@ def run_scripts( # pylint: disable=R0917
if test_mode and "exit" not in lines[-1]:
lines.append("exit")
- # Deals with the export with a path with "/" in it
export_folder = ""
if "export" in lines[0]:
export_folder = lines[0].split("export ")[1].rstrip()
@@ -720,15 +847,15 @@ def replace_dynamic(match: re.Match, special_arguments: dict[str, str]) -> str:
Parameters
----------
- match: re.Match[str]
- The match object
- special_arguments: Dict[str, str]
- The key value pairs to replace in the scripts
+ match : re.Match
+ The match object.
+ special_arguments : dict[str, str]
+ The key value pairs to replace in the scripts.
Returns
- ----------
+ -------
str
- The new string
+ The new string.
"""
cleaned = match[0].replace("{", "").replace("}", "").replace("$", "")
key, default = cleaned.split("=")
@@ -759,12 +886,13 @@ def run_routine(file: str, routines_args: str | None = None):
)
-# pylint: disable=unused-argument
def main(
debug: bool,
dev: bool,
path_list: list[str],
routines_args: list[str] | None = None,
+ *,
+ backend: Backend | None = None,
**kwargs,
):
"""Run the CLI with various options.
@@ -772,28 +900,19 @@ def main(
Parameters
----------
debug : bool
- Whether to run the CLI in debug mode
- dev:
- Points backend towards development environment instead of production
- test : bool
- Whether to run the CLI in integrated test mode
- filtert : str
- Filter test files with given string in name
- paths : List[str]
- The paths to run for scripts or to test
- verbose : bool
- Whether to show output from tests
- routines_args : List[str]
+ Whether to run the CLI in debug mode.
+ dev : bool
+ Points backend towards development environment instead of production.
+ path_list : list[str]
+ The paths to run for scripts or to test.
+ routines_args : list[str] | None
One or multiple inputs to be replaced in the routine and separated by commas.
- E.g. GME,AMC,BTC-USD
"""
if debug:
session.settings.DEBUG_MODE = True
if dev:
session.settings.DEV_BACKEND = True
- session.settings.BASE_URL = "https://payments.openbb.dev/"
- session.settings.HUB_URL = "https://my.openbb.dev"
if isinstance(path_list, list) and path_list[0].endswith(".openbb"):
run_routine(
@@ -803,9 +922,9 @@ def main(
elif path_list:
argv_cmds = list([" ".join(path_list).replace(" /", "/home/")])
argv_cmds = insert_start_slash(argv_cmds) if argv_cmds else argv_cmds
- run_cli(argv_cmds)
+ run_cli(argv_cmds, backend=backend)
else:
- run_cli()
+ run_cli(backend=backend)
def parse_args_and_run():
@@ -856,8 +975,6 @@ def parse_args_and_run():
"Run the CLI in testing mode. Also run this option and '-h' to see testing argument options."
),
)
- # The args -m, -f and --HistoryManager.hist_file are used only in reports menu
- # by papermill and that's why they have suppress help.
parser.add_argument(
"-m",
help=argparse.SUPPRESS,
@@ -883,8 +1000,6 @@ def parse_args_and_run():
sys.argv.insert(1, "--file")
ns_parser, unknown = parser.parse_known_args()
- # This ensures that if cli.py receives unknown args it will not start.
- # Use -d flag if you want to see the unknown args.
if unknown:
if ns_parser.debug:
session.console.print(unknown)
@@ -903,11 +1018,15 @@ def parse_args_and_run():
def launch(
- debug: bool = False, dev: bool = False, queue: list[str] | None = None
+ debug: bool = False,
+ dev: bool = False,
+ queue: list[str] | None = None,
+ *,
+ backend: Backend | None = None,
) -> None:
- """Launch CLI."""
+ """Launch CLI, optionally with a non-default ``Backend``."""
if queue:
- main(debug, dev, queue, module="")
+ main(debug, dev, queue, module="", backend=backend)
else:
parse_args_and_run()
diff --git a/cli/openbb_cli/controllers/credentials_controller.py b/cli/openbb_cli/controllers/credentials_controller.py
new file mode 100644
index 00000000000..ee67fef4216
--- /dev/null
+++ b/cli/openbb_cli/controllers/credentials_controller.py
@@ -0,0 +1,118 @@
+"""Credentials Controller Module."""
+
+import argparse
+from functools import partial, update_wrapper
+from types import MethodType
+from typing import Any, cast
+
+from openbb import obb as _obb_container
+from pydantic import SecretStr
+
+obb = cast(Any, _obb_container)
+
+from openbb_cli.controllers.base_controller import BaseController
+from openbb_cli.session import Session
+
+session = Session()
+
+
+def _credential_status(raw: object) -> str:
+ """Return a display string for a credential value."""
+ if isinstance(raw, SecretStr):
+ secret = raw.get_secret_value()
+ if secret:
+ return f"[green]Set ({secret[:4]}****)[/green]"
+ return "[red]Not set[/red]"
+
+
+class CredentialsController(BaseController):
+ """Credentials Controller class."""
+
+ PATH = "/user/credentials/"
+ CHOICES_GENERATION = True
+
+ _CRED_COMMANDS: dict[str, dict[str, Any]] = {}
+ for _fname, _finfo in sorted(obb.user.credentials.__class__.model_fields.items()):
+ _provider = _finfo.description or ""
+ if _provider == _fname:
+ _provider = (
+ _fname.rsplit("_api_key", 1)[0]
+ .rsplit("_token", 1)[0]
+ .rsplit("_key", 1)[0]
+ )
+ _CRED_COMMANDS[_fname] = {
+ "command": _fname,
+ "field_name": _fname,
+ "provider": _provider,
+ }
+
+ CHOICES_COMMANDS: list[str] = list(_CRED_COMMANDS.keys())
+ CHOICES_MENUS: list[str] = []
+
+ def __init__(self, queue: list[str] | None = None):
+ """Initialize the Constructor."""
+ super().__init__(queue)
+ for cmd, field in self._CRED_COMMANDS.items():
+ self._generate_credential_command(cmd, field)
+ self.update_completer(self.choices_default)
+
+ def print_help(self):
+ """Print help."""
+ spacing = 4
+ max_name = max((len(c) for c in self._CRED_COMMANDS), default=20)
+ col_width = max_name + spacing
+
+ lines: list[str] = []
+ lines.append("[info]Session-level credentials (not persisted):[/info]")
+ lines.append("[info]Use: -v [/info]")
+ lines.append("")
+
+ for cmd, f in self._CRED_COMMANDS.items():
+ raw = getattr(obb.user.credentials, f["field_name"], None)
+ status = _credential_status(raw)
+ provider = f["provider"]
+ pad = " " * (col_width - len(cmd))
+ lines.append(
+ f"[cmds]{' ' * spacing}{cmd}{pad}{provider:<20s}[/cmds] {status}"
+ )
+
+ menu_text = "\n".join(lines) + "\n"
+ session.console.print(text=menu_text, menu="Credentials")
+
+ def _generate_credential_command(self, cmd_name: str, field: dict):
+ """Generate a set command bound to obb.user.credentials."""
+
+ def _set_credential(self, other_args: list[str], field=field) -> None:
+ """Set a credential value."""
+ field_name = field["field_name"]
+ parser = argparse.ArgumentParser(
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ prog=field["command"],
+ description=f"Set {field_name} credential",
+ add_help=False,
+ )
+ parser.add_argument(
+ "-v",
+ "--value",
+ dest="value",
+ action="store",
+ required=False,
+ type=str,
+ )
+ ns_parser, _ = self.parse_simple_args(parser, other_args)
+ if ns_parser:
+ if ns_parser.value is not None:
+ setattr(obb.user.credentials, field_name, ns_parser.value)
+ session.console.print(
+ f"[info]{field_name}:[/info] [green]Set[/green]"
+ )
+ elif not other_args:
+ raw = getattr(obb.user.credentials, field_name, None)
+ status = _credential_status(raw)
+ session.console.print(f"[info]{field_name}:[/info] {status}")
+
+ bound_method = update_wrapper(
+ wrapper=partial(MethodType(_set_credential, self), field=field),
+ wrapped=_set_credential,
+ )
+ setattr(self, f"call_{cmd_name}", bound_method)
diff --git a/cli/openbb_cli/controllers/feature_controller.py b/cli/openbb_cli/controllers/feature_controller.py
new file mode 100644
index 00000000000..4c035cee882
--- /dev/null
+++ b/cli/openbb_cli/controllers/feature_controller.py
@@ -0,0 +1,1335 @@
+"""Feature Engineering Controller."""
+
+import argparse
+import ast
+
+import pandas as pd
+
+from openbb_cli.controllers.base_controller import BaseController, session
+from openbb_cli.controllers.utils import extract_dataframe
+
+
+class FeatureController(BaseController):
+ """Feature Engineering Controller class."""
+
+ CHOICES_COMMANDS = [
+ "list",
+ "select",
+ "info",
+ "view",
+ "query",
+ "colname",
+ "coltype",
+ "addcol",
+ "dropcol",
+ "renamecol",
+ "modifycol",
+ "join",
+ "copy",
+ "save",
+ "delete",
+ "results",
+ ]
+
+ PATH = "/feature/"
+ CHOICES_GENERATION = True
+
+ def __init__(
+ self,
+ queue: list[str] | None = None,
+ ):
+ """Initialize Feature Engineering Controller."""
+ super().__init__(queue=queue)
+ self.current_table: int | None = None
+ self.update_completer(self.choices_default)
+
+ def _get_table_indices(self) -> list[str]:
+ """Get list of table indices from cache."""
+ if session.obbject_registry.all:
+ names = []
+ for idx, data in session.obbject_registry.all.items():
+ name = data.get("key", "")
+
+ if not name:
+ name = str(idx)
+
+ names.append(name)
+ return names
+ return []
+
+ def _resolve_table_identifier(self, identifier: str) -> int | None:
+ """Resolve a table identifier (name or index) to numeric index.
+
+ Parameters
+ ----------
+ identifier : str
+ Either a register_key or numeric index.
+
+ Returns
+ -------
+ int | None
+ The numeric index if found, None otherwise.
+ """
+ try:
+ idx = int(identifier)
+ if idx in session.obbject_registry.all:
+ return idx
+ except ValueError:
+ pass
+
+ for idx, data in session.obbject_registry.all.items():
+ if data.get("key") == identifier:
+ return idx
+
+ return None
+
+ def _get_column_names(self) -> list[str]:
+ """Get column names from current table."""
+ if self.current_table is not None:
+ result = session.obbject_registry.get(self.current_table)
+ if result:
+ df = extract_dataframe(result)
+ return df.columns.tolist()
+ return []
+
+ @property
+ def choices_default(self) -> dict:
+ """Return default choices with dynamic completions."""
+ table_indices = self._get_table_indices()
+ column_names = self._get_column_names()
+
+ column_recursive: dict = {"--help": None, "-h": "--help"}
+ if column_names:
+ for col in column_names:
+ column_recursive[col] = {"--help": None, "-h": "--help"}
+ for next_col in column_names:
+ if next_col != col:
+ column_recursive[col][next_col] = {
+ "--help": None,
+ "-h": "--help",
+ }
+
+ choices = {
+ "list": {
+ "--help": None,
+ "-h": "--help",
+ },
+ "select": {
+ **{idx: None for idx in table_indices},
+ "--help": None,
+ "-h": "--help",
+ },
+ "info": {
+ "--help": None,
+ "-h": "--help",
+ },
+ "view": {
+ "--head": None,
+ "--tail": None,
+ "--help": None,
+ "-h": "--help",
+ },
+ "query": {
+ **{col: None for col in column_names},
+ "--save": None,
+ "-s": "--save",
+ "--help": None,
+ "-h": "--help",
+ },
+ "colname": {
+ "--help": None,
+ "-h": "--help",
+ },
+ "coltype": {
+ **{
+ col: {
+ "int64": None,
+ "float64": None,
+ "float32": None,
+ "int32": None,
+ "str": None,
+ "object": None,
+ "bool": None,
+ "datetime64[ns]": None,
+ "category": {
+ "--categories": None,
+ "--ordered": None,
+ "--help": None,
+ "-h": "--help",
+ },
+ "--help": None,
+ "-h": "--help",
+ }
+ for col in column_names
+ },
+ "--help": None,
+ "-h": "--help",
+ },
+ "addcol": {
+ "--help": None,
+ "-h": "--help",
+ },
+ "dropcol": {
+ **{
+ col: {
+ **{c: None for c in column_names if c != col},
+ "--help": None,
+ "-h": "--help",
+ }
+ for col in column_names
+ },
+ "--help": None,
+ "-h": "--help",
+ },
+ "renamecol": {
+ **{col: None for col in column_names},
+ "--help": None,
+ "-h": "--help",
+ },
+ "modifycol": {
+ **{col: None for col in column_names},
+ "--help": None,
+ "-h": "--help",
+ },
+ "join": {
+ **{
+ idx: {
+ "--on": {col: None for col in column_names},
+ "-o": "--on",
+ "--left-on": {col: None for col in column_names},
+ "-l": "--left-on",
+ "--right-on": None,
+ "-r": "--right-on",
+ "--type": {
+ "inner": None,
+ "left": None,
+ "right": None,
+ "outer": None,
+ },
+ "-t": "--type",
+ "--save": None,
+ "-s": "--save",
+ "--help": None,
+ "-h": "--help",
+ }
+ for idx in table_indices
+ },
+ "--on": {col: None for col in column_names},
+ "-o": "--on",
+ "--left-on": {col: None for col in column_names},
+ "-l": "--left-on",
+ "--right-on": None,
+ "-r": "--right-on",
+ "--type": {
+ "inner": None,
+ "left": None,
+ "right": None,
+ "outer": None,
+ },
+ "-t": "--type",
+ "--save": None,
+ "-s": "--save",
+ "--help": None,
+ "-h": "--help",
+ },
+ "copy": {
+ "--help": None,
+ "-h": "--help",
+ },
+ "save": {
+ "--index": None,
+ "-i": "--index",
+ "--table": None,
+ "-t": "--table",
+ "--mode": {
+ "replace": None,
+ "append": None,
+ "fail": None,
+ },
+ "-m": "--mode",
+ "--sheet-name": None,
+ "-s": "--sheet-name",
+ "--help": None,
+ "-h": "--help",
+ },
+ "delete": {
+ **{idx: None for idx in table_indices},
+ "--help": None,
+ "-h": "--help",
+ },
+ "results": {
+ **{idx: None for idx in table_indices},
+ "--index": {idx: None for idx in table_indices},
+ "-i": "--index",
+ "--key": {idx: None for idx in table_indices},
+ "-k": "--key",
+ "--chart": None,
+ "-c": "--chart",
+ "--export": {
+ "csv": None,
+ "json": None,
+ "xlsx": None,
+ "png": None,
+ "jpg": None,
+ "db": None,
+ "sqlite": None,
+ "sqlite3": None,
+ },
+ "-e": "--export",
+ "--sheet-name": None,
+ "--help": None,
+ "-h": "--help",
+ },
+ "load": {
+ "-f": None,
+ "--file": None,
+ "--register_key": None,
+ "--sheet-name": None,
+ "--help": None,
+ "-h": "--help",
+ },
+ "cls": None,
+ "home": None,
+ "h": None,
+ "?": None,
+ "help": None,
+ "q": None,
+ "quit": None,
+ "..": None,
+ "e": None,
+ "exit": None,
+ "r": None,
+ "reset": None,
+ "stop": None,
+ }
+
+ return choices
+
+ def print_help(self):
+ """Print help."""
+ current_table_info = ""
+ if self.current_table is not None:
+ try:
+ result = session.obbject_registry.get(self.current_table)
+ if result:
+ df = extract_dataframe(result)
+ metadata = session.obbject_registry.all.get(self.current_table, {})
+ table_name = metadata.get("key", "")
+ if not table_name:
+ table_name = f"Table {self.current_table}"
+
+ col_list = list(df.columns)
+ col_names = ", ".join(col_list)
+
+ current_table_info = f"""
+[info]Current Selection:[/info]
+ [bold green]{table_name}[/bold green] - {df.shape[0]} rows ร {df.shape[1]} columns
+ [yellow]{col_names}[/yellow]
+
+"""
+ except Exception:
+ current_table_info = (
+ f"\n[yellow]Selected table {self.current_table}[/yellow]\n\n"
+ )
+
+ help_text = (
+ "\n[info]Feature Engineering - Data Manipulation Menu[/info]\n" # noqa: S608
+ + current_table_info
+ + "\n[cmds]"
+ """
+ list list all tables in cache
+ select select a table to work with
+ info show information about current table (shape, dtypes, etc)
+ view display current table
+
+[info]Column Operations:[/info]
+ colname list all column names and types
+ coltype change column data type
+ addcol add new column with expression
+ dropcol drop/delete column(s)
+ renamecol rename column
+ modifycol modify column with expression
+
+[info]Table Operations:[/info]
+ query execute pandas query statement
+ join join current table with another table
+ copy copy current table to new name
+
+[info]File Operations:[/info]
+ save save current table to file (CSV, JSON, Excel)
+ delete delete table from cache
+[/cmds]
+"""
+ )
+ session.console.print(text=help_text, menu="Feature")
+
+ def call_list(self, other_args: list[str]):
+ """List all tables in the results cache."""
+ parser = argparse.ArgumentParser(
+ add_help=False,
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ prog="list",
+ description="List all tables available in the cache.",
+ )
+ ns_parser = self.parse_known_args_and_warn(parser, other_args)
+ if ns_parser:
+ if not session.obbject_registry.all:
+ session.console.print("[yellow]No tables in cache.[/yellow]")
+ return
+
+ tables_data = []
+ for idx in session.obbject_registry.all:
+ result = session.obbject_registry.get(idx)
+ if result:
+ df = extract_dataframe(result)
+ tables_data.append(
+ {
+ "Index": idx,
+ "Rows": len(df),
+ "Columns": len(df.columns),
+ "Current": "โ" if idx == self.current_table else "",
+ }
+ )
+
+ if tables_data:
+ tables_df = pd.DataFrame(tables_data)
+ session.output_adapter.display(
+ data=tables_df,
+ title="Tables in Cache",
+ export=False,
+ chart=False,
+ )
+ else:
+ session.console.print("[yellow]No dataframes in cache.[/yellow]")
+
+ def call_select(self, other_args: list[str]):
+ """Select a table to work with."""
+ parser = argparse.ArgumentParser(
+ add_help=False,
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ prog="select",
+ description="Select a table from cache to work with.",
+ )
+ parser.add_argument(
+ "index",
+ type=str,
+ help="Index or register_key of the table to select",
+ )
+ ns_parser = self.parse_known_args_and_warn(parser, other_args)
+ if ns_parser:
+ result = session.obbject_registry.get(ns_parser.index)
+
+ if result is None:
+ try:
+ idx = int(ns_parser.index)
+ result = session.obbject_registry.get(idx)
+ except ValueError:
+ pass
+
+ if result:
+ for idx, obj in enumerate(reversed(session.obbject_registry.obbjects)):
+ if obj.id == result.id:
+ self.current_table = idx
+ break
+
+ df = extract_dataframe(result)
+ table_name = result.extra.get("register_key", "")
+ if not table_name:
+ table_name = f"Table {self.current_table}"
+
+ session.console.print(
+ f"[green]Selected table '{table_name}' "
+ f"with shape {df.shape}[/green]"
+ )
+ self.update_completer(self.choices_default)
+ else:
+ session.console.print(
+ f"[red]Table '{ns_parser.index}' not found in cache.[/red]"
+ )
+
+ def call_info(self, other_args: list[str]):
+ """Show information about current table."""
+ parser = argparse.ArgumentParser(
+ add_help=False,
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ prog="info",
+ description="Display information about the current table.",
+ )
+ ns_parser = self.parse_known_args_and_warn(parser, other_args)
+ if ns_parser:
+ if self.current_table is None:
+ session.console.print(
+ "[yellow]No table selected. Use 'select' command first.[/yellow]"
+ )
+ return
+
+ result = session.obbject_registry.get(self.current_table)
+ if result is None:
+ return
+ df = extract_dataframe(result)
+
+ session.console.print(f"\n[bold]Table: {self.current_table}[/bold]")
+ session.console.print(
+ f"Shape: {df.shape[0]} rows ร {df.shape[1]} columns\n"
+ )
+
+ info_data = []
+ for col in df.columns:
+ info_data.append(
+ {
+ "Column": col,
+ "Type": str(df[col].dtype),
+ "Non-Null": df[col].count(),
+ "Null": df[col].isna().sum(),
+ }
+ )
+
+ info_df = pd.DataFrame(info_data)
+ session.output_adapter.display(
+ data=info_df,
+ title="Column Information",
+ export=False,
+ chart=False,
+ )
+
+ def call_view(self, other_args: list[str]):
+ """Display current table."""
+ parser = argparse.ArgumentParser(
+ add_help=False,
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ prog="view",
+ description="Display the current table.",
+ )
+ parser.add_argument(
+ "--head",
+ type=int,
+ dest="head",
+ help="Show only first N rows",
+ )
+ parser.add_argument(
+ "--tail",
+ type=int,
+ dest="tail",
+ help="Show only last N rows",
+ )
+ ns_parser = self.parse_known_args_and_warn(parser, other_args)
+ if ns_parser:
+ if self.current_table is None:
+ session.console.print(
+ "[yellow]No table selected. Use 'select' command first.[/yellow]"
+ )
+ return
+
+ result = session.obbject_registry.get(self.current_table)
+ if result is None:
+ return
+ df = extract_dataframe(result)
+
+ if ns_parser.head:
+ df = df.head(ns_parser.head)
+ elif ns_parser.tail:
+ df = df.tail(ns_parser.tail)
+
+ session.output_adapter.display(
+ data=df,
+ title=f"Table: {self.current_table}",
+ export=False,
+ chart=False,
+ )
+
+ def call_query(self, other_args: list[str]):
+ """Execute pandas query statement."""
+ parser = argparse.ArgumentParser(
+ add_help=False,
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ prog="query",
+ description="Execute pandas operations on the current table. "
+ "The current table is available as 'df'. "
+ 'Examples: query "df.query(\'REF_AREA == \\"CHE\\"\')" or query "df.groupby(\'REF_AREA\').mean()"',
+ )
+ parser.add_argument(
+ "-s",
+ "--save",
+ action="store_true",
+ help="Save the result back to the current table",
+ )
+ parser.add_argument(
+ "expression",
+ type=str,
+ nargs=argparse.REMAINDER,
+ help="Pandas expression - use 'df' to reference the current table",
+ )
+ ns_parser = self.parse_known_args_and_warn(parser, other_args)
+ if ns_parser:
+ if self.current_table is None:
+ session.console.print(
+ "[yellow]No table selected. Use 'select' command first.[/yellow]"
+ )
+ return
+
+ result = session.obbject_registry.get(self.current_table)
+ if result is None:
+ return
+
+ from openbb_cli.controllers.utils import SQLiteTable
+
+ data_obj = (
+ result.model_dump(exclude_unset=True, exclude_none=True).get("results")
+ if hasattr(result, "model_dump")
+ else result
+ )
+
+ query_str = " ".join(ns_parser.expression).strip()
+
+ if isinstance(data_obj, SQLiteTable):
+ sql_query = None
+
+ import re
+
+ simple_filter = re.match(
+ r"df\[df\[['\"](\w+)['\"]\]\s*([><=!]+)\s*([^\]]+)\]", query_str
+ )
+
+ if simple_filter:
+ col_name = simple_filter.group(1)
+ operator = simple_filter.group(2)
+ value = simple_filter.group(3).strip()
+
+ sql_operator = operator
+ if operator == "==":
+ sql_operator = "="
+
+ if value.startswith(("'", '"')):
+ sql_value = value
+ else:
+ try:
+ sql_value = str(ast.literal_eval(value)) # noqa: S307
+ if (
+ not sql_value.replace(".", "")
+ .replace("-", "")
+ .isdigit()
+ ):
+ sql_value = f"'{sql_value}'"
+ except (ValueError, SyntaxError):
+ sql_value = f"'{value}'"
+
+ sql_query = f"{col_name} {sql_operator} {sql_value}"
+
+ try:
+ eval_result = data_obj.query(where=sql_query)
+
+ session.console.print(
+ f"[green]Query executed on database: {len(eval_result)} rows[/green]"
+ )
+
+ if ns_parser.save:
+ result.results = eval_result
+ session.console.print(
+ "[green]Table updated with result.[/green]"
+ )
+ else:
+ session.output_adapter.display(
+ data=eval_result,
+ title="Result (SQL optimized)",
+ export=False,
+ chart=False,
+ )
+ return
+ except Exception as sql_e:
+ session.console.print(
+ f"[yellow]SQL optimization failed: {sql_e}. Falling back to pandas.[/yellow]"
+ )
+
+ df = extract_dataframe(result)
+
+ try:
+ namespace = {"df": df, "pd": pd}
+ for col in df.columns:
+ namespace[col] = df[col]
+
+ eval_result = eval(query_str, namespace) # noqa: S307
+
+ if isinstance(eval_result, pd.DataFrame):
+ if set(eval_result.columns) == set(df.columns) and len(
+ eval_result
+ ) != len(df):
+ session.console.print(
+ f"[green]Query result: {len(eval_result)} rows (from {len(df)})[/green]"
+ )
+
+ if ns_parser.save:
+ result.results = eval_result
+ session.console.print(
+ "[green]Table updated with result.[/green]"
+ )
+ else:
+ session.output_adapter.display(
+ data=eval_result,
+ title="Result",
+ export=False,
+ chart=False,
+ )
+ elif isinstance(eval_result, pd.Series):
+ session.output_adapter.display(
+ data=eval_result.to_frame(),
+ title="Result",
+ export=False,
+ chart=False,
+ )
+ elif (
+ isinstance(eval_result, (list, tuple, set))
+ or hasattr(eval_result, "__iter__")
+ and not isinstance(eval_result, str)
+ ):
+ result_df = pd.DataFrame({query_str: list(eval_result)})
+ session.output_adapter.display(
+ data=result_df,
+ title="Result",
+ export=False,
+ chart=False,
+ )
+ else:
+ session.console.print(f"\n[bold]Result:[/bold] {eval_result}")
+ except Exception as e:
+ error_msg = str(e)
+ session.console.print(f"[red]Query error: {error_msg}[/red]")
+ if "is not defined" in error_msg:
+ session.console.print(
+ "[yellow]Hint: For string comparisons, use quotes around values:[/yellow]\n"
+ ' [cyan]query "df.query(\'REF_AREA == \\"CHE\\"\')"[/cyan]'
+ )
+ elif "invalid syntax" in error_msg:
+ session.console.print(
+ "[yellow]Hint: Valid query examples:[/yellow]\n"
+ ' [cyan]query "df.query(\'REF_AREA == \\"CHE\\"\')"[/cyan] (filter rows)\n'
+ " [cyan]query \"df.query('VALUE > 100')\"[/cyan] (numeric filter)\n"
+ ' [cyan]query "df.query(\'REF_AREA == \\"CHE\\"\').pivot_table(...)"[/cyan] (chained)\n'
+ " [cyan]query REF_AREA.unique()[/cyan] (column operation)"
+ )
+
+ def call_colname(self, other_args: list[str]):
+ """List all column names and their types."""
+ parser = argparse.ArgumentParser(
+ add_help=False,
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ prog="colname",
+ description="List all columns with their data types.",
+ )
+ ns_parser = self.parse_known_args_and_warn(parser, other_args)
+ if ns_parser:
+ if self.current_table is None:
+ session.console.print(
+ "[yellow]No table selected. Use 'select' command first.[/yellow]"
+ )
+ return
+
+ result = session.obbject_registry.get(self.current_table)
+ if result is None:
+ return
+ df = extract_dataframe(result)
+
+ col_data = []
+ for col in df.columns:
+ col_data.append(
+ {
+ "Column": col,
+ "Type": str(df[col].dtype),
+ }
+ )
+
+ col_df = pd.DataFrame(col_data)
+ session.output_adapter.display(
+ data=col_df,
+ title="Column Names and Types",
+ export=False,
+ chart=False,
+ )
+
+ def call_coltype(self, other_args: list[str]):
+ """Change column data type."""
+ parser = argparse.ArgumentParser(
+ add_help=False,
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ prog="coltype",
+ description="Change the data type of a column.",
+ )
+ parser.add_argument(
+ "column",
+ type=str,
+ help="Column name",
+ )
+ parser.add_argument(
+ "dtype",
+ type=str,
+ help="New data type (any valid pandas dtype: int64, float32, str, bool, datetime64[ns], category, etc.)",
+ )
+ parser.add_argument(
+ "--categories",
+ type=str,
+ nargs="+",
+ dest="categories",
+ help="For category type: list of valid categories (space-separated)",
+ )
+ parser.add_argument(
+ "--ordered",
+ action="store_true",
+ dest="ordered",
+ help="For category type: whether categories are ordered",
+ )
+ ns_parser = self.parse_known_args_and_warn(parser, other_args)
+ if ns_parser:
+ if self.current_table is None:
+ session.console.print(
+ "[yellow]No table selected. Use 'select' command first.[/yellow]"
+ )
+ return
+
+ result = session.obbject_registry.get(self.current_table)
+ if result is None:
+ return
+ df = extract_dataframe(result)
+
+ try:
+ if "datetime" in ns_parser.dtype.lower():
+ df[ns_parser.column] = pd.to_datetime(df[ns_parser.column])
+ elif ns_parser.dtype.lower() == "category":
+ if ns_parser.categories:
+ df[ns_parser.column] = pd.Categorical(
+ df[ns_parser.column],
+ categories=ns_parser.categories,
+ ordered=ns_parser.ordered,
+ )
+ else:
+ df[ns_parser.column] = df[ns_parser.column].astype("category")
+ else:
+ df[ns_parser.column] = df[ns_parser.column].astype(ns_parser.dtype)
+
+ result.results = df
+ session.console.print(
+ f"[green]Changed column '{ns_parser.column}' to type '{df[ns_parser.column].dtype}'[/green]"
+ )
+ except Exception as e:
+ session.console.print(f"[red]Error changing type: {str(e)}[/red]")
+
+ def call_addcol(self, other_args: list[str]):
+ """Add new column with expression."""
+ parser = argparse.ArgumentParser(
+ add_help=False,
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ prog="addcol",
+ description="Add a new column to the current table using an expression.",
+ )
+ parser.add_argument(
+ "name",
+ type=str,
+ help="Name of the new column",
+ )
+ parser.add_argument(
+ "expression",
+ type=str,
+ nargs="+",
+ help="Expression to create the column (e.g., 'col1 + col2')",
+ )
+ ns_parser = self.parse_known_args_and_warn(parser, other_args)
+ if ns_parser:
+ if self.current_table is None:
+ session.console.print(
+ "[yellow]No table selected. Use 'select' command first.[/yellow]"
+ )
+ return
+
+ result = session.obbject_registry.get(self.current_table)
+ if result is None:
+ return
+ df = result.to_dataframe()
+
+ expr_str = " ".join(ns_parser.expression)
+
+ try:
+ df[ns_parser.name] = df.eval(expr_str)
+ result.results = df
+ session.console.print(
+ f"[green]Added column '{ns_parser.name}' with expression: {expr_str}[/green]"
+ )
+ self.update_completer(self.choices_default)
+ except Exception as e:
+ session.console.print(f"[red]Error adding column: {str(e)}[/red]")
+
+ def call_dropcol(self, other_args: list[str]):
+ """Drop column(s) from current table."""
+ parser = argparse.ArgumentParser(
+ add_help=False,
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ prog="dropcol",
+ description="Drop one or more columns from the current table.",
+ )
+ parser.add_argument(
+ "columns",
+ type=str,
+ nargs="+",
+ help="Column name(s) to drop",
+ )
+ ns_parser = self.parse_known_args_and_warn(parser, other_args)
+ if ns_parser:
+ if self.current_table is None:
+ session.console.print(
+ "[yellow]No table selected. Use 'select' command first.[/yellow]"
+ )
+ return
+
+ result = session.obbject_registry.get(self.current_table)
+ if result is None:
+ return
+ df = extract_dataframe(result)
+
+ try:
+ df = df.drop(columns=ns_parser.columns)
+ result.results = df
+ session.console.print(
+ f"[green]Dropped column(s): {', '.join(ns_parser.columns)}[/green]"
+ )
+ self.update_completer(self.choices_default)
+ except Exception as e:
+ session.console.print(f"[red]Error dropping column: {str(e)}[/red]")
+
+ def call_renamecol(self, other_args: list[str]):
+ """Rename a column."""
+ parser = argparse.ArgumentParser(
+ add_help=False,
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ prog="renamecol",
+ description="Rename a column in the current table.",
+ )
+ parser.add_argument(
+ "old_name",
+ type=str,
+ help="Current column name",
+ )
+ parser.add_argument(
+ "new_name",
+ type=str,
+ help="New column name",
+ )
+ ns_parser = self.parse_known_args_and_warn(parser, other_args)
+ if ns_parser:
+ if self.current_table is None:
+ session.console.print(
+ "[yellow]No table selected. Use 'select' command first.[/yellow]"
+ )
+ return
+
+ result = session.obbject_registry.get(self.current_table)
+ if result is None:
+ return
+ df = extract_dataframe(result)
+
+ try:
+ df = df.rename(columns={ns_parser.old_name: ns_parser.new_name})
+ result.results = df
+ session.console.print(
+ f"[green]Renamed column '{ns_parser.old_name}' to '{ns_parser.new_name}'[/green]"
+ )
+ self.update_completer(self.choices_default)
+ except Exception as e:
+ session.console.print(f"[red]Error renaming column: {str(e)}[/red]")
+
+ def call_modifycol(self, other_args: list[str]):
+ """Modify column with expression."""
+ parser = argparse.ArgumentParser(
+ add_help=False,
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ prog="modifycol",
+ description="Modify an existing column using an expression.",
+ )
+ parser.add_argument(
+ "name",
+ type=str,
+ help="Name of the column to modify",
+ )
+ parser.add_argument(
+ "expression",
+ type=str,
+ nargs="+",
+ help="Expression to modify the column (e.g., 'col.astype(str)')",
+ )
+ ns_parser = self.parse_known_args_and_warn(parser, other_args)
+ if ns_parser:
+ if self.current_table is None:
+ session.console.print(
+ "[yellow]No table selected. Use 'select' command first.[/yellow]"
+ )
+ return
+
+ result = session.obbject_registry.get(self.current_table)
+ if result is None:
+ return
+ df = extract_dataframe(result)
+
+ expr_str = " ".join(ns_parser.expression)
+
+ try:
+ if "." in expr_str and any(
+ method in expr_str
+ for method in ["astype", "str", "dt", "fillna", "replace"]
+ ):
+ df[ns_parser.name] = eval( # noqa: S307
+ f"df['{ns_parser.name}'].{expr_str}", {"df": df}
+ )
+ else:
+ df[ns_parser.name] = df.eval(expr_str)
+ result.results = df
+ session.console.print(
+ f"[green]Modified column '{ns_parser.name}' with expression: {expr_str}[/green]"
+ )
+ except Exception as e:
+ session.console.print(f"[red]Error modifying column: {str(e)}[/red]")
+
+ def call_join(self, other_args: list[str]):
+ """Join current table with another table."""
+ parser = argparse.ArgumentParser(
+ add_help=False,
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ prog="join",
+ description="Join the current table with another table from cache.",
+ )
+ parser.add_argument(
+ "table",
+ type=str,
+ help="Index or name of the table to join with",
+ )
+ parser.add_argument(
+ "-o",
+ "--on",
+ type=str,
+ dest="on",
+ help="Column name(s) to join on (comma-separated)",
+ )
+ parser.add_argument(
+ "-l",
+ "--left-on",
+ type=str,
+ dest="left_on",
+ help="Column name(s) in left table",
+ )
+ parser.add_argument(
+ "-r",
+ "--right-on",
+ type=str,
+ dest="right_on",
+ help="Column name(s) in right table",
+ )
+ parser.add_argument(
+ "-t",
+ "--type",
+ type=str,
+ dest="how",
+ default="inner",
+ choices=["inner", "left", "right", "outer"],
+ help="Type of join",
+ )
+ parser.add_argument(
+ "-s",
+ "--save",
+ action="store_true",
+ help="Save result to current table",
+ )
+ ns_parser = self.parse_known_args_and_warn(parser, other_args)
+ if ns_parser:
+ if self.current_table is None:
+ session.console.print(
+ "[yellow]No table selected. Use 'select' command first.[/yellow]"
+ )
+ return
+
+ table_idx = self._resolve_table_identifier(ns_parser.table)
+
+ if table_idx is None:
+ session.console.print(
+ f"[red]Table '{ns_parser.table}' not found in cache.[/red]"
+ )
+ return
+
+ if table_idx not in session.obbject_registry.all:
+ session.console.print(
+ f"[red]Table '{table_idx}' not found in cache.[/red]"
+ )
+ return
+
+ result_left = session.obbject_registry.get(self.current_table)
+ result_right = session.obbject_registry.get(table_idx)
+ if result_left is None or result_right is None:
+ return
+ df_left = extract_dataframe(result_left)
+ df_right = extract_dataframe(result_right)
+
+ try:
+ if ns_parser.on:
+ on_cols = [c.strip() for c in ns_parser.on.split(",")]
+ merged_df = pd.merge(
+ df_left, df_right, on=on_cols, how=ns_parser.how
+ )
+ elif ns_parser.left_on and ns_parser.right_on:
+ left_cols = [c.strip() for c in ns_parser.left_on.split(",")]
+ right_cols = [c.strip() for c in ns_parser.right_on.split(",")]
+ merged_df = pd.merge(
+ df_left,
+ df_right,
+ left_on=left_cols,
+ right_on=right_cols,
+ how=ns_parser.how,
+ )
+ else:
+ merged_df = pd.merge(
+ df_left,
+ df_right,
+ left_index=True,
+ right_index=True,
+ how=ns_parser.how,
+ )
+
+ session.console.print(
+ f"[green]Join result: {len(merged_df)} rows, {len(merged_df.columns)} columns[/green]"
+ )
+
+ if ns_parser.save:
+ result_left.results = merged_df
+ session.console.print(
+ "[green]Table updated with join result.[/green]"
+ )
+ else:
+ session.output_adapter.display(
+ data=merged_df.head(20),
+ title="Join Result (first 20 rows)",
+ export=False,
+ chart=False,
+ )
+
+ except Exception as e:
+ session.console.print(f"[red]Join error: {str(e)}[/red]")
+
+ def call_copy(self, other_args: list[str]):
+ """Copy current table to new name."""
+ parser = argparse.ArgumentParser(
+ add_help=False,
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ prog="copy",
+ description="Copy the current table to a new index in cache.",
+ )
+ parser.add_argument(
+ "name",
+ type=str,
+ help="Name/index for the new copy",
+ )
+ ns_parser = self.parse_known_args_and_warn(parser, other_args)
+ if ns_parser:
+ if self.current_table is None:
+ session.console.print(
+ "[yellow]No table selected. Use 'select' command first.[/yellow]"
+ )
+ return
+
+ result = session.obbject_registry.get(self.current_table)
+ if result is None:
+ return
+ df = extract_dataframe(result)
+
+ from copy import deepcopy
+
+ new_result = deepcopy(result)
+ new_result.results = df.copy()
+ import uuid
+
+ new_result.id = str(uuid.uuid4())
+ new_result.extra["command"] = f"copy from table {self.current_table}"
+ new_result.extra["register_key"] = ns_parser.name
+
+ if session.obbject_registry.register(new_result):
+ session.console.print(
+ f"[green]Copied table '{self.current_table}' to '{ns_parser.name}'[/green]"
+ )
+ else:
+ session.console.print("[red]Failed to register copied table.[/red]")
+ self.update_completer(self.choices_default)
+
+ def call_save(self, other_args: list[str]):
+ """Save current table to file."""
+ parser = argparse.ArgumentParser(
+ add_help=False,
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ prog="save",
+ description="Save the current table to a file.",
+ )
+ parser.add_argument(
+ "filename",
+ type=str,
+ help="Filename to save (with extension: .csv, .json, .xlsx, .db, .sqlite)",
+ )
+ parser.add_argument(
+ "-i",
+ "--index",
+ action="store_true",
+ help="Include index in output",
+ )
+ parser.add_argument(
+ "-t",
+ "--table",
+ type=str,
+ default=None,
+ help="Table name for SQLite databases (defaults to 'data')",
+ )
+ parser.add_argument(
+ "-m",
+ "--mode",
+ type=str,
+ choices=["replace", "append", "fail"],
+ default="replace",
+ help="Write mode: 'replace' (overwrite), 'append' (add rows), 'fail' (error if exists)",
+ )
+ parser.add_argument(
+ "-s",
+ "--sheet-name",
+ type=str,
+ default=None,
+ help="Sheet name for Excel files (defaults to 'Sheet1')",
+ )
+ ns_parser = self.parse_known_args_and_warn(parser, other_args)
+ if ns_parser:
+ if self.current_table is None:
+ session.console.print(
+ "[yellow]No table selected. Use 'select' command first.[/yellow]"
+ )
+ return
+
+ result = session.obbject_registry.get(self.current_table)
+ if result is None:
+ return
+ df = extract_dataframe(result)
+
+ from pathlib import Path
+
+ file_path = (
+ Path(session.user.preferences.data_directory) / ns_parser.filename
+ )
+
+ try:
+ if ns_parser.filename.endswith(".csv"):
+ df.to_csv(file_path, index=ns_parser.index)
+ session.console.print(f"[green]Saved table to {file_path}[/green]")
+ elif ns_parser.filename.endswith(".json"):
+ df.to_json(file_path, orient="records", indent=2)
+ session.console.print(f"[green]Saved table to {file_path}[/green]")
+ elif ns_parser.filename.endswith((".xlsx", ".xls")):
+ from openbb_cli.controllers.utils import save_to_excel
+
+ sheet_name = (
+ ns_parser.sheet_name if ns_parser.sheet_name else "Sheet1"
+ )
+ save_to_excel(
+ df=df,
+ saved_path=file_path,
+ sheet_name=sheet_name,
+ index=ns_parser.index,
+ )
+ session.console.print(
+ f"[green]Saved sheet '{sheet_name}' to {file_path}[/green]"
+ )
+ elif ns_parser.filename.endswith((".db", ".sqlite", ".sqlite3")):
+ import sqlite3
+
+ table_name = ns_parser.table if ns_parser.table else "data"
+
+ file_exists = file_path.exists()
+
+ conn = sqlite3.connect(file_path)
+ try:
+ cursor = conn.cursor()
+ cursor.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
+ (table_name,),
+ )
+ table_exists = cursor.fetchone() is not None
+
+ if table_exists:
+ if ns_parser.mode == "fail":
+ session.console.print(
+ f"[red]Table '{table_name}' already exists. Use --mode replace or --mode append[/red]"
+ )
+ return
+ elif ns_parser.mode == "replace":
+ session.console.print(
+ f"[yellow]Table '{table_name}' exists. Replacing...[/yellow]"
+ )
+ elif ns_parser.mode == "append":
+ quoted_tbl = '"' + table_name.replace('"', '""') + '"'
+ cursor.execute(f"SELECT COUNT(*) FROM {quoted_tbl}") # noqa: S608
+ old_count = cursor.fetchone()[0]
+ session.console.print(
+ f"[cyan]Appending {len(df)} rows to existing {old_count} rows...[/cyan]"
+ )
+
+ df.to_sql(
+ table_name,
+ conn,
+ if_exists=ns_parser.mode,
+ index=ns_parser.index,
+ )
+
+ if table_exists and ns_parser.mode == "append":
+ cursor.execute(f"SELECT COUNT(*) FROM {quoted_tbl}") # noqa: S608
+ new_count = cursor.fetchone()[0]
+ session.console.print(
+ f"[green]Appended {len(df)} rows. Table '{table_name}' now has {new_count} rows[/green]"
+ )
+ elif file_exists:
+ session.console.print(
+ f"[green]Saved table '{table_name}' ({len(df)} rows)"
+ f" to existing database {file_path}[/green]"
+ )
+ else:
+ session.console.print(
+ f"[green]Created new database and saved table"
+ f" '{table_name}' ({len(df)} rows) to {file_path}[/green]"
+ )
+ finally:
+ conn.close()
+ else:
+ session.console.print(
+ "[red]Unsupported file format. Use .csv, .json, .xlsx, .db, .sqlite, or .sqlite3[/red]"
+ )
+ return
+ except Exception as e:
+ session.console.print(f"[red]Error saving file: {str(e)}[/red]")
+
+ def call_delete(self, other_args: list[str]):
+ """Delete table from cache."""
+ parser = argparse.ArgumentParser(
+ add_help=False,
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ prog="delete",
+ description="Delete a table from the cache.",
+ )
+ parser.add_argument(
+ "index",
+ type=str,
+ help="Index or name of the table to delete",
+ )
+ ns_parser = self.parse_known_args_and_warn(parser, other_args)
+ if ns_parser:
+ table_idx = self._resolve_table_identifier(ns_parser.index)
+
+ if table_idx is None:
+ session.console.print(
+ f"[red]Table '{ns_parser.index}' not found in cache.[/red]"
+ )
+ return
+
+ if table_idx in session.obbject_registry.all:
+ session.obbject_registry.remove(table_idx)
+ if self.current_table == table_idx:
+ self.current_table = None
+ session.console.print(f"[green]Deleted table '{table_idx}'[/green]")
+ self.update_completer(self.choices_default)
+ else:
+ session.console.print(
+ f"[red]Table '{ns_parser.index}' not found in cache.[/red]"
+ )
diff --git a/cli/openbb_cli/controllers/platform_controller_factory.py b/cli/openbb_cli/controllers/platform_controller_factory.py
index c68ed48a1fa..1f9418ee0cc 100644
--- a/cli/openbb_cli/controllers/platform_controller_factory.py
+++ b/cli/openbb_cli/controllers/platform_controller_factory.py
@@ -1,56 +1,82 @@
-"""Platform controller factory to create a platform controller."""
+"""Platform controller factory."""
-from openbb_cli.argparse_translator.argparse_class_processor import (
- ArgparseClassProcessor,
-)
+from __future__ import annotations
+
+from typing import Any
+
+from openbb_cli.backend import Backend, LocalBackend
from openbb_cli.controllers.base_platform_controller import PlatformController
class PlatformControllerFactory:
- """Factory to create a platform controller."""
+ """Factory to create a platform controller from a ``Backend``."""
- def __init__(self, platform_router: type, **kwargs):
- """Create the controller name."""
- self.platform_router = platform_router
- self._translated_target = ArgparseClassProcessor(
- target_class=self.platform_router, reference=kwargs.get("reference", {})
+ def __init__(
+ self,
+ platform_router: type | None = None,
+ *,
+ backend: Backend | None = None,
+ router_name: str | None = None,
+ reference: dict[str, Any] | None = None,
+ ) -> None:
+ if backend is None and platform_router is None:
+ raise ValueError("Either ``backend`` or ``platform_router`` is required.")
+
+ if backend is not None:
+ if router_name is None:
+ raise ValueError("``router_name`` is required when ``backend`` is set.")
+ self._backend: Backend = backend
+ self._router_name: str = router_name
+ else:
+ del reference
+ assert platform_router is not None # noqa: S101
+ self._backend = LocalBackend()
+ self._router_name = _derive_router_name(platform_router)
+
+ self._translators, self._paths = self._backend.get_translators_for_path(
+ self._router_name
)
- self.router_name = (
- str(type(self.platform_router))
- .rsplit(".", maxsplit=1)[-1]
- .replace("'>", "")
- .replace("ROUTER_", "")
- .lower()
- )
- self.controller_name = f"{self.router_name.capitalize()}Controller"
+
+ @property
+ def router_name(self) -> str:
+ return self._router_name
+
+ @property
+ def controller_name(self) -> str:
+ return f"{self._router_name.capitalize()}Controller"
def create(self) -> type:
- """Create the platform controller."""
- ClassName = self.controller_name
- Parents = (PlatformController,)
- Attributes: dict[str, bool | list[str]] = {"CHOICES_GENERATION": True}
-
- # Menu and Command choices generation
+ """Create the platform controller class for this router."""
choices_menus: list[str] = []
choices_commands: list[str] = []
- translators = self._translated_target.translators
- paths = self._translated_target.paths
- # menus
- for key, value in paths.items():
+ for key, value in self._paths.items():
if value == "path":
continue
choices_menus.append(key)
- # commands
- for name, _ in translators.items():
- if any(f"{self.router_name}_{path}" in name for path in paths):
+ for name in self._translators:
+ if any(
+ f"{self._router_name}_{path}_" in f"{name}_" for path in self._paths
+ ):
continue
- new_name = name.replace(f"{self.router_name}_", "")
- choices_commands.append(new_name)
+ choices_commands.append(name.replace(f"{self._router_name}_", ""))
- Attributes["CHOICES_MENUS"] = choices_menus
- Attributes["CHOICES_COMMANDS"] = choices_commands
+ attributes: dict[str, Any] = {
+ "CHOICES_GENERATION": True,
+ "CHOICES_MENUS": choices_menus,
+ "CHOICES_COMMANDS": choices_commands,
+ "_factory_backend": self._backend,
+ "_factory_translators": self._translators,
+ "_factory_paths": self._paths,
+ }
+ return type(self.controller_name, (PlatformController,), attributes)
- # Use type to create the class
- DynamicClass = type(ClassName, Parents, Attributes)
- return DynamicClass
+def _derive_router_name(platform_router: type) -> str:
+ """Replicate the legacy class-name โ router-name derivation."""
+ return (
+ str(type(platform_router))
+ .rsplit(".", maxsplit=1)[-1]
+ .replace("'>", "")
+ .replace("ROUTER_", "")
+ .lower()
+ )
diff --git a/cli/openbb_cli/controllers/script_parser.py b/cli/openbb_cli/controllers/script_parser.py
index 2d6eefdd404..c92c6abcf09 100644
--- a/cli/openbb_cli/controllers/script_parser.py
+++ b/cli/openbb_cli/controllers/script_parser.py
@@ -5,14 +5,12 @@ from datetime import datetime, timedelta
from re import Match
from dateutil.relativedelta import relativedelta
+
from openbb_cli.session import Session
session = Session()
-# pylint: disable=too-many-statements,consider-iterating-dictionary
-# pylint: disable=too-many-branches,too-many-return-statements
-# Necessary for OpenBB keywords
MONTHS_VALUE = {
"JANUARY": 1,
"FEBRUARY": 2,
@@ -45,12 +43,12 @@ def is_reset(command: str) -> bool:
Parameters
----------
command : str
- The command to test
+ The command to test.
Returns
-------
- answer : bool
- Whether the command is a reset command
+ bool
+ Whether the command is a reset command.
"""
if "reset" in command:
return True
@@ -63,11 +61,12 @@ def match_and_return_openbb_keyword_date(keyword: str) -> str: # noqa: PLR0911
Parameters
----------
keyword : str
- String with potential OpenBB keyword (e.g. 1MONTHAGO,LASTFRIDAY,3YEARSFROMNOW,NEXTTUESDAY)
+ String with potential OpenBB keyword.
Returns
- ----------
- str: Date with format YYYY-MM-DD
+ -------
+ str
+ Date with format YYYY-MM-DD.
"""
now = datetime.now()
for i, regex in enumerate([r"^\$(\d+)([A-Z]+)AGO$", r"^\$(\d+)([A-Z]+)FROMNOW$"]):
@@ -79,22 +78,18 @@ def match_and_return_openbb_keyword_date(keyword: str) -> str: # noqa: PLR0911
if "DAYS" in clean_time or "MONTHS" in clean_time or "YEARS" in clean_time:
kwargs = {time_unit.lower(): integer_value}
if i == 0:
- return (now - relativedelta(**kwargs)).strftime("%Y-%m-%d") # type: ignore
- return (now + relativedelta(**kwargs)).strftime("%Y-%m-%d") # type: ignore
+ return (now - relativedelta(**kwargs)).strftime("%Y-%m-%d") # ty: ignore[invalid-argument-type]
+ return (now + relativedelta(**kwargs)).strftime("%Y-%m-%d") # ty: ignore[invalid-argument-type]
match = re.search(r"\$LAST(\w+)", keyword)
if match:
time_unit = match.group(1)
- # Check if it corresponds to a month
if time_unit in list(MONTHS_VALUE.keys()):
the_year = now.year
- # Calculate the year and month for last month date
if now.month <= MONTHS_VALUE[time_unit]:
- # If the current month is greater than the last date month, it means it is this year
the_year = now.year - 1
return datetime(the_year, MONTHS_VALUE[time_unit], 1).strftime("%Y-%m-%d")
- # Check if it corresponds to a week day
if time_unit in list(WEEKDAY_VALUE.keys()):
if datetime.weekday(now) > WEEKDAY_VALUE[time_unit]:
return (
@@ -112,11 +107,8 @@ def match_and_return_openbb_keyword_date(keyword: str) -> str: # noqa: PLR0911
match = re.search(r"\$NEXT(\w+)", keyword)
if match:
time_unit = match.group(1)
- # Check if it corresponds to a month
if time_unit in list(MONTHS_VALUE.keys()):
- # Calculate the year and month for next month date
if now.month < MONTHS_VALUE[time_unit]:
- # If the current month is greater than the last date month, it means it is this year
return datetime(now.year, MONTHS_VALUE[time_unit], 1).strftime(
"%Y-%m-%d"
)
@@ -125,7 +117,6 @@ def match_and_return_openbb_keyword_date(keyword: str) -> str: # noqa: PLR0911
"%Y-%m-%d"
)
- # Check if it corresponds to a week day
if time_unit in list(WEEKDAY_VALUE.keys()):
if datetime.weekday(now) < WEEKDAY_VALUE[time_unit]:
return (
@@ -151,35 +142,28 @@ def parse_openbb_script( # noqa: PLR0911,PLR0912
Parameters
----------
- raw_lines : List[str]
- Lines from .openbb script
- script_inputs: str, optional
- Inputs to the script that come externally
+ raw_lines : list[str]
+ Lines from .openbb script.
+ script_inputs : list[str] | None
+ Inputs to the script that come externally.
Returns
-------
- str
- Error that occurred - if empty means no error
- str
- Processed string from .openbb script that can be run by the OpenBB Platform CLI
+ tuple[str, str]
+ Error that occurred (empty means no error) and the processed script string.
"""
ROUTINE_VARS: dict[str, str | list[str]] = dict()
if script_inputs:
ROUTINE_VARS["$ARGV"] = script_inputs
- ## PRE PROCESSING
- # Remove reset commands, comments, empty lines and trailing/leading whitespaces
raw_lines = [
x.strip()
for x in raw_lines
if (not is_reset(x)) and ("#" not in x) and x.strip()
]
- ## LOOK FOR NEW VARIABLES BEING DECLARED FROM USERS
lines_without_declarations = list()
for line in raw_lines:
- # Check if this line has a variable attribution
- # This currently allows user to override ARGV parameter
if "$" in line and "=" in line:
match = re.search(r"\$(\w+)\s*=\s*([\w\d,-.\s]+)", line)
if match:
@@ -189,7 +173,6 @@ def parse_openbb_script( # noqa: PLR0911,PLR0912
VAR_VALUES if "," not in VAR_VALUES else VAR_VALUES.split(",")
)
- # Just throw a warning when user uses wrong convention
numdollars = len(re.findall(r"\$", line))
if numdollars > 1:
session.console.print(
@@ -203,19 +186,12 @@ def parse_openbb_script( # noqa: PLR0911,PLR0912
else:
lines_without_declarations.append(line)
- # At this stage our ROUTINE_VARS should be completed coming from external AND from internal
- # Now we want to replace the ROUTINE_VARS to where applicable throughout the .openbb script
- # Due to this implementation, a variable declared at the end will still be effective
-
lines_with_vars_replaced = list()
foreach_loop_found = False
for line in lines_without_declarations:
- # Save temporary line to ensure that all vars get replaced by correct vars
templine = line
- # Found 'end' keyword which means that a loop has terminated
if re.match(r"^\s*end\s*$", line, re.IGNORECASE):
- # Check whether the foreach loop has started or not
if not foreach_loop_found:
return (
"[red]The script has a foreach loop that terminates before it gets started. "
@@ -225,14 +201,11 @@ def parse_openbb_script( # noqa: PLR0911,PLR0912
foreach_loop_found = False
else:
- # Found 'foreach' keyword which means there needs to be a matching 'end'
if re.search(r"foreach", line, re.IGNORECASE):
foreach_loop_found = True
- # Regular expression pattern to match variables starting with $
pattern = r"(? None:
+ """Toggle a boolean preference."""
+ field_name = field["field_name"]
+ parser = argparse.ArgumentParser(
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ prog=field["command"],
+ description=field["description"],
+ add_help=False,
+ )
+ ns_parser, _ = self.parse_simple_args(parser, other_args)
+ if ns_parser:
+ current = getattr(obb.user.preferences, field_name)
+ setattr(obb.user.preferences, field_name, not current)
+ session.console.print(f"[info]{field_name}:[/info] {not current}")
+
+ def _set(self, other_args: list[str], field=field) -> None:
+ """Set a preference value."""
+ field_name = field["field_name"]
+ annotation = field["annotation"]
+ command = field["command"]
+
+ type_: type = str
+ choices = None
+ if get_origin(annotation) is Literal:
+ choices = list(get_args(annotation))
+ type_ = str
+ elif annotation is int or (
+ get_origin(annotation) is Annotated
+ and get_args(annotation)
+ and get_args(annotation)[0] is int
+ ):
+ type_ = int
+ elif annotation is not str:
+ type_ = str
+
+ parser = argparse.ArgumentParser(
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ prog=command,
+ description=field["description"],
+ add_help=False,
+ )
+ parser.add_argument(
+ "-v",
+ "--value",
+ dest="value",
+ action="store",
+ required=False,
+ type=type_,
+ choices=choices,
+ )
+ ns_parser, _ = self.parse_simple_args(parser, other_args)
+ if ns_parser:
+ if ns_parser.value is not None:
+ setattr(obb.user.preferences, field_name, ns_parser.value)
+ session.console.print(
+ f"[info]{field_name}:[/info] {getattr(obb.user.preferences, field_name)}"
+ )
+ elif not other_args:
+ current = getattr(obb.user.preferences, field_name)
+ session.console.print(f"[info]{field_name}:[/info] {current}")
+
+ if action_type == "toggle":
+ action = _toggle
+ elif action_type == "set":
+ action = _set
+ else:
+ raise ValueError(f"Action type '{action_type}' not allowed.")
+
+ bound_method = update_wrapper(
+ wrapper=partial(MethodType(action, self), field=field), wrapped=action
+ )
+ setattr(self, f"call_{cmd_name}", bound_method)
diff --git a/cli/openbb_cli/controllers/utils.py b/cli/openbb_cli/controllers/utils.py
index 1bec8bad1fe..32a2d64ac8c 100644
--- a/cli/openbb_cli/controllers/utils.py
+++ b/cli/openbb_cli/controllers/utils.py
@@ -6,6 +6,7 @@ import os
import random
import re
import shutil
+import sqlite3
import sys
from contextlib import contextmanager
from datetime import (
@@ -13,23 +14,152 @@ from datetime import (
)
from pathlib import Path
from typing import TYPE_CHECKING, Optional
+from zoneinfo import ZoneInfo, available_timezones
import numpy as np
import pandas as pd
import requests
-from openbb_cli.config.constants import AVAILABLE_FLAIRS, ENV_FILE_SETTINGS
-from openbb_cli.session import Session
from openbb_core.app.model.obbject import OBBject
-from pytz import all_timezones, timezone
+
+all_timezones = available_timezones()
from rich.table import Table
+from openbb_cli.config.constants import AVAILABLE_FLAIRS, ENV_FILE_SETTINGS
+from openbb_cli.session import Session
+
if TYPE_CHECKING:
- from openbb_charting.core.openbb_figure import OpenBBFigure
-
-# pylint: disable=R1702,R0912
+ from openbb_charting.core.openbb_figure import ( # ty: ignore[unresolved-import]
+ OpenBBFigure,
+ )
-# pylint: disable=too-many-statements,no-member,too-many-branches,C0302
+class SQLiteTable:
+ """Lazy-loading wrapper for SQLite tables."""
+
+ def __init__(self, db_path: str, table_name: str, row_count: int = 0):
+ """Initialize SQLite table wrapper.
+
+ Parameters
+ ----------
+ db_path : str
+ Path to SQLite database file.
+ table_name : str
+ Name of the table.
+ row_count : int
+ Number of rows (from metadata).
+ """
+ self.db_path = db_path
+ self.table_name = table_name
+ self.row_count = row_count
+ self._cached_df: pd.DataFrame | None = None
+
+ @property
+ def _quoted_name(self) -> str:
+ """Return the table name quoted for safe SQL interpolation."""
+ return '"' + self.table_name.replace('"', '""') + '"'
+
+ def to_dataframe(self, use_cache: bool = True) -> pd.DataFrame:
+ """Load table data from SQLite database.
+
+ Parameters
+ ----------
+ use_cache : bool
+ If True, return cached DataFrame if available.
+
+ Returns
+ -------
+ pd.DataFrame
+ DataFrame containing table data.
+ """
+ if use_cache and self._cached_df is not None:
+ return self._cached_df
+
+ conn = sqlite3.connect(self.db_path)
+ try:
+ sql = f"SELECT * FROM {self._quoted_name}" # noqa: S608
+ df = pd.read_sql_query(sql, conn)
+ if use_cache:
+ self._cached_df = df
+ return df
+ finally:
+ conn.close()
+
+ def get_schema(self) -> list[tuple]:
+ """Get table schema (column names and types).
+
+ Returns
+ -------
+ list[tuple]
+ List of (column_name, type, notnull, default, pk) tuples.
+ """
+ conn = sqlite3.connect(self.db_path)
+ try:
+ cursor = conn.cursor()
+ cursor.execute(f"PRAGMA table_info({self._quoted_name})")
+ return cursor.fetchall()
+ finally:
+ conn.close()
+
+ def query(self, where: str = "", limit: int | None = None) -> pd.DataFrame:
+ """Execute SQL query with optional filters.
+
+ Parameters
+ ----------
+ where : str
+ SQL WHERE clause (without WHERE keyword).
+ limit : int | None
+ Maximum number of rows to return.
+
+ Returns
+ -------
+ pd.DataFrame
+ DataFrame with query results.
+ """
+ sql = f"SELECT * FROM {self._quoted_name}" # noqa: S608
+ if where:
+ sql += f" WHERE {where}"
+ if limit:
+ sql += f" LIMIT {limit}"
+
+ conn = sqlite3.connect(self.db_path)
+ try:
+ return pd.read_sql_query(sql, conn)
+ finally:
+ conn.close()
+
+
+def extract_dataframe(obbject) -> pd.DataFrame:
+ """Extract DataFrame from OBBject without using to_dataframe().
+
+ Parameters
+ ----------
+ obbject
+ OBBject instance or other data.
+
+ Returns
+ -------
+ pd.DataFrame
+ DataFrame extracted from results.
+ """
+ results = (
+ obbject.model_dump(exclude_unset=True, exclude_none=True).get("results")
+ if hasattr(obbject, "model_dump")
+ else obbject
+ )
+
+ if results is None:
+ return pd.DataFrame()
+ elif isinstance(results, SQLiteTable):
+ return results.to_dataframe()
+ elif isinstance(results, pd.DataFrame):
+ return results
+ elif isinstance(results, list):
+ return pd.DataFrame(results)
+ elif isinstance(results, dict):
+ return pd.DataFrame([results])
+ else:
+ return pd.DataFrame({"value": [results]})
+
session = Session()
@@ -47,7 +177,6 @@ def remove_file(path: Path) -> bool:
bool
The status of the removal.
"""
- # TODO: Check why module level import leads to circular import.
try:
if os.path.isfile(path):
os.remove(path)
@@ -79,52 +208,41 @@ Please feel free to check out our other products:
def bootup():
"""Bootup the cli."""
- if sys.platform == "win32":
- # Enable VT100 Escape Sequence for WINDOWS 10 Ver. 1607
- os.system("") # nosec # noqa: S605,S607
+ if sys.platform == "win32": # pragma: no cover
+ os.system("") # noqa: S605, S607
try:
- if os.name == "nt":
- # pylint: disable=E1101
- sys.stdin.reconfigure(encoding="utf-8") # type: ignore
- # pylint: disable=E1101
- sys.stdout.reconfigure(encoding="utf-8") # type: ignore
- except Exception as e:
+ if os.name == "nt": # pragma: no cover
+ sys.stdin.reconfigure(encoding="utf-8")
+ sys.stdout.reconfigure(encoding="utf-8")
+ except Exception as e: # pragma: no cover
session.console.print(e, "\n")
def welcome_message():
- """Print the welcome message.
-
- Prints first welcome message, help and a notification if updates are available.
- """
+ """Print the welcome message."""
session.console.print(
f"\nWelcome to OpenBB Platform CLI v{session.settings.VERSION}"
)
def reset(queue: list[str] | None = None):
- """Reset the CLI.
-
- Allows for checking code without quitting.
- """
+ """Reset the CLI."""
session.console.print("resetting...")
debug = session.settings.DEBUG_MODE
dev = session.settings.DEV_BACKEND
try:
- # we clear all openbb_cli modules from sys.modules
for module in list(sys.modules.keys()):
parts = module.split(".")
if parts[0] == "openbb_cli":
del sys.modules[module]
- queue_list = ["/".join(queue) if len(queue) > 0 else ""] # type: ignore
+ queue_list = ["/".join(queue) if len(queue) > 0 else ""] # ty: ignore[invalid-argument-type, no-matching-overload]
- # pylint: disable=import-outside-toplevel
from openbb_cli.controllers.cli_controller import main
- main(debug, dev, queue_list, module="") # type: ignore
+ main(debug, dev, queue_list, module="")
except Exception as e:
session.console.print(f"Unfortunately, resetting wasn't possible: {e}\n")
@@ -149,13 +267,10 @@ def suppress_stdout():
def first_time_user() -> bool:
"""Check whether a user is a first time user.
- A first time user is someone with an empty .env file.
- If this is true, it also adds an env variable to make sure this does not run again.
-
Returns
-------
bool
- Whether or not the user is a first time user
+ Whether or not the user is a first time user.
"""
if ENV_FILE_SETTINGS.stat().st_size == 0:
session.settings.set_item("PREVIOUS_USE", True)
@@ -166,33 +281,29 @@ def first_time_user() -> bool:
def parse_and_split_input(an_input: str, custom_filters: list) -> list[str]:
"""Filter and split the input queue.
- Uses regex to filters command arguments that have forward slashes so that it doesn't
- break the execution of the command queue.
- Currently handles unix paths and sorting settings for screener menus.
-
Parameters
----------
an_input : str
- User input as string
- custom_filters : List
- Additional regular expressions to match
+ User input as string.
+ custom_filters : list
+ Additional regular expressions to match.
Returns
-------
- List[str]
- Command queue as list
+ list[str]
+ Command queue as list.
"""
- # Make sure that the user can go back to the root when doing "/"
if an_input and an_input == "/":
an_input = "home"
- # everything from ` -f ` to the next known extension
file_flag = r"(\ -f |\ --file )"
up_to = r".*?"
- known_extensions = r"(\.(xlsx|csv|xls|tsv|json|yaml|ini|openbb|ipynb))"
- unix_path_arg_exp = f"({file_flag}{up_to}{known_extensions})"
+ known_extensions = (
+ r"(\.(xlsx|csv|xls|tsv|json|yaml|ini|openbb|ipynb|db|sqlite|sqlite3))"
+ )
+ optional_args = r"(?:\ [^/]+)*?"
+ unix_path_arg_exp = f"({file_flag}{up_to}{known_extensions}{optional_args})"
- # Add custom expressions to handle edge cases of individual controllers
custom_filter = ""
for exp in custom_filters:
if exp is not None:
@@ -207,9 +318,7 @@ def parse_and_split_input(an_input: str, custom_filters: list) -> list[str]:
match = re.search(pattern=slash_filter_exp, string=an_input)
if match is not None:
placeholder = f"{{placeholder{len(placeholders) + 1}}}"
- placeholders[placeholder] = an_input[
- match.span()[0] : match.span()[1]
- ] # noqa:E203
+ placeholders[placeholder] = an_input[match.span()[0] : match.span()[1]] # noqa:E203
an_input = (
an_input[: match.span()[0]] + placeholder + an_input[match.span()[1] :]
) # noqa:E203
@@ -225,28 +334,24 @@ def parse_and_split_input(an_input: str, custom_filters: list) -> list[str]:
if len(matching_placeholders) > 0:
for tag in matching_placeholders:
commands[command_num] = command.replace(tag, placeholders[tag])
- return commands
+ return list(filter(None, commands))
def return_colored_value(value: str):
"""Return the string value based on condition.
- Return it with green, yellow, red or white color based on
- whether the number is positive, negative, zero or other, respectively.
-
Parameters
----------
- value: str
- string to be checked
+ value : str
+ String to be checked.
Returns
-------
- value: str
- string with color based on value of number if it exists
+ str
+ String with color based on value of number if it exists.
"""
values = re.findall(r"[-+]?(?:\d*\.\d+|\d+)", value)
- # Finds exactly 1 number in the string
if len(values) == 1:
if float(values[0]) > 0:
return f"[green]{value}[/green]"
@@ -260,7 +365,6 @@ def return_colored_value(value: str):
return f"{value}"
-# pylint: disable=too-many-arguments,too-many-positional-arguments
def print_rich_table( # noqa: PLR0912
df: pd.DataFrame,
show_index: bool = False,
@@ -281,45 +385,39 @@ def print_rich_table( # noqa: PLR0912
Parameters
----------
- df: pd.DataFrame
- Dataframe to turn into table
- show_index: bool
- Whether to include index
- title: str
- Title for table
+ df : pd.DataFrame
+ Dataframe to turn into table.
+ show_index : bool
+ Whether to include index.
+ title : str
+ Title for table.
index_name : str
- Title for index column
- headers: List[str]
- Titles for columns
- floatfmt: Union[str, List[str]]
- Float number formatting specs as string or list of strings. Defaults to ".2f"
- show_header: bool
+ Title for index column.
+ headers : list[str] | pd.Index | None
+ Titles for columns.
+ floatfmt : str | list[str]
+ Float number formatting specs as string or list of strings.
+ show_header : bool
Whether to show the header row.
- automatic_coloring: bool
- Automatically color a table based on positive and negative values
- columns_to_auto_color: List[str]
- Columns to automatically color
- rows_to_auto_color: List[str]
- Rows to automatically color
- export: bool
- Whether we are exporting the table to a file. If so, we don't want to print it.
- limit: Optional[int]
+ automatic_coloring : bool
+ Automatically color a table based on positive and negative values.
+ columns_to_auto_color : list[str] | None
+ Columns to automatically color.
+ rows_to_auto_color : list[str] | None
+ Rows to automatically color.
+ export : bool
+ Whether we are exporting the table to a file.
+ limit : int | None
Limit the number of rows to show.
- columns_keep_types: Optional[List[str]]
- Columns to keep their types, i.e. not convert to numeric
+ columns_keep_types : list[str] | None
+ Columns to keep their types, i.e. not convert to numeric.
"""
if export:
return
- MAX_COLS = session.settings.ALLOWED_NUMBER_OF_COLUMNS
- MAX_ROWS = session.settings.ALLOWED_NUMBER_OF_ROWS
-
- # Make a copy of the dataframe to avoid SettingWithCopyWarning
df = df.copy()
show_index = not isinstance(df.index, pd.RangeIndex) and show_index
- # convert non-str that are not timestamp or int into str
- # eg) praw.models.reddit.subreddit.Subreddit
for col in df.columns:
if columns_keep_types is not None and col in columns_keep_types:
continue
@@ -339,17 +437,14 @@ def print_rich_table( # noqa: PLR0912
output = list(_headers)
if len(output) != len(df.columns):
raise ValueError("Length of headers does not match length of DataFrame.")
- return output # type: ignore
+ return output # ty: ignore[invalid-return-type]
- if session.settings.USE_INTERACTIVE_DF:
+ if session.settings.USE_INTERACTIVE_DF and session.backend is not None:
df_outgoing = df.copy()
- # If headers are provided, use them
if headers is not None:
- # We check if headers are valid
df_outgoing.columns = _get_headers(headers)
if show_index and index_name not in df_outgoing.columns:
- # If index name is provided, we use it
df_outgoing.index.name = index_name or "Index"
df_outgoing = df_outgoing.reset_index()
@@ -357,47 +452,31 @@ def print_rich_table( # noqa: PLR0912
if col == "":
df_outgoing = df_outgoing.rename(columns={col: " "})
- session._backend.send_table( # type: ignore # pylint: disable=protected-access
- df_table=df_outgoing,
- title=title,
- theme=session.user.preferences.table_style,
- )
- return
+ try:
+ session.backend.send_table(
+ df_table=df_outgoing,
+ title=title,
+ theme=session.user.preferences.table_style,
+ )
+ return
+ except Exception: # noqa: S110
+ pass
df = df.copy() if not limit else df.copy().iloc[:limit]
if automatic_coloring:
if columns_to_auto_color:
for col in columns_to_auto_color:
- # checks whether column exists
if col in df.columns:
df[col] = df[col].apply(lambda x: return_colored_value(str(x)))
if rows_to_auto_color:
for row in rows_to_auto_color:
- # checks whether row exists
if row in df.index:
df.loc[row] = df.loc[row].apply(
lambda x: return_colored_value(str(x))
)
if columns_to_auto_color is None and rows_to_auto_color is None:
- df = df.map(lambda x: return_colored_value(str(x))) # type: ignore
-
- exceeds_allowed_columns = len(df.columns) > MAX_COLS
- exceeds_allowed_rows = len(df) > MAX_ROWS
-
- if exceeds_allowed_columns:
- original_columns = df.columns.tolist()
- trimmed_columns = df.columns.tolist()[:MAX_COLS]
- df = df[trimmed_columns]
- trimmed_columns = [
- col for col in original_columns if col not in trimmed_columns
- ]
-
- if exceeds_allowed_rows:
- n_rows = len(df.index)
- max_rows = MAX_ROWS
- df = df[:max_rows]
- trimmed_rows_count = n_rows - max_rows
+ df = df.map(lambda x: return_colored_value(str(x)))
if use_tabulate_df:
table = Table(title=title, show_lines=True, show_header=show_header)
@@ -423,7 +502,6 @@ def print_rich_table( # noqa: PLR0912
floatfmt = [floatfmt for _ in range(len(df.columns))]
for idx, values in zip(df.index.tolist(), df.values.tolist()):
- # remove hour/min/sec from timestamp index - Format: YYYY-MM-DD # make better
row_idx = [str(idx)] if show_index else []
row_idx += [
(
@@ -446,23 +524,6 @@ def print_rich_table( # noqa: PLR0912
else:
session.console.print(df.to_string(col_space=0))
- if exceeds_allowed_columns:
- session.console.print(
- f"[yellow]\nAllowed number of columns exceeded ({session.settings.ALLOWED_NUMBER_OF_COLUMNS}).\n"
- f"The following columns were removed from the output: {', '.join(trimmed_columns)}.\n[/yellow]"
- )
-
- if exceeds_allowed_rows:
- session.console.print(
- f"[yellow]\nAllowed number of rows exceeded ({session.settings.ALLOWED_NUMBER_OF_ROWS}).\n"
- f"{trimmed_rows_count} rows were removed from the output.\n[/yellow]"
- )
-
- if exceeds_allowed_columns or exceeds_allowed_rows:
- session.console.print(
- "Use the `--export` flag to analyse the full output on a file."
- )
-
def check_non_negative(value) -> int:
"""Argparse type to check non negative int."""
@@ -489,6 +550,41 @@ def validate_register_key(value: str) -> str:
return str(value)
+def get_user_data_directory() -> Path:
+ """Get the OpenBBUserData directory path."""
+ return Path(session.user.preferences.data_directory)
+
+
+def get_data_files_for_completion() -> list[str]:
+ """Get list of data files in OpenBBUserData for tab completion.
+
+ Returns list of file paths relative to OpenBBUserData directory.
+ Includes CSV, JSON, and Excel files.
+ """
+ try:
+ user_data_dir = get_user_data_directory()
+ if not user_data_dir.exists():
+ return []
+
+ files = []
+ for file_path in user_data_dir.rglob("*"):
+ if file_path.is_file() and file_path.suffix.lower() in [
+ ".csv",
+ ".json",
+ ".xlsx",
+ ".xls",
+ ".db",
+ ".sqlite",
+ ".sqlite3",
+ ]:
+ rel_path = file_path.relative_to(user_data_dir)
+ files.append(str(rel_path).replace("\\", "/"))
+
+ return sorted(files)
+ except Exception:
+ return []
+
+
def get_user_agent() -> str:
"""Get a not very random user agent."""
user_agent_strings = [
@@ -501,7 +597,7 @@ def get_user_agent() -> str:
"Mozilla/5.0 (Windows NT 6.1; WOW64; rv:84.0) Gecko/20100101 Firefox/84.0",
]
- return random.choice(user_agent_strings) # nosec # noqa: S311
+ return random.choice(user_agent_strings) # noqa: S311
def get_flair() -> str:
@@ -515,7 +611,7 @@ def get_dtime() -> str:
"""Get a datetime string."""
dtime = ""
if session.settings.USE_DATETIME and get_user_timezone_or_invalid() != "INVALID":
- dtime = datetime.now(timezone(get_user_timezone())).strftime("%Y %b %d, %H:%M")
+ dtime = datetime.now(ZoneInfo(get_user_timezone())).strftime("%Y %b %d, %H:%M")
return dtime
@@ -533,13 +629,13 @@ def is_timezone_valid(user_tz: str) -> bool:
Parameters
----------
- user_tz: str
- Timezone to check for validity
+ user_tz : str
+ Timezone to check for validity.
Returns
-------
bool
- True if timezone provided is valid
+ True if timezone provided is valid.
"""
return user_tz in all_timezones
@@ -550,7 +646,7 @@ def get_user_timezone() -> str:
Returns
-------
str
- user timezone based on .env file
+ User timezone based on .env file.
"""
return session.settings.TIMEZONE
@@ -561,7 +657,7 @@ def get_user_timezone_or_invalid() -> str:
Returns
-------
str
- user timezone based on timezone.openbb file or INVALID
+ User timezone based on timezone.openbb file or INVALID.
"""
user_tz = get_user_timezone()
if is_timezone_valid(user_tz):
@@ -574,13 +670,13 @@ def check_file_type_saved(valid_types: list[str] | None = None):
Parameters
----------
- valid_types: List[str]
- List of valid types to export data
+ valid_types : list[str] | None
+ List of valid types to export data.
Returns
-------
- check_filenames: Optional[List[str]]
- Function that returns list of filenames to export data
+ Callable
+ Function that returns list of filenames to export data.
"""
def check_filenames(filenames: str = "") -> str:
@@ -588,13 +684,13 @@ def check_file_type_saved(valid_types: list[str] | None = None):
Parameters
----------
- filenames: str
- filenames to be saved separated with comma
+ filenames : str
+ Filenames to be saved separated with comma.
Returns
- ----------
+ -------
str
- valid filenames separated with comma
+ Valid filenames separated with comma.
"""
if not filenames or not valid_types:
return ""
@@ -618,21 +714,20 @@ def remove_timezone_from_dataframe(df: pd.DataFrame) -> pd.DataFrame:
Parameters
----------
df : pd.DataFrame
- The dataframe to remove timezone information from
+ The dataframe to remove timezone information from.
Returns
-------
pd.DataFrame
- The dataframe with timezone information removed
+ The dataframe with timezone information removed.
"""
date_cols = []
index_is_date = False
- # Find columns and index containing date data
if (
df.index.dtype.kind == "M"
and hasattr(df.index.dtype, "tz")
- and df.index.dtype.tz is not None # type: ignore
+ and df.index.dtype.tz is not None
):
index_is_date = True
@@ -640,13 +735,12 @@ def remove_timezone_from_dataframe(df: pd.DataFrame) -> pd.DataFrame:
if dtype.kind == "M" and hasattr(df.index.dtype, "tz") and dtype.tz is not None:
date_cols.append(col)
- # Remove the timezone information
for col in date_cols:
df[col] = df[col].dt.date
if index_is_date:
index_name = df.index.name
- df.index = df.index.date # type: ignore
+ df.index = df.index.date # ty: ignore[unresolved-attribute]
df.index.name = index_name
return df
@@ -655,25 +749,20 @@ def remove_timezone_from_dataframe(df: pd.DataFrame) -> pd.DataFrame:
def compose_export_path(func_name: str, dir_path: str) -> Path:
"""Compose export path for data from the terminal.
- Creates a path to a folder and a filename based on conditions.
-
Parameters
----------
func_name : str
- Name of the command that invokes this function
+ Name of the command that invokes this function.
dir_path : str
- Path of directory from where this function is called
+ Path of directory from where this function is called.
Returns
-------
Path
- Path variable containing the path of the exported file
+ Path variable containing the path of the exported file.
"""
now = datetime.now()
- # Resolving all symlinks and also normalizing path.
resolve_path = Path(dir_path).resolve()
- # Getting the directory names from the path. Instead of using split/replace (Windows doesn't like that)
- # check if this is done in a main context to avoid saving with openbb_cli
if resolve_path.parts[-2] == "openbb_cli":
path_cmd = f"{resolve_path.parts[-1]}"
else:
@@ -689,8 +778,10 @@ def compose_export_path(func_name: str, dir_path: str) -> Path:
def ask_file_overwrite(file_path: Path) -> tuple[bool, bool]:
"""Provide a prompt for overwriting existing files.
- Returns two values, the first is a boolean indicating if the file exists and the
- second is a boolean indicating if the user wants to overwrite the file.
+ Returns
+ -------
+ tuple[bool, bool]
+ Whether the file exists and whether the user wants to overwrite it.
"""
if session.settings.FILE_OVERWRITE:
return False, True
@@ -700,26 +791,28 @@ def ask_file_overwrite(file_path: Path) -> tuple[bool, bool]:
overwrite = input("\nFile already exists. Overwrite? [y/n]: ").lower()
if overwrite == "y":
file_path.unlink(missing_ok=True)
- # File exists and user wants to overwrite
return True, True
- # File exists and user does not want to overwrite
return True, False
- # File does not exist
return False, True
-# This is a false positive on pylint and being tracked in pylint #3060
-# pylint: disable=abstract-class-instantiated,too-many-positional-arguments
def save_to_excel(df, saved_path, sheet_name, start_row=0, index=True, header=True):
"""Save a Pandas DataFrame to an Excel file.
- Args:
- df: A Pandas DataFrame.
- saved_path: The path to the Excel file to save to.
- sheet_name: The name of the sheet to save the DataFrame to.
- start_row: The row number to start writing the DataFrame at.
- index: Whether to write the DataFrame index to the Excel file.
- header: Whether to write the DataFrame header to the Excel file.
+ Parameters
+ ----------
+ df
+ A Pandas DataFrame.
+ saved_path
+ The path to the Excel file to save to.
+ sheet_name
+ The name of the sheet to save the DataFrame to.
+ start_row
+ The row number to start writing the DataFrame at.
+ index
+ Whether to write the DataFrame index to the Excel file.
+ header
+ Whether to write the DataFrame header to the Excel file.
"""
overwrite_options = {
"o": "replace",
@@ -746,7 +839,7 @@ def save_to_excel(df, saved_path, sheet_name, start_row=0, index=True, header=Tr
with pd.ExcelWriter(
saved_path,
mode="a",
- if_sheet_exists=overwrite_options[overwrite_option], # type: ignore
+ if_sheet_exists=overwrite_options[overwrite_option], # ty: ignore[invalid-argument-type]
engine="openpyxl",
) as writer:
df.to_excel(
@@ -758,9 +851,7 @@ def save_to_excel(df, saved_path, sheet_name, start_row=0, index=True, header=Tr
)
-# This is a false positive on pylint and being tracked in pylint #3060
-# pylint: disable=abstract-class-instantiated,too-many-positional-arguments
-def export_data(
+def export_data( # noqa: PLR0912
export_type: str,
dir_path: str,
func_name: str,
@@ -774,17 +865,17 @@ def export_data(
Parameters
----------
export_type : str
- Type of export between: csv,json,xlsx,xls
+ Type of export between: csv, json, xlsx, xls.
dir_path : str
- Path of directory from where this function is called
+ Path of directory from where this function is called.
func_name : str
- Name of the command that invokes this function
- df : pd.Dataframe
- Dataframe of data to save
- sheet_name : str
- If provided. The name of the sheet to save in excel file
- figure : Optional[OpenBBFigure]
- Figure object to save as image file
+ Name of the command that invokes this function.
+ df : pd.DataFrame
+ Dataframe of data to save.
+ sheet_name : str | None
+ The name of the sheet to save in excel file.
+ figure : OpenBBFigure | None
+ Figure object to save as image file.
margin : bool
Automatically adjust subplot parameters to give specified padding.
"""
@@ -792,10 +883,8 @@ def export_data(
saved_path = compose_export_path(func_name, dir_path).resolve()
saved_path.parent.mkdir(parents=True, exist_ok=True)
for exp_type in export_type.split(","):
- # In this scenario the path was provided, e.g. --export pt.csv, pt.jpg
if "." in exp_type:
saved_path = saved_path.with_name(exp_type)
- # In this scenario we use the default filename
else:
if ".OpenBB_openbb_cli" in saved_path.name:
saved_path = saved_path.with_name(
@@ -832,7 +921,6 @@ def export_data(
df.reset_index(drop=True, inplace=True)
df.to_json(saved_path)
elif exp_type.endswith("xlsx"):
- # since xlsx does not support datetimes with timezones we need to remove it
df = remove_timezone_from_dataframe(df)
if sheet_name is None: # noqa: SIM223
@@ -849,6 +937,51 @@ def export_data(
session.console.print("No plot to export.")
continue
figure.show(export_image=saved_path, margin=margin)
+ elif saved_path.suffix in [".db", ".sqlite", ".sqlite3"]:
+ import sqlite3
+
+ table_name = sheet_name if sheet_name else "data"
+
+ conn = sqlite3.connect(saved_path)
+ try:
+ cursor = conn.cursor()
+ cursor.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
+ (table_name,),
+ )
+ table_exists = cursor.fetchone() is not None
+
+ if table_exists:
+ choice = input(
+ f"\nTable '{table_name}' exists. Overwrite/Append/New? [o/a/n]: "
+ ).lower()
+ if choice == "o":
+ df.to_sql(
+ table_name, conn, if_exists="replace", index=False
+ )
+ elif choice == "a":
+ df.to_sql(table_name, conn, if_exists="append", index=False)
+ elif choice == "n":
+ i = 1
+ new_name = f"{table_name}_{i}"
+ while True:
+ cursor.execute(
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
+ (new_name,),
+ )
+ if cursor.fetchone() is None:
+ break
+ i += 1
+ new_name = f"{table_name}_{i}"
+ df.to_sql(new_name, conn, if_exists="fail", index=False)
+ table_name = new_name
+ else:
+ session.console.print("Invalid choice. Skipping.")
+ continue
+ else:
+ df.to_sql(table_name, conn, if_exists="fail", index=False)
+ finally:
+ conn.close()
else:
session.console.print("Wrong export file specified.")
continue
@@ -859,15 +992,14 @@ def export_data(
session.console.print(f"Failed to save file: {saved_path}")
if figure is not None:
- figure._exported = True # pylint: disable=protected-access
+ figure._exported = True
def system_clear():
"""Clear screen."""
- os.system("cls||clear") # nosec # noqa: S605,S607
+ os.system("cls||clear") # noqa: S605, S607
-# Write an abstract helper to make requests from a url with potential headers and params
def request(
url: str, method: str = "get", timeout: int = 0, **kwargs
) -> requests.Response:
@@ -876,29 +1008,25 @@ def request(
Parameters
----------
url : str
- Url to make the request to
+ Url to make the request to.
method : str
- HTTP method to use. Choose from:
- delete, get, head, patch, post, put, by default "get"
+ HTTP method to use: delete, get, head, patch, post, put.
timeout : int
- How many seconds to wait for the server to send data
+ How many seconds to wait for the server to send data.
Returns
-------
requests.Response
- Request response object
+ Request response object.
Raises
------
ValueError
- If invalid method is passed
+ If invalid method is passed.
"""
method = method.lower()
if method not in ["delete", "get", "head", "patch", "post", "put"]:
raise ValueError(f"Invalid method: {method}")
- # We want to add a user agent to the request, so check if there are any headers
- # If there are headers, check if there is a user agent, if not add one.
- # Some requests seem to work only with a specific user agent, so we want to be able to override it.
headers = kwargs.pop("headers", {})
timeout = timeout or session.user.preferences.request_timeout
@@ -943,26 +1071,40 @@ def handle_obbject_display(
"""Handle the display of an OBBject."""
df: pd.DataFrame = pd.DataFrame()
fig: OpenBBFigure | None = None
+
+ if isinstance(getattr(obbject, "results", None), SQLiteTable):
+ sqlite_tbl: SQLiteTable = obbject.results # ty: ignore[invalid-assignment]
+ obbject.results = sqlite_tbl.to_dataframe()
+
if chart:
try:
if obbject.chart:
obbject.show(**kwargs)
else:
- obbject.charting.to_chart(**kwargs) # type: ignore
+ obbject.charting.to_chart(**kwargs) # ty: ignore[unresolved-attribute]
if export:
- fig = obbject.chart.fig # type: ignore
- df = obbject.to_dataframe()
+ fig = obbject.chart.fig # ty: ignore[unresolved-attribute]
+ df = extract_dataframe(obbject)
except Exception as e:
session.console.print(f"Failed to display chart: {e}")
elif session.settings.USE_INTERACTIVE_DF:
- obbject.charting.table() # type: ignore
+ try:
+ obbject.charting.table() # ty: ignore[unresolved-attribute]
+ except AttributeError:
+ df = extract_dataframe(obbject)
+ session.output_adapter.display(
+ data=df,
+ title=obbject.extra.get("command", ""),
+ export=bool(export),
+ chart=False,
+ )
else:
- df = obbject.to_dataframe()
- print_rich_table(
- df=df,
- show_index=True,
+ df = extract_dataframe(obbject)
+ session.output_adapter.display(
+ data=df,
title=obbject.extra.get("command", ""),
export=bool(export),
+ chart=False,
)
if export and not df.empty:
if sheet_name and isinstance(sheet_name, list):
diff --git a/cli/openbb_cli/dispatchers/__init__.py b/cli/openbb_cli/dispatchers/__init__.py
new file mode 100644
index 00000000000..612876a3e4f
--- /dev/null
+++ b/cli/openbb_cli/dispatchers/__init__.py
@@ -0,0 +1,18 @@
+"""Dispatcher subsystem for non-TTY operation."""
+
+from openbb_cli.dispatchers.base import Dispatcher
+from openbb_cli.dispatchers.http import HttpDispatcher
+from openbb_cli.dispatchers.local import LocalDispatcher
+from openbb_cli.dispatchers.protocol import Request, Response, ResponseError
+from openbb_cli.dispatchers.runtime import run_argv, run_batch
+
+__all__ = [
+ "Dispatcher",
+ "HttpDispatcher",
+ "LocalDispatcher",
+ "Request",
+ "Response",
+ "ResponseError",
+ "run_argv",
+ "run_batch",
+]
diff --git a/cli/openbb_cli/dispatchers/_unpack.py b/cli/openbb_cli/dispatchers/_unpack.py
new file mode 100644
index 00000000000..c7750615072
--- /dev/null
+++ b/cli/openbb_cli/dispatchers/_unpack.py
@@ -0,0 +1,47 @@
+"""Shared response unwrapping used by both the HTTP dispatcher and codegen."""
+
+from __future__ import annotations
+
+import json as _json
+import re as _re
+from typing import Any
+
+_BARE_ASTERISK = _re.compile(r":\s*\*\s*(?=[,}\]])")
+
+
+def safe_json_loads(text: str) -> Any:
+ """Parse JSON text, sanitizing common upstream quirks first."""
+ try:
+ return _json.loads(text)
+ except ValueError:
+ sanitized = _BARE_ASTERISK.sub(": null", text)
+ return _json.loads(sanitized)
+
+
+def unpack_response(
+ payload: Any,
+) -> tuple[list[dict[str, Any]], dict[str, Any]]:
+ """Split a raw API payload into (rows, metadata)."""
+ if isinstance(payload, list):
+ if len(payload) == 1:
+ return unpack_response(payload[0])
+ rows = [r for r in payload if isinstance(r, dict)]
+ if rows:
+ return rows, {}
+ return [{"value": r} for r in payload], {}
+ if not isinstance(payload, dict):
+ return [{"value": payload}], {}
+ list_keys = [k for k, v in payload.items() if isinstance(v, list)]
+ if len(list_keys) == 1:
+ key = list_keys[0]
+ items = payload[key]
+ rows = [r for r in items if isinstance(r, dict)]
+ if not rows and items:
+ rows = [{"value": r} for r in items]
+ metadata = {k: v for k, v in payload.items() if k != key}
+ return rows, metadata
+ if len(payload) == 1:
+ only_value = next(iter(payload.values()))
+ if isinstance(only_value, (list, dict)):
+ return unpack_response(only_value)
+ return [payload], {}
diff --git a/cli/openbb_cli/dispatchers/base.py b/cli/openbb_cli/dispatchers/base.py
new file mode 100644
index 00000000000..0460c830b8e
--- /dev/null
+++ b/cli/openbb_cli/dispatchers/base.py
@@ -0,0 +1,20 @@
+"""Dispatcher Protocol โ the shared interface for Local and Http backends."""
+
+from __future__ import annotations
+
+from typing import Protocol, runtime_checkable
+
+from openbb_cli.dispatchers.protocol import Request, Response
+
+
+@runtime_checkable
+class Dispatcher(Protocol):
+ """Async dispatcher contract."""
+
+ async def dispatch(self, request: Request) -> Response:
+ """Resolve and run the command described by ``request``."""
+ ... # pragma: no cover
+
+ async def aclose(self) -> None:
+ """Release any held resources (HTTP clients, connection pools)."""
+ ... # pragma: no cover
diff --git a/cli/openbb_cli/dispatchers/http.py b/cli/openbb_cli/dispatchers/http.py
new file mode 100644
index 00000000000..709b37478b2
--- /dev/null
+++ b/cli/openbb_cli/dispatchers/http.py
@@ -0,0 +1,1023 @@
+"""HTTP dispatcher โ thin client that talks to a long-running openbb-platform-api server."""
+
+from __future__ import annotations
+
+import contextlib
+import inspect
+import re
+from datetime import datetime, timezone
+from time import perf_counter_ns
+from typing import Any
+
+import httpx
+
+from openbb_cli.auth import AuthContext, AuthDecision, AuthHook
+from openbb_cli.dispatchers._unpack import unpack_response
+from openbb_cli.dispatchers.openapi_schema import (
+ PROVIDER_SECTION_SPLIT_RE,
+ PROVIDER_TAG_RE,
+)
+from openbb_cli.dispatchers.protocol import Request, Response, ResponseError
+from openbb_cli.dispatchers.spec import command_parameters
+
+_PATH_TEMPLATE_RE = re.compile(r"\{([^}]+)\}")
+
+
+def _help_for_provider(text: str | None, provider: str) -> str | None:
+ """Pick the sections of an OpenBB-merged help string that apply to ``provider``."""
+ if not text:
+ return text
+ sections = PROVIDER_SECTION_SPLIT_RE.split(text)
+ target = provider.lower()
+ kept: list[str] = []
+ for raw_section in sections:
+ section = raw_section.strip()
+ if not section:
+ continue
+ match = PROVIDER_TAG_RE.search(section)
+ if match is None:
+ kept.append(section)
+ continue
+ tag_providers = {
+ p.strip().lower() for p in match.group(1).split(",") if p.strip()
+ }
+ if target in tag_providers:
+ kept.append(PROVIDER_TAG_RE.sub("", section).rstrip())
+ return "\n".join(kept) if kept else None
+
+
+def _slim_param(p: dict[str, Any]) -> dict[str, Any]:
+ """Strip empty / falsy fields from a normalized parameter entry."""
+ out: dict[str, Any] = {
+ "name": p["name"],
+ "in": p.get("in", "query"),
+ "type": p.get("type", "string"),
+ }
+ if p.get("is_list"):
+ out["is_list"] = True
+ if p.get("json_arg"):
+ out["json_arg"] = True
+ if p.get("required"):
+ out["required"] = True
+ if p.get("default") is not None:
+ out["default"] = p["default"]
+ if p.get("choices"):
+ out["choices"] = p["choices"]
+ if p.get("help"):
+ out["help"] = p["help"]
+ return out
+
+
+def _group_by_provider(
+ raw_params: list[dict[str, Any]],
+ providers: list[str],
+ output_schema: dict[str, Any] | None,
+) -> dict[str, dict[str, Any]]:
+ """Group parameters and output schemas per provider."""
+ out: dict[str, dict[str, Any]] = {}
+ for provider in providers:
+ provider_params: list[dict[str, Any]] = []
+ for p in raw_params:
+ if p.get("name") == "provider":
+ continue
+ tags = p.get("providers") or []
+ if tags and provider not in tags:
+ continue
+ slim = _slim_param(p)
+ slim["help"] = _help_for_provider(slim.get("help"), provider)
+ if not slim.get("help"):
+ slim.pop("help", None)
+ provider_params.append(slim)
+ out[provider] = {
+ "parameters": provider_params,
+ "output_schema": _provider_output_schema(output_schema, provider),
+ }
+ return out
+
+
+def _provider_output_schema(
+ output_schema: dict[str, Any] | None, provider: str
+) -> dict[str, Any] | None:
+ """Pick the per-provider variant out of a OneOf-of-data-classes results list."""
+ if not isinstance(output_schema, dict):
+ return None
+ results = (output_schema.get("properties") or {}).get("results")
+ if not isinstance(results, dict):
+ return None
+ candidates: list[Any] = []
+ for variant in results.get("anyOf") or [results]:
+ if not isinstance(variant, dict) or variant.get("type") == "null":
+ continue
+ if variant.get("type") == "array" and isinstance(variant.get("items"), dict):
+ inner = variant["items"]
+ candidates.extend(inner.get("oneOf") or [inner])
+ else:
+ candidates.extend(variant.get("oneOf") or [variant])
+ target = provider.lower()
+ for cand in candidates:
+ if not isinstance(cand, dict):
+ continue
+ title = (cand.get("title") or "").lower()
+ if title.startswith(target):
+ return cand
+ if len(candidates) == 1 and isinstance(candidates[0], dict):
+ return candidates[0]
+ return None
+
+
+def _decode_response(response: httpx.Response) -> Any:
+ """Return the response body as JSON when the server says so, otherwise text."""
+ content_type = (response.headers.get("content-type") or "").lower().split(";", 1)[0]
+ if content_type.endswith("json") or content_type == "":
+ try:
+ return response.json()
+ except ValueError:
+ return response.text
+ return response.text
+
+
+_NUMERIC_RESPONSE_TYPES: frozenset[str] = frozenset(
+ {"number", "money", "percent", "double"}
+)
+
+_SOCRATA_PAGE_SIZE = 5000
+
+_SOCRATA_MAX_PAGES = 1000
+
+
+def _user_requested_all_rows(request: Request) -> bool:
+ """Return True when the caller asked for the full dataset (``limit=0``)."""
+ raw = (request.params or {}).get("limit")
+ if raw is None:
+ return False
+ try:
+ return int(raw) == 0
+ except (TypeError, ValueError):
+ return False
+
+
+def _user_limit_for_per_item(request: Request) -> int | None:
+ """Return the user's ``limit`` as an int, or ``None`` if unusable."""
+ raw = (request.params or {}).get("limit")
+ if raw is None:
+ return None
+ try:
+ n = int(raw)
+ except (TypeError, ValueError):
+ return None
+ if n < 0:
+ return None
+ return n if n > 0 else None
+
+
+async def _fetch_until_n_distinct_dates(
+ client: httpx.AsyncClient,
+ url: str,
+ base_query: dict[str, Any],
+ extra_headers: dict[str, str] | None,
+ time_axis: str,
+ n: int,
+) -> list[dict[str, Any]]:
+ """Page until the response covers >= ``n + 1`` distinct ``time_axis`` values."""
+ rows: list[dict[str, Any]] = []
+ distinct_dates: set[Any] = set()
+ base = dict(base_query)
+ base["$limit"] = _SOCRATA_PAGE_SIZE
+ for page in range(_SOCRATA_MAX_PAGES):
+ page_params = dict(base)
+ page_params["$offset"] = page * _SOCRATA_PAGE_SIZE
+ response = await client.get(url, params=page_params, headers=extra_headers)
+ response.raise_for_status()
+ payload = _decode_response(response)
+ if not isinstance(payload, list) or not payload:
+ break
+ rows.extend(payload)
+ for row in payload:
+ if isinstance(row, dict):
+ value = row.get(time_axis)
+ if value is not None:
+ distinct_dates.add(value)
+ if len(distinct_dates) > n:
+ break
+ if len(payload) < _SOCRATA_PAGE_SIZE:
+ break
+ return rows
+
+
+async def _fetch_all_pages(
+ client: httpx.AsyncClient,
+ url: str,
+ base_query: dict[str, Any],
+ extra_headers: dict[str, str] | None,
+) -> list[dict[str, Any]]:
+ """Page through ``$offset`` until the upstream returns an empty page."""
+ rows: list[dict[str, Any]] = []
+ base = dict(base_query)
+ base["$limit"] = _SOCRATA_PAGE_SIZE
+ for page in range(_SOCRATA_MAX_PAGES):
+ page_params = dict(base)
+ page_params["$offset"] = page * _SOCRATA_PAGE_SIZE
+ response = await client.get(url, params=page_params, headers=extra_headers)
+ response.raise_for_status()
+ payload = _decode_response(response)
+ if not isinstance(payload, list) or not payload:
+ break
+ rows.extend(payload)
+ if len(payload) < _SOCRATA_PAGE_SIZE:
+ break
+ return rows
+
+
+def _request_with_page_size_limit(request: Request) -> Request:
+ """Return a copy of ``request`` with ``limit`` capped to one page."""
+ bumped = dict(request.params or {})
+ bumped["limit"] = _SOCRATA_PAGE_SIZE
+ return Request(id=request.id, command=request.command, params=bumped)
+
+
+def _truncate_to_top_n_dates(
+ shaped: Any, time_axis: str, user_limit: int | None
+) -> Any:
+ """Keep every row whose ``time_axis`` is among the ``user_limit`` most recent."""
+ rows = _extract_rows(shaped)
+ if rows is None or user_limit is None:
+ return shaped
+ seen_dates: list[Any] = []
+ seen_set: set[Any] = set()
+ kept: list[Any] = []
+ for row in rows:
+ if not isinstance(row, dict):
+ kept.append(row)
+ continue
+ date = row.get(time_axis)
+ if date is None:
+ kept.append(row)
+ continue
+ if date not in seen_set:
+ if len(seen_dates) >= user_limit:
+ break
+ seen_dates.append(date)
+ seen_set.add(date)
+ kept.append(row)
+ return _replace_rows(shaped, kept)
+
+
+def _extract_rows(shaped: Any) -> list[Any] | None:
+ """Pull the row list out of a shaped result, or return ``None``."""
+ if isinstance(shaped, list):
+ return shaped
+ if isinstance(shaped, dict):
+ results = shaped.get("results")
+ if isinstance(results, list):
+ return results
+ return None
+ results = getattr(shaped, "results", None)
+ if isinstance(results, list) and hasattr(shaped, "extra"):
+ return results
+ return None
+
+
+def _replace_rows(shaped: Any, rows: list[Any]) -> Any:
+ """Inverse of ``_extract_rows`` โ substitute the collapsed row list back in."""
+ if isinstance(shaped, list):
+ return rows
+ if isinstance(shaped, dict) and isinstance(shaped.get("results"), list):
+ return {**shaped, "results": rows}
+ if isinstance(getattr(shaped, "results", None), list) and hasattr(shaped, "extra"):
+ shaped.results = rows
+ return shaped
+ return shaped
+
+
+def _coerce_response_value(value: Any, type_name: str, fmt: str) -> Any:
+ """Cast a single field value to the type declared by the response schema."""
+ if not isinstance(value, str):
+ return value
+ if type_name in _NUMERIC_RESPONSE_TYPES:
+ try:
+ num = float(value)
+ except ValueError:
+ return value
+ return int(num) if num.is_integer() else num
+ if type_name == "integer":
+ try:
+ return int(value)
+ except ValueError:
+ return value
+ if type_name == "boolean":
+ if value.lower() in {"true", "false"}:
+ return value.lower() == "true"
+ return value
+ if type_name == "string" and fmt == "date" and "T" in value and len(value) >= 10:
+ return value.split("T", 1)[0]
+ return value
+
+
+def _row_column_types(cmd_spec: dict[str, Any]) -> dict[str, tuple[str, str]]:
+ """Extract ``{field_name: (type, format)}`` from a command's response schema."""
+ response = cmd_spec.get("response_schema") or {}
+ results = (response.get("properties") or {}).get("results") or {}
+ items = results.get("items") or {}
+ item_props = items.get("properties") or {}
+ out: dict[str, tuple[str, str]] = {}
+ for name, schema in item_props.items():
+ if not isinstance(schema, dict):
+ continue
+ out[name] = (
+ schema.get("type") or "",
+ schema.get("format") or "",
+ )
+ return out
+
+
+def _command_to_route(command: str) -> str:
+ """Translate a dotted CLI command into a slash-separated route."""
+ return "/" + command.replace(".", "/").strip("/")
+
+
+def _row_column_metadata(cmd_spec: dict[str, Any]) -> dict[str, dict[str, Any]]:
+ """Extract per-column description / format hints from the response schema."""
+ response = cmd_spec.get("response_schema") or {}
+ results = (response.get("properties") or {}).get("results") or {}
+ items = results.get("items") or {}
+ item_props = items.get("properties") or {}
+ out: dict[str, dict[str, Any]] = {}
+ for name, schema in item_props.items():
+ if not isinstance(schema, dict):
+ continue
+ meta: dict[str, Any] = {}
+ if schema.get("description"):
+ meta["description"] = schema["description"]
+ if schema.get("format"):
+ meta["format"] = schema["format"]
+ if schema.get("socrata_format"):
+ meta["socrata_format"] = schema["socrata_format"]
+ if meta:
+ out[name] = meta
+ return out
+
+
+def _coerce_row_types(
+ rows: list[dict[str, Any]], column_types: dict[str, tuple[str, str]]
+) -> list[dict[str, Any]]:
+ """Apply schema-driven coercion to every row in a response."""
+ if not column_types:
+ return rows
+ coerced: list[dict[str, Any]] = []
+ for row in rows:
+ if not isinstance(row, dict):
+ coerced.append(row)
+ continue
+ new_row = {}
+ for k, v in row.items():
+ type_info = column_types.get(k)
+ if type_info is None:
+ new_row[k] = v
+ else:
+ new_row[k] = _coerce_response_value(v, type_info[0], type_info[1])
+ coerced.append(new_row)
+ return coerced
+
+
+def _is_plotly_figure(payload: Any) -> bool:
+ """Detect a Plotly figure-shaped payload."""
+ if not isinstance(payload, dict):
+ return False
+ data = payload.get("data")
+ layout = payload.get("layout")
+ if not isinstance(data, list) or not isinstance(layout, dict):
+ return False
+ if not data:
+ return False
+ return any(
+ isinstance(trace, dict) and isinstance(trace.get("type"), str) for trace in data
+ )
+
+
+def _wrap_plotly_figure(
+ payload: dict[str, Any],
+ *,
+ command: str,
+ params: dict[str, Any] | None,
+ timestamp: datetime | None,
+ duration_ns: int | None,
+) -> Any:
+ """Wrap a Plotly figure dict in an ``OBBject`` with ``results=figure``."""
+ try:
+ from openbb_core.app.model.charts.chart import Chart
+ from openbb_core.app.model.metadata import Metadata
+ from openbb_core.app.model.obbject import OBBject
+ except ImportError:
+ return payload
+
+ extra: dict[str, Any] = {}
+ if timestamp is not None and duration_ns is not None:
+ with contextlib.suppress(Exception):
+ extra["metadata"] = Metadata(
+ arguments={
+ "provider_choices": {},
+ "standard_params": dict(params or {}),
+ "extra_params": {},
+ },
+ duration=duration_ns,
+ route=_command_to_route(command),
+ timestamp=timestamp,
+ )
+
+ return OBBject(
+ results=payload,
+ chart=Chart(content=payload, format="plotly"),
+ extra=extra,
+ )
+
+
+def _shape_result(payload: Any) -> Any:
+ """Apply the same envelope unwrap codegen does, then return a tidy result."""
+ if not isinstance(payload, (dict, list)):
+ return payload
+ rows, metadata = unpack_response(payload)
+ if not rows and not metadata:
+ return payload
+ body: Any
+ if len(rows) == 1:
+ sole = rows[0]
+ body = sole.get("value", sole) if set(sole.keys()) == {"value"} else sole
+ else:
+ body = [r.get("value", r) if set(r.keys()) == {"value"} else r for r in rows]
+ if metadata:
+ return {"results": body, "metadata": metadata}
+ return body
+
+
+class HttpDispatcher:
+ """Multi-tenant dispatcher backed by openbb-platform-api over HTTP."""
+
+ def __init__(
+ self,
+ base_url: str,
+ *,
+ client: httpx.AsyncClient | None = None,
+ timeout: float = 60.0,
+ api_prefix: str = "/api/v1",
+ command_methods: dict[str, str] | None = None,
+ command_url_paths: dict[str, str | list[str]] | None = None,
+ headers: dict[str, str] | None = None,
+ query_params: dict[str, str] | None = None,
+ spec_doc: dict[str, Any] | None = None,
+ auth_hook: AuthHook | None = None,
+ namespace: str | None = None,
+ ) -> None:
+ self._base_url = base_url.rstrip("/")
+ self._api_prefix = "/" + api_prefix.strip("/")
+ self._timeout = timeout
+ if client is not None:
+ self._client: httpx.AsyncClient | None = client
+ self._owns_client = False
+ else:
+ self._client = None
+ self._owns_client = True
+ self._command_methods = command_methods or {}
+ self._command_url_paths: dict[str, list[str]] = {
+ cmd: [paths] if isinstance(paths, str) else list(paths)
+ for cmd, paths in (command_url_paths or {}).items()
+ }
+ self._headers = dict(headers) if headers else {}
+ self._query_params = dict(query_params) if query_params else {}
+ self._spec_doc = spec_doc or {}
+ self._auth_hook = auth_hook
+ self._namespace = namespace
+
+ def _url_for(self, command: str) -> str:
+ """Build the fully-qualified URL for a command, no path-param substitution."""
+ path = command.replace(".", "/").strip("/")
+ return f"{self._base_url}{self._api_prefix}/{path}"
+
+ def _resolve_url(
+ self, command: str, params: dict[str, Any]
+ ) -> tuple[str, dict[str, Any]]:
+ """Build the URL for ``command`` and split path-substituted params off.
+
+ When multiple URL templates exist for the command (overloads merged at
+ spec-build time), pick the one with the most path placeholders that are
+ all satisfied by ``params``; fall back to the shortest template otherwise.
+ """
+ templates = self._command_url_paths.get(command)
+ if not templates:
+ return self._url_for(command), self._apply_param_transforms(
+ command, dict(params)
+ )
+
+ ordered = sorted(templates, key=lambda t: -t.count("{"))
+ chosen: str | None = None
+ for template in ordered:
+ placeholders = _PATH_TEMPLATE_RE.findall(template)
+ if not placeholders:
+ chosen = template
+ break
+ if all(params.get(key) not in (None, "") for key in placeholders):
+ chosen = template
+ break
+ if chosen is None:
+ chosen = ordered[-1]
+
+ consumed: set[str] = set()
+
+ def _substitute(match: re.Match[str]) -> str:
+ key = match.group(1)
+ consumed.add(key)
+ value = params.get(key)
+ if value is None:
+ return match.group(0)
+ return str(value)
+
+ path = _PATH_TEMPLATE_RE.sub(_substitute, chosen)
+ remaining = {k: v for k, v in params.items() if k not in consumed}
+ return f"{self._base_url}{path}", self._apply_param_transforms(
+ command, remaining
+ )
+
+ def _date_snapshot_context(self, request: Request) -> tuple[str | None, int | None]:
+ """Detect the rows-from-N-most-recent-dates branch for ``limit``."""
+ cmd_spec = (self._spec_doc.get("commands") or {}).get(request.command) or {}
+ time_axis = cmd_spec.get("_socrata_time_axis")
+ if not time_axis:
+ return None, None
+ raw_limit = (request.params or {}).get("limit")
+ if raw_limit is None:
+ return None, None
+ try:
+ user_limit = int(raw_limit)
+ except (TypeError, ValueError):
+ return None, None
+ if user_limit <= 0:
+ return None, None
+ return time_axis, user_limit
+
+ def _shape_and_coerce(
+ self,
+ command: str,
+ payload: Any,
+ *,
+ params: dict[str, Any] | None = None,
+ timestamp: datetime | None = None,
+ duration_ns: int | None = None,
+ ) -> Any:
+ """Unpack envelopes, coerce row types, then return a real ``OBBject``."""
+ if _is_plotly_figure(payload):
+ return _wrap_plotly_figure(
+ payload,
+ command=command,
+ params=params,
+ timestamp=timestamp,
+ duration_ns=duration_ns,
+ )
+ shaped = _shape_result(payload)
+ cmd_spec = (self._spec_doc.get("commands") or {}).get(command) or {}
+ column_types = _row_column_types(cmd_spec)
+ column_metadata = _row_column_metadata(cmd_spec)
+
+ api_metadata: dict[str, Any] = {}
+ rows: Any
+ if isinstance(shaped, list):
+ rows = shaped
+ elif isinstance(shaped, dict):
+ inner = shaped.get("results")
+ if isinstance(inner, list) or inner is not None:
+ rows = inner
+ api_metadata = dict(shaped.get("metadata") or {})
+ else:
+ rows = shaped
+ else:
+ return shaped
+
+ if column_types:
+ if isinstance(rows, list):
+ rows = _coerce_row_types(rows, column_types)
+ elif isinstance(rows, dict):
+ rows = _coerce_row_types([rows], column_types)[0]
+
+ if not column_metadata and not api_metadata:
+ return rows
+
+ try:
+ from openbb_core.app.model.metadata import Metadata
+ from openbb_core.app.model.obbject import OBBject
+ except ImportError:
+ extra_fallback: dict[str, Any] = dict(api_metadata)
+ if column_metadata:
+ extra_fallback["results_metadata"] = {"columns": column_metadata}
+ return {"results": rows, "extra": extra_fallback}
+
+ extra: dict[str, Any] = dict(api_metadata)
+ if column_metadata:
+ extra["results_metadata"] = {"columns": column_metadata}
+
+ if timestamp is not None and duration_ns is not None:
+ with contextlib.suppress(Exception):
+ extra["metadata"] = Metadata(
+ arguments={
+ "provider_choices": {},
+ "standard_params": dict(params or {}),
+ "extra_params": {},
+ },
+ duration=duration_ns,
+ route=_command_to_route(command),
+ timestamp=timestamp,
+ )
+
+ return OBBject(results=rows, extra=extra)
+
+ def _apply_param_transforms(
+ self, command: str, params: dict[str, Any]
+ ) -> dict[str, Any]:
+ """Translate user-facing param names into the upstream wire form."""
+ cmd_spec = (self._spec_doc.get("commands") or {}).get(command) or {}
+ param_specs = cmd_spec.get("parameters") or []
+ if not param_specs:
+ return params
+ out = dict(params)
+ where_parts: list[str] = []
+ for p in param_specs:
+ if not isinstance(p, dict):
+ continue
+ op = p.get("_socrata_op")
+ if op not in {"date_min", "date_max"}:
+ continue
+ value = out.pop(p["name"], None)
+ if value is None:
+ continue
+ column = p.get("_socrata_column")
+ if not column:
+ continue
+ cmp = ">=" if op == "date_min" else "<="
+ where_parts.append(f"{column} {cmp} '{value}'")
+ if where_parts:
+ existing = out.pop("$where", None)
+ joined = " AND ".join(where_parts)
+ out["$where"] = f"({existing}) AND ({joined})" if existing else joined
+ time_axis = cmd_spec.get("_socrata_time_axis")
+ if time_axis and "$order" not in out:
+ out["$order"] = f"{time_axis} DESC"
+ for p in param_specs:
+ if not isinstance(p, dict):
+ continue
+ wire = p.get("wire_name")
+ name = p.get("name")
+ if wire and name and wire != name and name in out:
+ out[wire] = out.pop(name)
+ return out
+
+ def _method_for(self, command: str, override: str | None) -> str:
+ """Resolve the HTTP method for ``command``: explicit override > map > default."""
+ if override is not None:
+ return override.lower()
+ return self._command_methods.get(command, "post").lower()
+
+ def _partition_params(
+ self, command: str, params: dict[str, Any]
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
+ """Split dispatch params into ``(query, body)`` by their spec ``in`` tag."""
+ cmd_spec = (self._spec_doc.get("commands") or {}).get(command) or {}
+ locations = {
+ p["name"]: p.get("in", "query")
+ for p in command_parameters(cmd_spec)
+ if isinstance(p, dict) and p.get("name")
+ }
+ query: dict[str, Any] = {}
+ body: dict[str, Any] = {}
+ for name, value in params.items():
+ if locations.get(name, "body") == "body":
+ body[name] = value
+ else:
+ query[name] = value
+ return query, body
+
+ async def _invoke_auth_hook(
+ self, request: Request, method_lower: str
+ ) -> AuthDecision:
+ """Run the configured auth hook (if any) and return its decision."""
+ if self._auth_hook is None:
+ return AuthDecision()
+ ctx = AuthContext(
+ namespace=self._namespace,
+ command=request.command,
+ params=dict(request.params or {}),
+ method=method_lower,
+ )
+ try:
+ result = self._auth_hook(ctx)
+ if inspect.isawaitable(result):
+ result = await result
+ except Exception as exc: # noqa: BLE001
+ return AuthDecision(allow=False, deny_reason=f"auth hook raised: {exc}")
+ if not isinstance(result, AuthDecision):
+ return AuthDecision(
+ allow=False,
+ deny_reason=(
+ f"auth hook returned {type(result).__name__}; expected AuthDecision"
+ ),
+ )
+ return result
+
+ async def dispatch(self, request: Request, method: str | None = None) -> Response:
+ """Send ``request`` to the platform-api endpoint that maps to its command."""
+ if request.command == "__commands__":
+ return await self._list_commands(request)
+ if request.command == "__schema__":
+ return await self._describe_command(request)
+ time_axis, user_limit = self._date_snapshot_context(request)
+ fetch_all = _user_requested_all_rows(request)
+ wire_request = request
+ if time_axis is not None or fetch_all:
+ wire_request = _request_with_page_size_limit(request)
+ timestamp = datetime.now(timezone.utc)
+ start_ns = perf_counter_ns()
+ try:
+ url, body_or_query = self._resolve_url(
+ wire_request.command, wire_request.params or {}
+ )
+ method_lower = self._method_for(wire_request.command, method)
+ decision = await self._invoke_auth_hook(wire_request, method_lower)
+ if not decision.allow:
+ return Response(
+ id=request.id,
+ ok=False,
+ error=ResponseError(
+ type="AccessDenied",
+ message=decision.deny_reason or "auth hook denied request",
+ ),
+ )
+ extra_headers = decision.headers or None
+ extra_query = decision.query_params or None
+ async with self._client_context() as client:
+ if method_lower == "get":
+ merged_query = {
+ **self._query_params,
+ **(extra_query or {}),
+ **body_or_query,
+ }
+ if fetch_all:
+ payload = await _fetch_all_pages(
+ client, url, merged_query, extra_headers
+ )
+ elif time_axis is not None:
+ payload = await _fetch_until_n_distinct_dates(
+ client,
+ url,
+ merged_query,
+ extra_headers,
+ time_axis,
+ user_limit or 0,
+ )
+ else:
+ response = await client.get(
+ url, params=merged_query, headers=extra_headers
+ )
+ response.raise_for_status()
+ payload = _decode_response(response)
+ else:
+ query_part, body_part = self._partition_params(
+ wire_request.command, body_or_query
+ )
+ merged_query = {
+ **self._query_params,
+ **(extra_query or {}),
+ **query_part,
+ }
+ response = await client.post(
+ url,
+ params=merged_query or None,
+ json=body_part,
+ headers=extra_headers,
+ )
+ response.raise_for_status()
+ payload = _decode_response(response)
+ duration_ns = perf_counter_ns() - start_ns
+ shaped = self._shape_and_coerce(
+ wire_request.command,
+ payload,
+ params=request.params or {},
+ timestamp=timestamp,
+ duration_ns=duration_ns,
+ )
+ if time_axis is not None and not fetch_all:
+ shaped = _truncate_to_top_n_dates(shaped, time_axis, user_limit)
+ return Response(
+ id=request.id,
+ ok=True,
+ result=shaped,
+ error=None,
+ )
+ except httpx.HTTPStatusError as exc:
+ try:
+ detail = exc.response.json()
+ except ValueError:
+ detail = exc.response.text
+ return Response(
+ id=request.id,
+ ok=False,
+ error=ResponseError(
+ type=f"HTTP{exc.response.status_code}",
+ message=str(detail),
+ ),
+ )
+ except httpx.RequestError as exc:
+ return Response(
+ id=request.id,
+ ok=False,
+ error=ResponseError(type=type(exc).__name__, message=str(exc)),
+ )
+ except Exception as exc: # noqa: BLE001
+ return Response(
+ id=request.id,
+ ok=False,
+ error=ResponseError(type=type(exc).__name__, message=str(exc)),
+ )
+
+ async def _list_commands(self, request: Request) -> Response:
+ """Return ``[{name, description}, ...]`` for every command the auth hook permits."""
+ commands = self._spec_doc.get("commands", {})
+ rows: list[dict[str, Any]] = []
+ for name, meta in commands.items():
+ if self._auth_hook is not None:
+ decision = await self._invoke_auth_hook(
+ Request(command=name), method_lower="list"
+ )
+ if not decision.allow:
+ continue
+ rows.append(
+ {
+ "name": name,
+ "description": (meta.get("description") or "").split(".")[0] or "",
+ }
+ )
+ rows.sort(key=lambda r: r["name"])
+ return Response(id=request.id, ok=True, result=rows, error=None)
+
+ async def _describe_command(self, request: Request) -> Response:
+ """Return ``{name, parameters, output_schema}`` for one command."""
+ name = (request.params or {}).get("name")
+ if not name:
+ return Response(
+ id=request.id,
+ ok=False,
+ error=ResponseError(
+ type="MissingParameter",
+ message="__schema__ requires --name=",
+ ),
+ )
+ cmd_spec = self._spec_doc.get("commands", {}).get(name)
+ if cmd_spec is None:
+ return Response(
+ id=request.id,
+ ok=False,
+ error=ResponseError(
+ type="UnknownCommand",
+ message=(
+ f"command not in spec: {name!r}. "
+ "Use __commands__ to list available commands."
+ ),
+ ),
+ )
+ if self._auth_hook is not None:
+ decision = await self._invoke_auth_hook(
+ Request(command=name), method_lower="schema"
+ )
+ if not decision.allow:
+ return Response(
+ id=request.id,
+ ok=False,
+ error=ResponseError(
+ type="AccessDenied",
+ message=decision.deny_reason or "auth hook denied describe",
+ ),
+ )
+ providers = cmd_spec.get("providers") or []
+ if providers:
+ requested_provider = (request.params or {}).get("provider")
+ grouped = _group_by_provider(
+ command_parameters(cmd_spec),
+ providers,
+ cmd_spec.get("response_schema"),
+ )
+ if requested_provider:
+ slice_ = grouped.get(requested_provider)
+ if slice_ is None:
+ return Response(
+ id=request.id,
+ ok=False,
+ error=ResponseError(
+ type="UnknownProvider",
+ message=(
+ f"provider {requested_provider!r} not declared "
+ f"by {name!r}. Available: "
+ f"{', '.join(sorted(grouped))}."
+ ),
+ ),
+ )
+ return Response(
+ id=request.id,
+ ok=True,
+ result={
+ "name": name,
+ "provider": requested_provider,
+ **slice_,
+ },
+ error=None,
+ )
+ return Response(
+ id=request.id,
+ ok=True,
+ result={"name": name, "providers": grouped},
+ error=None,
+ )
+ params: list[dict[str, Any]] = [
+ _slim_param(p) for p in command_parameters(cmd_spec)
+ ]
+ return Response(
+ id=request.id,
+ ok=True,
+ result={
+ "name": name,
+ "parameters": params,
+ "output_schema": cmd_spec.get("response_schema"),
+ },
+ error=None,
+ )
+
+ def _client_context(self):
+ """Yield an ``httpx.AsyncClient`` for one dispatch."""
+ from contextlib import asynccontextmanager
+
+ owns = self._owns_client
+ client_ref = self._client
+ timeout = self._timeout
+ headers = self._headers or None
+
+ @asynccontextmanager
+ async def _ctx():
+ if owns:
+ async with httpx.AsyncClient(timeout=timeout, headers=headers) as c:
+ yield c
+ else:
+ yield client_ref
+
+ return _ctx()
+
+ async def aclose(self) -> None:
+ """No-op."""
+ return None
+
+
+def http_dispatcher_from_spec(
+ spec_doc: dict[str, Any],
+ *,
+ headers: dict[str, str] | None = None,
+ query_params: dict[str, str] | None = None,
+ auth_hook: AuthHook | None = None,
+ namespace: str | None = None,
+) -> HttpDispatcher:
+ """Build an ``HttpDispatcher`` from a loaded ``.spec`` document."""
+ commands = spec_doc.get("commands", {})
+ methods = {cmd: meta.get("method", "post") for cmd, meta in commands.items()}
+ url_paths: dict[str, str | list[str]] = {}
+ for cmd, meta in commands.items():
+ templates = meta.get("url_templates")
+ if templates:
+ url_paths[cmd] = list(templates)
+ elif meta.get("url_path"):
+ url_paths[cmd] = meta["url_path"]
+ return HttpDispatcher(
+ spec_doc["base_url"],
+ api_prefix=spec_doc.get("api_prefix", "/api/v1"),
+ command_methods=methods,
+ command_url_paths=url_paths,
+ headers=headers,
+ query_params=query_params,
+ spec_doc=spec_doc,
+ auth_hook=auth_hook,
+ namespace=namespace,
+ )
+
+
+def http_dispatcher_from_server(
+ base_url: str,
+ *,
+ headers: dict[str, str] | None = None,
+ query_params: dict[str, str] | None = None,
+ auth_hook: AuthHook | None = None,
+ namespace: str | None = None,
+) -> HttpDispatcher:
+ """Build an ``HttpDispatcher`` by fetching the server's OpenAPI document."""
+ from openbb_cli.dispatchers.openapi_schema import fetch_openapi
+ from openbb_cli.dispatchers.spec import build_spec_document
+
+ openapi = fetch_openapi(base_url, headers=headers, query_params=query_params)
+ spec_doc = build_spec_document(openapi, base_url=base_url)
+ return http_dispatcher_from_spec(
+ spec_doc,
+ headers=headers,
+ query_params=query_params,
+ auth_hook=auth_hook,
+ namespace=namespace,
+ )
diff --git a/cli/openbb_cli/dispatchers/local.py b/cli/openbb_cli/dispatchers/local.py
new file mode 100644
index 00000000000..5b0ac29dcfa
--- /dev/null
+++ b/cli/openbb_cli/dispatchers/local.py
@@ -0,0 +1,78 @@
+"""In-process dispatcher โ resolves commands against the real ``obb`` namespace."""
+
+from __future__ import annotations
+
+import asyncio
+import inspect
+from typing import Any
+
+from openbb_cli.dispatchers.protocol import Request, Response, ResponseError
+
+
+class CommandNotFound(KeyError):
+ """Raised when a dotted command path does not resolve under ``obb``."""
+
+
+class LocalDispatcher:
+ """Single-tenant in-process dispatcher."""
+
+ def __init__(self) -> None:
+ from openbb import obb
+
+ self._obb = obb
+
+ @staticmethod
+ def _resolve(root: Any, path: str) -> Any:
+ node: Any = root
+ for part in path.split("."):
+ if not part:
+ raise CommandNotFound(f"Empty segment in command path: {path!r}")
+ try:
+ node = getattr(node, part)
+ except AttributeError as exc:
+ raise CommandNotFound(
+ f"Command not found: {path!r} (failed at {part!r})"
+ ) from exc
+ return node
+
+ @staticmethod
+ def _serialize(value: Any) -> Any:
+ if value is None:
+ return None
+ if hasattr(value, "model_dump"):
+ return value.model_dump(exclude_unset=True, exclude_none=True)
+ if hasattr(value, "to_dict"):
+ return value.to_dict(orient="records")
+ return value
+
+ async def dispatch(self, request: Request) -> Response:
+ """Resolve ``request`` against ``obb`` and execute the leaf command."""
+ try:
+ command = self._resolve(self._obb, request.command)
+ if not callable(command): # pragma: no cover
+ raise CommandNotFound(
+ f"Resolved {request.command!r} but it is not callable"
+ )
+ if inspect.iscoroutinefunction(command):
+ result = await command(**request.params)
+ else:
+ result = await asyncio.to_thread(command, **request.params)
+ return Response(
+ id=request.id, ok=True, result=self._serialize(result), error=None
+ )
+ except CommandNotFound as exc:
+ return Response(
+ id=request.id,
+ ok=False,
+ error=ResponseError(type="CommandNotFound", message=str(exc)),
+ )
+ except Exception as exc: # noqa: BLE001
+ return Response(
+ id=request.id,
+ ok=False,
+ error=ResponseError(type=type(exc).__name__, message=str(exc)),
+ )
+
+ async def aclose(self) -> None:
+ """No-op โ the local dispatcher holds no external resources."""
+ return None
diff --git a/cli/openbb_cli/dispatchers/multi.py b/cli/openbb_cli/dispatchers/multi.py
new file mode 100644
index 00000000000..d1629a2250b
--- /dev/null
+++ b/cli/openbb_cli/dispatchers/multi.py
@@ -0,0 +1,116 @@
+"""Multi-spec dispatcher โ routes namespaced commands to per-spec backends."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from openbb_cli.dispatchers.http import HttpDispatcher
+from openbb_cli.dispatchers.protocol import Request, Response, ResponseError
+
+
+class MultiSpecDispatcher:
+ """Routes ``.`` to per-spec ``HttpDispatcher``s."""
+
+ def __init__(self, dispatchers: dict[str, HttpDispatcher]) -> None:
+ if not dispatchers:
+ raise ValueError("MultiSpecDispatcher requires at least one namespace.")
+ self._dispatchers = dict(dispatchers)
+ self._spec_doc = self._merge_spec_docs()
+
+ def _merge_spec_docs(self) -> dict[str, Any]:
+ """Build an aggregated spec doc for the spec-aware parser path."""
+ merged_commands: dict[str, Any] = {}
+ merged_routers: dict[str, str] = {}
+ merged_reference_paths: dict[str, dict[str, Any]] = {}
+ merged_reference_routers: dict[str, dict[str, Any]] = {}
+ for namespace, dispatcher in self._dispatchers.items():
+ doc = dispatcher._spec_doc
+ for cmd, entry in doc.get("commands", {}).items():
+ merged_commands[f"{namespace}.{cmd}"] = entry
+ merged_routers[namespace] = "menu"
+ reference = doc.get("reference") or {}
+ for path, meta in (reference.get("paths") or {}).items():
+ prefixed = (
+ "/" + namespace + (path if path.startswith("/") else "/" + path)
+ )
+ merged_reference_paths[prefixed] = meta
+ for router, meta in (reference.get("routers") or {}).items():
+ if router == "":
+ merged_reference_routers[namespace] = meta
+ else:
+ merged_reference_routers[f"{namespace}/{router}"] = meta
+ return {
+ "commands": merged_commands,
+ "routers": merged_routers,
+ "reference": {
+ "paths": merged_reference_paths,
+ "routers": merged_reference_routers,
+ },
+ }
+
+ async def dispatch(self, request: Request, method: str | None = None) -> Response:
+ """Route ``request`` to the namespace named by its leading command segment."""
+ if request.command == "__commands__":
+ return await self._aggregate_list_commands(request)
+ if request.command == "__schema__":
+ return await self._forward_schema(request)
+ namespace, _, inner = request.command.partition(".")
+ if not inner or namespace not in self._dispatchers:
+ return Response(
+ id=request.id,
+ ok=False,
+ error=ResponseError(
+ type="UnknownNamespace",
+ message=(
+ f"command {request.command!r} does not start with a "
+ f"declared spec namespace. Available: "
+ f"{', '.join(sorted(self._dispatchers))}."
+ ),
+ ),
+ )
+ forwarded = Request(id=request.id, command=inner, params=request.params)
+ return await self._dispatchers[namespace].dispatch(forwarded, method=method)
+
+ async def _aggregate_list_commands(self, request: Request) -> Response:
+ """Concatenate every namespace's ``__commands__`` with prefixed names."""
+ rows: list[dict[str, Any]] = []
+ for namespace in sorted(self._dispatchers):
+ sub = await self._dispatchers[namespace].dispatch(
+ Request(id=None, command="__commands__")
+ )
+ if not sub.ok or not isinstance(sub.result, list):
+ continue
+ for entry in sub.result:
+ if not isinstance(entry, dict) or "name" not in entry:
+ continue
+ rows.append({**entry, "name": f"{namespace}.{entry['name']}"})
+ rows.sort(key=lambda r: r["name"])
+ return Response(id=request.id, ok=True, result=rows, error=None)
+
+ async def _forward_schema(self, request: Request) -> Response:
+ """Strip the leading namespace from ``params['name']`` and forward."""
+ params = dict(request.params or {})
+ name = params.get("name") or ""
+ namespace, _, inner = name.partition(".")
+ if not inner or namespace not in self._dispatchers:
+ return Response(
+ id=request.id,
+ ok=False,
+ error=ResponseError(
+ type="UnknownNamespace",
+ message=(
+ f"__schema__ name {name!r} does not start with a "
+ f"declared spec namespace. Available: "
+ f"{', '.join(sorted(self._dispatchers))}."
+ ),
+ ),
+ )
+ params["name"] = inner
+ return await self._dispatchers[namespace].dispatch(
+ Request(id=request.id, command="__schema__", params=params)
+ )
+
+ async def aclose(self) -> None:
+ """Close every owned dispatcher."""
+ for dispatcher in self._dispatchers.values():
+ await dispatcher.aclose()
diff --git a/cli/openbb_cli/dispatchers/openapi_schema.py b/cli/openbb_cli/dispatchers/openapi_schema.py
new file mode 100644
index 00000000000..dc58928f8f5
--- /dev/null
+++ b/cli/openbb_cli/dispatchers/openapi_schema.py
@@ -0,0 +1,698 @@
+"""Build CLI menu / parser structures from an OpenAPI 3.x schema."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+from typing import Any
+
+import httpx
+
+PROVIDER_TAG_RE = re.compile(r"\s*\(provider:\s*([^)]+)\)\s*$")
+PROVIDER_SECTION_SPLIT_RE = re.compile(r";\s*\n\s*")
+
+
+def parse_provider_sections(text: str) -> tuple[set[str], bool]:
+ """Split an OpenBB-merged help string into ``(tagged_providers, has_untagged)``."""
+ tagged: set[str] = set()
+ has_untagged = False
+ for raw_section in PROVIDER_SECTION_SPLIT_RE.split(text):
+ section = raw_section.strip()
+ if not section:
+ continue
+ match = PROVIDER_TAG_RE.search(section)
+ if match is None:
+ has_untagged = True
+ continue
+ for p in match.group(1).split(","):
+ name = p.strip().lower()
+ if name:
+ tagged.add(name)
+ return tagged, has_untagged
+
+
+def param_provider_membership(
+ schema: dict[str, Any],
+ description: str | None,
+ providers_set: set[str],
+) -> list[str]:
+ """Return the providers a parameter belongs to, or ``[]`` if shared by all."""
+ providers_lower = {p.lower(): p for p in providers_set}
+ if description:
+ tagged, has_untagged = parse_provider_sections(description)
+ if has_untagged:
+ return []
+ if tagged:
+ return [providers_lower[t] for t in tagged if t in providers_lower]
+ keys = [
+ k
+ for k, v in schema.items()
+ if k in providers_set
+ and k not in _OPENAPI_RESERVED_SCHEMA_KEYS
+ and isinstance(v, dict)
+ ]
+ if keys:
+ return keys
+ title = schema.get("title")
+ if isinstance(title, str) and title.lower() in providers_lower:
+ return [providers_lower[title.lower()]]
+ return []
+
+
+_TYPE_MAP: dict[str, type] = {
+ "string": str,
+ "integer": int,
+ "number": float,
+ "boolean": bool,
+}
+
+_OPENAPI_RESERVED_SCHEMA_KEYS = frozenset(
+ {
+ "type",
+ "format",
+ "title",
+ "description",
+ "default",
+ "enum",
+ "const",
+ "anyOf",
+ "oneOf",
+ "allOf",
+ "items",
+ "properties",
+ "additionalProperties",
+ "required",
+ "nullable",
+ "minimum",
+ "maximum",
+ "minLength",
+ "maxLength",
+ "pattern",
+ }
+)
+
+
+def resolve_ref(spec: dict[str, Any], ref: str) -> dict[str, Any]:
+ """Resolve a local OpenAPI ``$ref`` JSON pointer against ``spec``."""
+ if not ref.startswith("#/"):
+ return {}
+ node: Any = spec
+ for raw_part in ref[2:].split("/"):
+ part = raw_part.replace("~1", "/").replace("~0", "~")
+ if not isinstance(node, dict) or part not in node:
+ return {}
+ node = node[part]
+ return node if isinstance(node, dict) else {}
+
+
+def deref_parameter(spec: dict[str, Any], param: dict[str, Any]) -> dict[str, Any]:
+ """Resolve a parameter that may be a ``$ref``."""
+ seen: set[str] = set()
+ while "$ref" in param:
+ ref = param["$ref"]
+ if ref in seen:
+ return {}
+ seen.add(ref)
+ param = resolve_ref(spec, ref) or {}
+ schema = param.get("schema")
+ if isinstance(schema, dict) and "$ref" in schema:
+ param = {**param, "schema": resolve_ref(spec, schema["$ref"]) or schema}
+ return param
+
+
+def deref_schema(
+ spec: dict[str, Any],
+ node: Any,
+ seen: frozenset[str] | None = None,
+ max_depth: int = 32,
+) -> Any:
+ """Recursively expand every ``$ref`` inside an OpenAPI schema fragment."""
+ if max_depth <= 0:
+ return node
+ if seen is None:
+ seen = frozenset()
+ if isinstance(node, dict):
+ ref = node.get("$ref")
+ if isinstance(ref, str):
+ if ref in seen:
+ return {"$ref": ref}
+ target = resolve_ref(spec, ref)
+ if not target:
+ return node
+ return deref_schema(spec, target, seen | {ref}, max_depth - 1)
+ return {k: deref_schema(spec, v, seen, max_depth - 1) for k, v in node.items()}
+ if isinstance(node, list):
+ return [deref_schema(spec, v, seen, max_depth - 1) for v in node]
+ return node
+
+
+_SUCCESS_PRIORITY = ("200", "2XX", "201", "default")
+_JSON_CONTENT_TYPES = ("application/json", "application/vnd.api+json")
+
+
+def _deref_response(spec: dict[str, Any], response: dict[str, Any]) -> dict[str, Any]:
+ if "$ref" in response:
+ return resolve_ref(spec, response["$ref"]) or {}
+ return response
+
+
+def extract_response_schema(
+ spec: dict[str, Any], operation: dict[str, Any]
+) -> dict[str, Any] | None:
+ """Pull the primary success-response JSON schema for an operation."""
+ responses = operation.get("responses") or {}
+ response: dict[str, Any] | None = None
+ for key in _SUCCESS_PRIORITY:
+ candidate = responses.get(key)
+ if isinstance(candidate, dict):
+ response = _deref_response(spec, candidate)
+ break
+ if response is None:
+ for value in responses.values():
+ if isinstance(value, dict):
+ response = _deref_response(spec, value)
+ break
+ if response is None:
+ return None
+ content = response.get("content") or {}
+ media: dict[str, Any] | None = None
+ for ct in _JSON_CONTENT_TYPES:
+ candidate = content.get(ct)
+ if isinstance(candidate, dict):
+ media = candidate
+ break
+ if media is None:
+ for value in content.values():
+ if isinstance(value, dict) and "schema" in value:
+ media = value
+ break
+ if media is None:
+ return None
+ schema = media.get("schema")
+ if not isinstance(schema, dict):
+ return None
+ return deref_schema(spec, schema)
+
+
+def extract_request_body_schema(
+ spec: dict[str, Any], operation: dict[str, Any]
+) -> dict[str, Any] | None:
+ """Pull the primary request-body JSON schema for an operation."""
+ rb = operation.get("requestBody")
+ if not isinstance(rb, dict):
+ return None
+ if "$ref" in rb:
+ rb = resolve_ref(spec, rb["$ref"]) or {}
+ content = rb.get("content") or {}
+ media: dict[str, Any] | None = None
+ for ct in _JSON_CONTENT_TYPES:
+ candidate = content.get(ct)
+ if isinstance(candidate, dict):
+ media = candidate
+ break
+ if media is None:
+ for value in content.values():
+ if isinstance(value, dict) and "schema" in value:
+ media = value
+ break
+ if media is None:
+ return None
+ schema = media.get("schema")
+ if not isinstance(schema, dict):
+ return None
+ return deref_schema(spec, schema)
+
+
+def extract_response_schemas(
+ spec: dict[str, Any], operation: dict[str, Any]
+) -> dict[str, dict[str, dict[str, Any]]]:
+ """Return every response schema as ``{status_code: {content_type: schema}}``."""
+ out: dict[str, dict[str, dict[str, Any]]] = {}
+ responses = operation.get("responses") or {}
+ for status, response in responses.items():
+ if not isinstance(response, dict):
+ continue
+ deref_resp = _deref_response(spec, response)
+ content = deref_resp.get("content") or {}
+ per_content: dict[str, dict[str, Any]] = {}
+ for content_type, media in content.items():
+ if not isinstance(media, dict):
+ continue
+ schema = media.get("schema")
+ if isinstance(schema, dict):
+ per_content[content_type] = deref_schema(spec, schema)
+ if per_content:
+ out[status] = per_content
+ return out
+
+
+def _resolve_schema(schema: dict[str, Any]) -> tuple[type, list[Any], bool]:
+ """Resolve an OpenAPI parameter schema to ``(py_type, choices, is_list)``."""
+ if "anyOf" in schema:
+ non_null = [s for s in schema["anyOf"] if s.get("type") != "null"]
+ if not non_null:
+ return (str, [], False)
+ if len(non_null) == 1:
+ return _resolve_schema(non_null[0])
+ choices: list[Any] = []
+ is_list = False
+ for s in non_null:
+ if s.get("type") == "array":
+ is_list = True
+ for v in s.get("enum", []):
+ if v not in choices:
+ choices.append(v)
+ return (str, choices, is_list)
+
+ if schema.get("type") == "array":
+ items = schema.get("items", {}) or {}
+ py_type, item_choices, _ = _resolve_schema(items)
+ return (py_type, item_choices, True)
+
+ if "const" in schema:
+ return (type(schema["const"]), [schema["const"]], False)
+
+ py_type = _TYPE_MAP.get(schema.get("type", "string"), str)
+ return (py_type, list(schema.get("enum", [])), False)
+
+
+def _is_json_arg(schema: dict[str, Any]) -> bool:
+ """Whether a request-body field must be supplied as a raw JSON value."""
+ if schema.get("type") == "object" or "properties" in schema:
+ return True
+ if schema.get("additionalProperties"):
+ return True
+ if schema.get("type") == "array":
+ items = schema.get("items")
+ return isinstance(items, dict) and _is_json_arg(items)
+ return any(
+ isinstance(member, dict) and _is_json_arg(member)
+ for member in schema.get("anyOf", []) or []
+ )
+
+
+def parse_json_arg(raw: str) -> Any:
+ """Decode a CLI argument value that carries a JSON document."""
+ try:
+ return json.loads(raw)
+ except json.JSONDecodeError as exc:
+ raise argparse.ArgumentTypeError(f"invalid JSON: {exc}") from exc
+
+
+def request_body_parameters(
+ body_schema: dict[str, Any] | None,
+) -> list[dict[str, Any]]:
+ """Flatten a dereferenced request-body schema into OpenAPI parameter objects."""
+ if not isinstance(body_schema, dict) or body_schema.get("type") != "object":
+ return []
+ required = set(body_schema.get("required") or [])
+ out: list[dict[str, Any]] = []
+ for name, prop in (body_schema.get("properties") or {}).items():
+ if not isinstance(prop, dict):
+ continue
+ out.append(
+ {
+ "name": name,
+ "in": "body",
+ "required": name in required,
+ "description": prop.get("description") or prop.get("title"),
+ "schema": prop,
+ "_json_arg": _is_json_arg(prop),
+ }
+ )
+ return out
+
+
+def _provider_choices(schema: dict[str, Any]) -> list[Any]:
+ """Union of per-provider ``choices`` lists found in OpenBB extension keys."""
+ out: list[Any] = []
+ for key, value in schema.items():
+ if key in _OPENAPI_RESERVED_SCHEMA_KEYS:
+ continue
+ if isinstance(value, dict) and isinstance(value.get("choices"), list):
+ for c in value["choices"]:
+ if c not in out:
+ out.append(c)
+ return out
+
+
+def _escape_help(text: str | None) -> str | None:
+ """Escape lone ``%`` so argparse's %-formatting validator accepts the help text."""
+ if text is None:
+ return None
+ return text.replace("%", "%%")
+
+
+def parameter_to_kwargs(param: dict[str, Any]) -> tuple[str, dict[str, Any]] | None:
+ """Translate one OpenAPI parameter to ``(flag, add_argument kwargs)``."""
+ name = param.get("name")
+ if not name or name == "chart":
+ return None
+
+ schema = param.get("schema", {}) or {}
+ py_type, enum_choices, is_list = _resolve_schema(schema)
+ provider_choices = _provider_choices(schema)
+ choices: list[Any] = list(enum_choices)
+ for c in provider_choices:
+ if c not in choices:
+ choices.append(c)
+
+ kwargs: dict[str, Any] = {
+ "dest": name,
+ "help": _escape_help(param.get("description") or schema.get("description")),
+ }
+
+ if param.get("_json_arg"):
+ kwargs["type"] = parse_json_arg
+ if param.get("required"):
+ kwargs["required"] = True
+ return (f"--{name}", kwargs)
+
+ if py_type is bool:
+ kwargs.update(action="store_true", default=bool(schema.get("default", False)))
+ return (f"--{name}", kwargs)
+
+ kwargs["type"] = py_type
+ if "default" in schema:
+ kwargs["default"] = schema["default"]
+ if choices:
+ kwargs["choices"] = choices
+ if is_list:
+ kwargs["nargs"] = "+"
+ if param.get("required") and "default" not in kwargs:
+ kwargs["required"] = True
+
+ return (f"--{name}", kwargs)
+
+
+def build_parser_from_operation(
+ op: dict[str, Any], spec: dict[str, Any] | None = None
+) -> argparse.ArgumentParser:
+ """Build an ``ArgumentParser`` from an OpenAPI operation object."""
+ parser = argparse.ArgumentParser(
+ prog=op.get("operationId", "cmd"),
+ description=(op.get("description") or op.get("summary") or "").strip() or None,
+ add_help=False,
+ formatter_class=argparse.RawTextHelpFormatter,
+ )
+ body_params = (
+ request_body_parameters(extract_request_body_schema(spec, op))
+ if spec is not None
+ else []
+ )
+ for param in [*op.get("parameters", []), *body_params]:
+ translated = parameter_to_kwargs(param)
+ if translated is None:
+ continue
+ flag, kwargs = translated
+ try:
+ parser.add_argument(flag, **kwargs)
+ except argparse.ArgumentError:
+ continue
+ return parser
+
+
+def _strip_placeholders(segment: str) -> str:
+ """Remove ``{placeholder}`` substrings from a URL segment."""
+ out = segment
+ while "{" in out and "}" in out:
+ i = out.find("{")
+ j = out.find("}", i)
+ if j < 0:
+ break
+ out = out[:i] + out[j + 1 :]
+ return out.strip(".")
+
+
+def detect_api_prefix(spec: dict[str, Any]) -> str:
+ """Compute the longest slash-segmented prefix shared by every path."""
+ paths = list(spec.get("paths", {}).keys())
+ if not paths:
+ return "/api/v1"
+ parts_lists = [p.split("/")[:-1] for p in paths]
+ common: list[str] = parts_lists[0]
+ for parts in parts_lists[1:]:
+ new_common: list[str] = []
+ for a, b in zip(common, parts):
+ if a == b:
+ new_common.append(a)
+ else:
+ break
+ common = new_common
+ if not common:
+ break
+ prefix = "/".join(common).rstrip("/")
+ return prefix or ""
+
+
+def url_to_command(url: str, api_prefix: str = "/api/v1") -> str:
+ """Convert a URL path to a dotted command, dropping ``{path_params}``."""
+ prefix_parts = [p for p in api_prefix.strip("/").split("/") if p]
+ parts = [p for p in url.strip("/").split("/") if p]
+ if parts[: len(prefix_parts)] == prefix_parts:
+ parts = parts[len(prefix_parts) :]
+ cleaned = [_strip_placeholders(p) for p in parts]
+ return ".".join(p for p in cleaned if p)
+
+
+def build_command_index(
+ spec: dict[str, Any], *, api_prefix: str = "/api/v1"
+) -> dict[str, argparse.ArgumentParser]:
+ """Map each ``paths`` entry in the spec to an ArgumentParser by dotted command."""
+ index: dict[str, argparse.ArgumentParser] = {}
+ for url, methods in spec.get("paths", {}).items():
+ op = methods.get("get") or methods.get("post")
+ if not op:
+ continue
+ index[url_to_command(url, api_prefix=api_prefix)] = build_parser_from_operation(
+ op, spec
+ )
+ return index
+
+
+def build_router_map(
+ spec: dict[str, Any], *, api_prefix: str = "/api/v1"
+) -> dict[str, str]:
+ """Classify every dotted path in ``spec`` as ``"menu"`` or ``"command"``."""
+ out: dict[str, str] = {}
+ for url, methods in spec.get("paths", {}).items():
+ if not (methods.get("get") or methods.get("post")):
+ continue
+ cmd = url_to_command(url, api_prefix=api_prefix)
+ if not cmd:
+ continue
+ parts = cmd.split(".")
+ for i in range(1, len(parts)):
+ out[".".join(parts[:i])] = "menu"
+ out.setdefault(cmd, "command")
+ return out
+
+
+def build_reference(
+ spec: dict[str, Any], *, api_prefix: str = "/api/v1"
+) -> dict[str, Any]:
+ """Mimic ``obb.reference`` โ ``paths`` for commands, ``routers`` for menus."""
+ prefix_parts = [p for p in api_prefix.strip("/").split("/") if p]
+ tag_descriptions: dict[str, str] = {
+ t["name"]: t.get("description", "")
+ for t in spec.get("tags", [])
+ if isinstance(t, dict) and t.get("name")
+ }
+
+ router_map = build_router_map(spec, api_prefix=api_prefix)
+
+ paths_out: dict[str, dict[str, Any]] = {}
+ routers_out: dict[str, dict[str, Any]] = {}
+ for url, methods in spec.get("paths", {}).items():
+ op = methods.get("get") or methods.get("post")
+ if not op:
+ continue
+ parts = [p for p in url.strip("/").split("/") if p]
+ if parts[: len(prefix_parts)] == prefix_parts:
+ parts = parts[len(prefix_parts) :]
+ non_template = [p for p in parts if not (p.startswith("{") and p.endswith("}"))]
+ cli_path = "/" + "/".join(non_template)
+ op_desc = (op.get("description") or op.get("summary") or "").strip()
+ if cli_path not in paths_out or not paths_out[cli_path].get("description"):
+ paths_out[cli_path] = {"description": op_desc}
+ tags = op.get("tags") or []
+ tag_desc = tag_descriptions.get(tags[0]) if tags else ""
+ for i in range(1, len(non_template) + 1):
+ dotted = ".".join(non_template[:i])
+ if router_map.get(dotted) != "menu":
+ continue
+ menu_path = "/" + "/".join(non_template[:i]) + "/"
+ if not routers_out.get(menu_path, {}).get("description"):
+ routers_out[menu_path] = {"description": tag_desc or ""}
+ return {"paths": paths_out, "routers": routers_out}
+
+
+def _parse_spec_text(text: str, *, content_type: str = "") -> dict[str, Any]:
+ """Parse a fetched spec body, choosing JSON or YAML by content sniff."""
+ stripped = text.lstrip()
+ if stripped.startswith(("{", "[")):
+ return json.loads(text)
+ if "yaml" in content_type or "yml" in content_type:
+ return _yaml_load(text)
+ if stripped[:8] in ("openapi:", "swagger:"):
+ return _yaml_load(text)
+ try:
+ return json.loads(text)
+ except json.JSONDecodeError:
+ return _yaml_load(text)
+
+
+def _yaml_load(text: str) -> dict[str, Any]:
+ """Parse a YAML document."""
+ import yaml
+
+ return yaml.safe_load(text)
+
+
+_EMBEDDED_SPEC_MARKERS: tuple[str, ...] = (
+ "var spec = ",
+ "const spec = ",
+ "let spec = ",
+ "window.spec = ",
+ '"spec":',
+ "spec:",
+)
+
+
+def _find_matching_brace(text: str, start: int) -> int | None:
+ """Return the index *after* the ``}`` that matches the ``{`` at ``start``."""
+ if start >= len(text) or text[start] != "{":
+ return None
+ depth = 0
+ in_string = False
+ escape = False
+ for i in range(start, len(text)):
+ c = text[i]
+ if escape:
+ escape = False
+ continue
+ if c == "\\":
+ escape = True
+ continue
+ if c == '"':
+ in_string = not in_string
+ continue
+ if in_string:
+ continue
+ if c == "{":
+ depth += 1
+ elif c == "}":
+ depth -= 1
+ if depth == 0:
+ return i + 1
+ return None
+
+
+def _extract_embedded_spec(html: str) -> dict[str, Any] | None:
+ """Scan an HTML page for an embedded OpenAPI / Swagger spec."""
+ for marker in _EMBEDDED_SPEC_MARKERS:
+ idx = 0
+ while True:
+ i = html.find(marker, idx)
+ if i < 0:
+ break
+ start = i + len(marker)
+ while start < len(html) and html[start] in " \t\r\n":
+ start += 1
+ end = _find_matching_brace(html, start)
+ if end is None:
+ idx = i + 1
+ continue
+ try:
+ obj = json.loads(html[start:end])
+ except json.JSONDecodeError:
+ idx = i + 1
+ continue
+ if isinstance(obj, dict) and ("openapi" in obj or "swagger" in obj):
+ return obj
+ idx = end
+ return None
+
+
+def fetch_openapi(
+ base_url: str,
+ *,
+ timeout: float = 10.0,
+ path: str | None = None,
+ headers: dict[str, str] | None = None,
+ query_params: dict[str, str] | None = None,
+) -> dict[str, Any]:
+ """Fetch the OpenAPI spec at ``base_url`` and return the parsed dict."""
+ explicit_path = path is not None
+ full_url = (
+ path
+ if path and (path.startswith("http://") or path.startswith("https://"))
+ else f"{base_url.rstrip('/')}{path or '/openapi.json'}"
+ )
+ merged_headers = {"User-Agent": "openbb-cli/1.0"}
+ if headers:
+ merged_headers.update(headers)
+
+ response = httpx.get(
+ full_url,
+ timeout=timeout,
+ follow_redirects=True,
+ headers=merged_headers,
+ params=query_params or None,
+ )
+ if response.status_code < 400:
+ try:
+ return _ensure_openapi_dict(
+ _parse_spec_text(
+ response.text,
+ content_type=response.headers.get("content-type", ""),
+ ),
+ full_url,
+ )
+ except (json.JSONDecodeError, ValueError):
+ pass
+
+ if explicit_path:
+ response.raise_for_status()
+ return _ensure_openapi_dict(
+ _parse_spec_text(
+ response.text,
+ content_type=response.headers.get("content-type", ""),
+ ),
+ full_url,
+ )
+
+ landing_url = base_url.rstrip("/") + "/"
+ landing = httpx.get(
+ landing_url,
+ timeout=timeout,
+ follow_redirects=True,
+ headers=merged_headers,
+ params=query_params or None,
+ )
+ landing.raise_for_status()
+ embedded = _extract_embedded_spec(landing.text)
+ if embedded is not None:
+ return embedded
+
+ response.raise_for_status()
+ return _ensure_openapi_dict(
+ _parse_spec_text(
+ response.text,
+ content_type=response.headers.get("content-type", ""),
+ ),
+ full_url,
+ )
+
+
+def _ensure_openapi_dict(parsed: Any, source_url: str) -> dict[str, Any]:
+ """Reject parsed bodies that aren't a JSON object (and thus can't be an OpenAPI doc)."""
+ if not isinstance(parsed, dict):
+ raise ValueError(
+ f"{source_url!r} returned a "
+ f"{type(parsed).__name__}, not an OpenAPI document. "
+ "Pass --openapi-path to point at the real spec endpoint "
+ "(e.g. /swagger/v1/swagger.json)."
+ )
+ return parsed
diff --git a/cli/openbb_cli/dispatchers/protocol.py b/cli/openbb_cli/dispatchers/protocol.py
new file mode 100644
index 00000000000..666032fe478
--- /dev/null
+++ b/cli/openbb_cli/dispatchers/protocol.py
@@ -0,0 +1,42 @@
+"""Wire-format models for the NDJSON dispatcher protocol."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class Request(BaseModel):
+ """One request line on the NDJSON stream."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ id: str | None = Field(
+ default=None,
+ description="Opaque correlation id echoed on the response. Generated client-side.",
+ )
+ command: str = Field(
+ description="Dotted command path, e.g. 'economy.gdp' or 'equity.price.historical'.",
+ )
+ params: dict[str, Any] = Field(default_factory=dict)
+
+
+class ResponseError(BaseModel):
+ """Structured error attached to a failed Response."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ type: str
+ message: str
+
+
+class Response(BaseModel):
+ """One response line on the NDJSON stream."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ id: str | None = None
+ ok: bool
+ result: Any = None
+ error: ResponseError | None = None
diff --git a/cli/openbb_cli/dispatchers/runtime.py b/cli/openbb_cli/dispatchers/runtime.py
new file mode 100644
index 00000000000..c6bfaf4d7b0
--- /dev/null
+++ b/cli/openbb_cli/dispatchers/runtime.py
@@ -0,0 +1,473 @@
+"""Runtime entry points for the dispatcher subsystem."""
+
+from __future__ import annotations
+
+import argparse
+import ast
+import asyncio
+import json
+import os
+import sys
+from collections.abc import Iterable
+from typing import IO, Any
+
+from openbb_cli.dispatchers.base import Dispatcher
+from openbb_cli.dispatchers.protocol import Request, Response, ResponseError
+
+DEFAULT_BATCH_CONCURRENCY = 8
+
+
+_JSON_LITERALS = {"true": True, "false": False, "null": None}
+
+
+def _coerce_literal(text: str) -> Any:
+ """Best-effort coerce a CLI string to a Python literal."""
+ if text in _JSON_LITERALS:
+ return _JSON_LITERALS[text]
+ try:
+ return ast.literal_eval(text)
+ except (ValueError, SyntaxError):
+ return text
+
+
+def parse_argv(argv: Iterable[str]) -> Request:
+ """Convert positional+kv argv into a Request."""
+ args = list(argv)
+ if not args:
+ raise SystemExit("usage: openbb [--key value | --key=value]")
+ command = args[0]
+ params: dict[str, Any] = {}
+ i = 1
+ while i < len(args):
+ token = args[i]
+ if not token.startswith("--"):
+ raise SystemExit(f"Unexpected positional argument: {token!r}")
+ body = token[2:]
+ if "=" in body:
+ key, _, value = body.partition("=")
+ params[key.replace("-", "_")] = _coerce_literal(value)
+ i += 1
+ continue
+ if i + 1 < len(args) and not args[i + 1].startswith("--"):
+ params[body.replace("-", "_")] = _coerce_literal(args[i + 1])
+ i += 2
+ else:
+ params[body.replace("-", "_")] = True
+ i += 1
+ return Request(command=command, params=params)
+
+
+async def _run_one(dispatcher: Dispatcher, request: Request) -> Response:
+ return await dispatcher.dispatch(request)
+
+
+def _to_json_line(response: Response) -> str:
+ """Serialize a Response to a single JSON line, tolerating non-serializable values."""
+ return json.dumps(
+ response.model_dump(exclude_unset=True, exclude_none=True),
+ default=str,
+ )
+
+
+def run_argv(dispatcher: Dispatcher, argv: Iterable[str]) -> int:
+ """Dispatch a single command from argv and print the JSON response."""
+ request = parse_argv(argv)
+ response = asyncio.run(_run_one(dispatcher, request))
+ sys.stdout.write(_to_json_line(response) + "\n")
+ sys.stdout.flush()
+ return 0 if response.ok else 1
+
+
+async def _batch_loop(
+ dispatcher: Dispatcher,
+ reader: IO[str],
+ writer: IO[str],
+ *,
+ concurrency: int,
+) -> int:
+ """Core async loop for ``run_batch``."""
+ sem = asyncio.Semaphore(concurrency)
+ in_flight: set[asyncio.Task[Response]] = set()
+ write_lock = asyncio.Lock()
+ failures = 0
+
+ async def _drain(task: asyncio.Task[Response]) -> None:
+ nonlocal failures
+ try:
+ response = await task
+ finally:
+ sem.release()
+ async with write_lock:
+ writer.write(_to_json_line(response) + "\n")
+ writer.flush()
+ if not response.ok:
+ failures += 1
+
+ loop = asyncio.get_running_loop()
+
+ def _readline() -> str:
+ return reader.readline()
+
+ while True:
+ line = await loop.run_in_executor(None, _readline)
+ if not line:
+ break
+ line = line.strip()
+ if not line:
+ continue
+ payload: Any = None
+ try:
+ payload = json.loads(line)
+ request = Request.model_validate(payload)
+ except Exception as exc: # noqa: BLE001
+ err = Response(
+ id=payload.get("id") if isinstance(payload, dict) else None,
+ ok=False,
+ error=ResponseError(type="RequestParseError", message=str(exc)),
+ )
+ async with write_lock:
+ writer.write(err.model_dump_json() + "\n")
+ writer.flush()
+ failures += 1
+ continue
+
+ await sem.acquire()
+ task = asyncio.create_task(_run_one(dispatcher, request))
+ in_flight.add(task)
+ task.add_done_callback(in_flight.discard)
+ asyncio.create_task(_drain(task))
+
+ if in_flight:
+ await asyncio.gather(*in_flight, return_exceptions=True)
+ return 0 if failures == 0 else 1
+
+
+def run_batch(
+ dispatcher: Dispatcher,
+ reader: IO[str] | None = None,
+ writer: IO[str] | None = None,
+ *,
+ concurrency: int | None = None,
+) -> int:
+ """Read NDJSON requests from ``reader``, write responses to ``writer``."""
+ reader = reader if reader is not None else sys.stdin
+ writer = writer if writer is not None else sys.stdout
+ if concurrency is None:
+ concurrency = int(
+ os.environ.get("OPENBB_CLI_BATCH_CONCURRENCY", DEFAULT_BATCH_CONCURRENCY)
+ )
+
+ async def _run() -> int:
+ try:
+ return await _batch_loop(
+ dispatcher, reader, writer, concurrency=concurrency
+ )
+ finally:
+ await dispatcher.aclose()
+
+ return asyncio.run(_run())
+
+
+def build_parser() -> argparse.ArgumentParser:
+ """Build the CLI argument parser used by ``openbb_cli.cli:main``."""
+ parser = argparse.ArgumentParser(
+ prog="openbb",
+ description="OpenBB Platform CLI. Default mode is non-TTY.",
+ add_help=True,
+ )
+ mode = parser.add_mutually_exclusive_group()
+ mode.add_argument(
+ "-i",
+ "--interactive",
+ action="store_true",
+ help="Enter the interactive REPL with rich output and prompt-toolkit completion.",
+ )
+ mode.add_argument(
+ "--batch",
+ action="store_true",
+ help="Read NDJSON requests from stdin; emit NDJSON responses to stdout.",
+ )
+ mode.add_argument(
+ "--generate-spec",
+ action="store_true",
+ help=(
+ "Fetch ``openapi.json`` from --server and write a precomputed "
+ ".spec file to --output. Use --spec on subsequent invocations "
+ "to skip the OpenAPI fetch + parse on every call."
+ ),
+ )
+ mode.add_argument(
+ "--generate-extension",
+ action="store_true",
+ help=(
+ "Generate a full installable OpenBB Platform extension package "
+ "from a .spec file (--spec PATH). Each spec becomes one provider "
+ "with its own router. After generation: ``pip install -e