Files
OpenBB/cli/tests/test_dispatchers_http.py
Danglewood bbf1ab2020 V5 (#7489)
* [Feature] OpenBB V5: Initial `openbb-core` Refactor (#7482)

* initial refactor

* ruff does not dictate style

* unresolved imports in test action

* fix general linting

* resolve pandas imports for actions run

* httpx in test workflow

* pytest-asyncio in test workflow

* pandas.Index(...)

* max version for windows CI is 3.14

* remove temp refactor files that shouldn't have been committed:

* use ruff format instead of black

* formatting and typing

* tab space helper

* test fixtures..

* ty check

* also run integration test in CI

* remove that annoying labeler action

* [V5] `openbb-core`: 100% Test Coverage (#7484)

* increase test coverage to exercise 100% of the testable code path in openbb-core

* model_fields syntax cleanup

* set the environment variable for CI runs

* run codeql on v5 branch

* more CI env setup

* cleanup redundant files

* ruff

* dedupe workflow trigger

* some windows coverage missing

* ubuntu integration test job

* more windows coverage

* test_reference_generator

* test_docstring_generator

* remove line no references from test function docstrings

* clean useless comments from test files

* fix polars type check

* fix test_obbject

* fix test_obbject

* package builder fixes

* charting pyproject version

* fix docstring generator for nested models

* missing coverage

* [V5] `openbb-core`: Add Backwards-Compatible Layered Config System (openbb.toml) (#7492)

* add backwards-compatible layered config system

* fix path reference in windows test

* need python version-aware handling in docstring generator

* update for deprecated pydantic syntax

* ruff UP007

* test_system_settings

* remove v4 platform workflow

* [V5] Refactor `openbb-oecd` for full SDMX 2 API Exposure and V5 Packaging (#7413)

* stash some changes

* add more robust testing

* mypy

* point PR at V5

* introduce spec file

* codespell

* test fix

* fix workflow environment setup

* fix workflow environment setup

* fix workflow environment setup

* add pyyaml to dependencies

* split lint jobs

* fix workflow environment setup

* fix workflow environment setup

* workflow env setup

* workflow env setup

* clean up code comments

* add auth hook entrypoints

* codespell

* add codegen feature

* codespell

* move _unpack into dispatchers for consistency with codegen packages

* surface nested models in the response

* fix missing coverage in CI

* socrata updates

* test fix

* detect plotly output

* add --include and --exclude flags from generate-extension command

* refactor openbb-platform-api for V5 operations and spec file handling

* delete poetry.lock

* add test workflow

* add temp branch id for test workflow

* add httpx to dev dependencies

* add a conftest.py

* tweak conftest

* tweak conftest

* python 3.10 issues

* more python 3.10

* more python 3.10

* more python 3.10

* more python 3.10

* fix url template resolution

* multi-spec support

* fix a windows path issue

* merge branch develop

* table builder, integration tests, and more

* linting

* merge branch develop

* bit of cleanup

* some more updates, cleanup, and apps.json

* codespell

* black

* integration test params and linting

* mypy

* pylint

* black

* more pylint

* recapture teest cassettes

* black again

* more linting

* table presentation choices

* black

* fix merge conflict

* split up metadata module and reformat cache to use msgpack

* mypy

* regenerate lock file

* use json and include dependencies to make openbb-oecd a standalone install

* pylint

* more linting

* serve metadata as dependency instead of assign and initialize

* fix integration_tests_testers to not count dependency injections as paramters

* add assets to git ignore

* ruff

* v5 refactor and publish workflow

* test coverage and ty fixes

* codespell

* update lock file

* workflow setup

* ty check

* workflow setup

* Scope PR to OECD changes only

Restore non-OECD files in the diff against v5 to their v5 state. The
branch was originally created off feature/cli-2 and inherited 186 files
of unrelated changes (cli, agent_server, mcp_server, platform_api,
core/, .github/workflows/* not oecd). Those have nothing to do with the
openbb-oecd v5 refactor and were polluting the PR diff.

* scope cleanup: include leaked v5-resident files (general-linting workflow, platform_api test fixtures)

---------

Co-authored-by: deeleeramone <>
Co-authored-by: Copilot <copilot@github.com>

* fix docstring generator and body param unwrapping

* project ty.toml file

* unpack POST parameters from params: Model

* remove comment chatter

* [V5] `openbb-oecd`: Rename `oecd_utils` as `oecd` (#7509)

* rename oecd_utils to oecd and populate all models there when openbb-economy is not installed

* linting

* bump dependencies

* fix test drift

* linter

* FieldInfo import

* [V5] `openbb-imf`: Refactor for V5 (#7508)

* refactor imf extension for V5

* codespell

* fix some python 3.10 items

* return self on copy of metadata object

* [V5] `openbb-news`: Refactor News for V5 and Add Configurable RSS Endpoint (#7507)

* refactor news for v5 and add rss endpoint

* remove poetry.lock

---------

Co-authored-by: deeleeramone <>

* [V5] `openbb-quantitative`: Refactor for V5 (#7506)

* refactor quantitative for v5

* add factors endpoint and add a few charts

---------

Co-authored-by: deeleeramone <>

* [V5] `openbb-technical`:  Refactor for V5 & Add More Quick Win Endpoints (#7502)

* refactor openbb-technical for v5

* forward refs

* linting

* no python 3.15 in test matrix

* reame

* ty

* move all params to body

---------

Co-authored-by: deeleeramone <>

* remove stockgrid provider (#7486)

* [V5] remove wsj provider and related code (#7485)

* remove wsj provider and related code

* purge from metadata

* actions

* actions

* openbb-core publish workflow

* delete biztoc (#7523)

* [V5] Remove `openbb-alpha-vantage` (#7522)

* delete alpha vantage

* more files

---------

Co-authored-by: Theodore Aptekarev <aptekarev@gmail.com>

* [V5] Remove `openbb-benzinga` (#7521)

* delete benzinga

* more references

---------

Co-authored-by: Theodore Aptekarev <aptekarev@gmail.com>

* [V5] `openbb-bls`: Refactor for V5 + Add Many Endpoints (#7518)

* refactor bls for v5, add 100 widgets

* some CI fixes

* no python 3.15

* fix missing dev group in pyproject.toml

* more pyproject fallout

* action

* refactor econometrics for v5 (#7505)

Co-authored-by: deeleeramone <>

* [V5] `openbb-famafrench`: Refactor for V5 (#7503)

* refactor famafrench for v5

* codespell

* finishing touches

---------

Co-authored-by: deeleeramone <>

* [V5] CLI 2.0 - Pluggable Backends, Non-TTY Default, HTTP Dispatcher, Spec Files + Codegen (#7433)

* stash some changes

* add more robust testing

* mypy

* point PR at V5

* introduce spec file

* codespell

* test fix

* fix workflow environment setup

* fix workflow environment setup

* fix workflow environment setup

* add pyyaml to dependencies

* split lint jobs

* fix workflow environment setup

* fix workflow environment setup

* workflow env setup

* workflow env setup

* clean up code comments

* add auth hook entrypoints

* codespell

* add codegen feature

* codespell

* move _unpack into dispatchers for consistency with codegen packages

* surface nested models in the response

* fix missing coverage in CI

* socrata updates

* test fix

* detect plotly output

* add --include and --exclude flags from generate-extension command

* cap test matrix at python 3.14

* no useless comments

* platform controller command description split

* merge URL overloads from path params

* exclude none and unset from model dump

---------

Co-authored-by: deeleeramone <>
Co-authored-by: Copilot <copilot@github.com>

* general-linting

* downgrade aiohttp in devtools for vcrpy

* some ty items and add requests-types stubs for ty in devtools

* markdown linting

* remove integration tests from extensions that were moved into the famafrench provider

* [V5] `openbb-congress-gov`: Refactor for V5 + Convert To Using Bulk Files Instead of `api.congress.gov`. (#7528)

* refactor congress-gov for v5 and use bulk files instead of API with credentials

* integration tests

* apps tab name

* convert caching to sql

* codespell

* [V5] [BugFix] Fix for Breaking Changes in FastAPI 0.137+ (#7537)

* fix route introspection for fastapi 0.137

* recapture oeccd gdp forecast test cassette

---------

Co-authored-by: deeleeramone <>

* [V5] `openbb-platform-api`: Refactor for V5 Spec, Config, Packaging, Test Coverage (#7494)

* stash some changes

* add more robust testing

* mypy

* point PR at V5

* introduce spec file

* codespell

* test fix

* fix workflow environment setup

* fix workflow environment setup

* fix workflow environment setup

* add pyyaml to dependencies

* split lint jobs

* fix workflow environment setup

* fix workflow environment setup

* workflow env setup

* workflow env setup

* clean up code comments

* add auth hook entrypoints

* codespell

* add codegen feature

* codespell

* move _unpack into dispatchers for consistency with codegen packages

* surface nested models in the response

* fix missing coverage in CI

* socrata updates

* test fix

* detect plotly output

* add --include and --exclude flags from generate-extension command

* refactor openbb-platform-api for V5 operations and spec file handling

* delete poetry.lock

* add test workflow

* add temp branch id for test workflow

* add httpx to dev dependencies

* add a conftest.py

* tweak conftest

* tweak conftest

* python 3.10 issues

* more python 3.10

* more python 3.10

* more python 3.10

* more python 3.10

* fix url template resolution

* multi-spec support

* fix a windows path issue

* cap test matrix at python 3.14

* no 3.15 runners for platform-api

* no useless comments

* platform controller command description split

* merge URL overloads from path params

* exclude none and unset from model dump

* markdown lint

* ignore get/post endpoints that are return SSE

* fix remaining fastapi 0.137 fallout

---------

Co-authored-by: deeleeramone <>
Co-authored-by: Copilot <copilot@github.com>

* add full support for AsyncIterator response type for streaming output (#7530)

* [V5] Refactor Shared Pass Through Router Extensions For Hatchling (#7524)

* refactor shared pass-through routers for hatchling and ruff + ty

* add publish workflows

* [V5] `openbb-charting`: Refactor for V5 and Decouple  (#7545)

* refactor charting for v5 patterns and decouple entirely

* coveragerc

* lint and tests

* fix tests

* configure dev group with openbb-technical

* ty

* fix cli test

* remove 3.15 from test matrix - dependencies are unstable

---------

Co-authored-by: deeleeramone <>

* fix merge widgets

* workflow cleanup"

* fixes for ty

* fogot that file

* general lint setup

* consolidate lint job

* remove seeking alpha provider (#7570)

* remove tradier provider (#7573)

* remove tiingo provider (#7574)

* remove intrinio provider (#7575)

Remove the openbb-intrinio provider extension from the repository.
Reason: proprietary vendor with no internal access to any endpoint;
cannot build, develop or maintain.

* remove yfinance provider from repo (#7576)

* fix(cli): resolve modular OpenAPI references (#7586)

Fixes #7585

* [V5] `openbb-cli`: Modular OpenAPI Refs (#7589)

* follow up to #7585

* add another codegen test

* unused ty ignore

* Update generated tests to get python parameter names instead of raw API-provider parameter names. (#7597)

* Update the record-vs-envelope heuristic in the payload processing code to not treat a record that happens to have a list datatype as an entire envelope. (#7598)

* Update class generation code to handle empty data that php-style serialization emits as []. (#7599)

* fix ty check failures exposed by newer version

* add drugs.com and brutalist as news rss feeds (#7602)

Co-authored-by: deeleeramone <>

* codespell

* missing test coverage that appeared in famafrench..

* plotly 7 handling for mapbox namespace change

* remove finviz (#7577)

* remove trading economics provider (#7578)

* remove openbb-multpl from repository (#7610)

* add httpx2 to devtools for starlette test client

* restore test coverage to 100% in openbb_core

* remove fmp provider extension from repository (#7665)

* unpin ruff

* fix charting conftest and missing coverage in 3.10

* add charting coverage

* fix new ty issues

* fix tag grouping in swagger docs

* [V5] `openbb-ecb`: Refactor for V5 and Expand Coverage (#7560)

* refactor openbb-ecb for v5 and expand coverage

* some updates

* tests

* workflow

* remove 3.15 from test matrix

* some more table schema updates

* remove some html chrome on embedded articles

---------

Co-authored-by: deeleeramone <>

* [V5] `openbb-federal-reserve`: Refactor for V5 + FFIEC & Regional Fed Routers (#7561)

* refactor for v5

* add workflows

* fix bhcpr schema

* fix pyproject.toml

* fix test

* codespell

* ty

* fix pyproject

* fix ci environment

* fix ci environment

* convert documents endpoints to use fedinprint service

* codeql fix

* fix ny publications and add ny nowcast

* [V5] `openbb-eia`: Refactor for V5 + Add Full API Implementation (#7584)

* eia refactor for v5

* add test and publish workflows

* data browser state

* fix workflows

* more workflow test items

* temp files in tests

* more fixes

* more browsers

* fix codeql issues

* fix mer state and ensure multi-user capability

* uv lock

* table bridge

* fix electricity browser state

* convert HTML widgets to iFrame

* fix recursion error in workspace mcp

* [V5] `openbb-government-us`: Refactor for V5 + Move `uscongress` (#7604)

* v5 government_us provider

* workflow files

* ty and add xlrd to pyproject

* shrink test cassettes

* dev dependencies

* wheel packaging and more tests

* python 3.10 thing..?

* fix test

* default state for multiselect dropdowns

* replace congress html widgets with iframes

* fallback to congress api for congress before 108

* ensure member passage voting count populates

* [V5] Feature: Add JODI World Oil & Gas Database (#7593)

* add the JODI oil and gas database as a provider/router extension

* lint fixes

* add grouping to apps.json

---------

Co-authored-by: deeleeramone <>

* [V5] `openbb-cftc`: Refactor for V5 + Add OIS, Forward Curve, and FX Trade Analysis (#7615)

* refactor openbb-cftc for v5

* add SHIBOR to fixings

---------

Co-authored-by: deeleeramone <>

* fix for plotly 7 breaking change to schemas

* [V5] `openbb-tmx`: Refactor extension for V5 (#7668)

* refactor tmx provider for v5

* fix tests..

* fix tests stack trace exposure

* fix workflow setup

* [V5] `openbb-nasdaq`: Refactor provider for V5. (#7670)

* refactor nasdaq provider for v5

* add workflows

* ty fix

* [V5] `openbb-fred`: Refactor as V5 extension. (#7669)

* refactor fred extension for v5

* fix markdown MD037 lint error in README

* codespell

* [V5] `openbb-cboe`: Refactor for V5 + Add Payoff Diagrams (#7617)

* refactor cboe for v5

* use zoneinfo instead

* fix test

* some fixes related to the cache access

* fix a couple of tests

* fix the test coverage

* [V5] `openbb-deribit`: Refactor provider for V5 (#7674)

* refactor openbb-deribit for v5

* restructure the optimizer around the published combos data and add integration test suites

* fix markdown lint errors

* remove openbb-econdb provider (#7675)

* codespell

* [V5] `openbb-sec`: Refactor for V5 (#7525)

* refactor openbb-sec for v5

* linting

* ty

* normalize compare_company_facts

* add workflows

* python 3.10 fix

* add more thorough url validation and tests

* ty check

* ty for python 3.10

* reconfigure with apps

* temp pyproject overrride

* pyproject

* pyproject

* pyproject

* codespell

* pyproject

* fix uk schema file links in schema_files

* test coverage

* test_cli

* port #7547 into this pr

* no hard coded prefix

* improve filing rendering capabilities

* fix xml text detection

* make codeql happy

* missed a file in the commit

* test coverage

* codespell

* fix flakey test

* some updates

* management_ownership --> management_profiles

* proxy statement markdown

* ruff

* fix test coverage

* more tests

* test coverage

* fix lint errors

* Add SEC adviser search, profile, and universe routes (#7559)

* Add SEC institutional data fetchers

* Add SEC filing index and Form D routes

* Add SEC investment adviser people data

* Expose SEC adviser and institutional routes

* fix(sec): address adviser review feedback

* refactor(sec): narrow adviser support to IAPD search

* feat(sec): add adviser firm and individual searches

* feat(sec): add adviser profile drill-down

* fix: repair pre-commit configuration

* feat(sec): add adviser universe route

* feat(sec): expose complete Form ADV data

---------

Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com>

* fix test coverage

---------

Co-authored-by: deeleeramone <>
Co-authored-by: Theodore Aptekarev <aptekarev@gmail.com>

* [V5] `openbb-finra`:  Refactor for V5 (#7676)

* refactor finra provider for v5

* add workflows

* fix tests

* fix lint errors

* [V5] Update Repository License to Apache 2.0 (#7677)

* change license to Apache 2.0 for V5

* codespell

* bump devtools and repoint pyproject.toml

* Enterprise Flask Migration Toolkit (#7264)

* feat: Add Flask-to-OpenBB enterprise migration toolkit

- Implement Router.from_flask() for zero-touch legacy API conversion
- Enable 4-hour migration vs 6-month traditional rewrite (80% cost reduction)
- Generate FastAPI endpoints from Flask routes with automatic type validation
- Support enterprise consulting services and custom integration workflows

Demonstrates OpenBB's competitive advantage for institutional client migrations.
Complements existing Workspace applications like Open Portfolio suite.

* chore: Resolve .gitignore conflict and add common ignores

* refactor: implement Phase 1 Flask integration architecture- Move Flask conversion logic to openbb_core.app.utils.flask- Add Flask detection to extension_loader - Integrate with OpenBB's core extension system- Add Flask dependency validation with sys.modules checkingAddresses @deeleeramone's architectural feedback for native OpenBB integration.

* docs: add Flask direct entry point demos- demo_direct_flask_entry_point.py: shows direct Flask instance reference- example_pyproject_flask_entry.toml: demonstrates pyproject.toml configurationSupports new architecture eliminating wrapper extensions.

* Implement Phase 1 Flask adapter per code review feedback

- Minimal working implementation with Flask detection and router creation
- Removes complex code generation logic for focused Phase 1 delivery
- Establishes foundation for Phase 2 route conversion features

* Fix Flask integration: Replace missing FlaskToOpenBBAdapter with WSGIMiddleware

- Remove FlaskToOpenBBAdapter which was not implemented
- Use FastAPI's WSGIMiddleware to mount Flask apps directly
- Update extension_loader to use returned Router object
- Convert utils.py to utils/__init__.py to support flask submodule
- Addresses PR review comment about unhandled AttributeError

* Address PR review feedback: fix Flask imports and remove unused files

- Revert .gitignore to upstream/develop
- Remove AI-generated demo/example files and flask_adapter extension
- Move Flask/werkzeug imports inside conditional blocks to avoid ImportError
- Use lazy imports via __getattr__ in utils/flask/__init__.py
- Add Flask tests with @pytest.mark.skipif markers

* Force APIRouter.include_router to propagate Mount paths instead of dropping them. (#1)

This change ensures that mounted ASGI applications (such as Flask apps wrapped in WSGIMiddleware) are not dropped when `APIRouter.include_router` is called.

Previously, while the initial load in `ExtensionLoader` worked as expected, the `Mount` objects were ignored by FastAPI's default inclusion logic. This caused the mounted elements to be lost during the final application assembly, resulting in 404 errors for the Flask routes.

This fix overrides the inclusion logic to manually propagate `Mount` objects downstream.

* feat(flask): Add metadata layer for OpenAPI documentation

Enhance Flask-to-OpenBB converter with structured docstring parsing:
- Parse Google-style docstrings (summary, description, params)
- Extract parameter descriptions from Args section
- Pass metadata to Router.command() for rich OpenAPI docs

This addresses PR feedback to provide tangible benefit by generating
comprehensive API documentation for migrated Flask routes.

* feat(flask): Implement manual OpenAPI spec generation and attachment

- Add FlaskParam and FlaskRouteInfo TypedDict for type-safe metadata
- Implement OpenAPISpecGenerator for Flask→OpenAPI conversion
- Extract route metadata via FlaskIntrospector
- Attach generated spec to router.openbb_openapi_spec_ext
- Add unit tests (3/3 passing)

Addresses WSGI/mount boundary issue where Router.command metadata
cannot propagate through WSGIMiddleware. Spec attached to router
instance for downstream OpenAPI assembly.

Resolves maintainer feedback on PR #7264

* feat(flask): Implement FlaskMountRegistry for multiple mount support

- Add FlaskMountRegistry class for centralized OpenAPI spec management
- Replace router.openbb_openapi_spec_ext with registry pattern
- Support multiple Flask apps at different mount prefixes
- Automatic path rewriting in aggregated OpenAPI specs
- Add registry test case (4/4 tests passing)

Addresses @deeleeramone's feedback on PR #7264 regarding router
attribute fragility and multiple mount support. Registry provides
lifecycle-independent state management and enables enterprise
scenarios with multiple Flask apps.

Resolves: OpenBB-finance/OpenBB#7264 (partial)

* feat(core): load Flask apps as openbb_core_extension WSGI sub-apps

A Flask app referenced directly by an openbb_core_extension entry point is
mounted via WSGIMiddleware and its routes are introspected into openapi.json
(operationId/widgetId/mcp_tool, parameters, request bodies, security, widget_config
and HTML widgets) so the widget factory and MCP server can discover them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(core): 100% coverage for flask utils + flask dev dependency

Add branch-coverage tests for the Flask integration package (introspection,
AST analysis, docstrings, OpenAPI generation, registry, loader, detection) and
declare flask as a dev dependency so the suite runs in CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(flask): treat f-string returns as HTML responses

* fix(flask): mount via a2wsgi instead of deprecated starlette WSGIMiddleware

- add openbb-core[flask] extra (flask, a2wsgi); a2wsgi resolved through require_optional
- run the WSGI serve test through mount_flask_extensions; drop httpx importorskip
- core CI installs [pandas,flask] and httpx2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(flask): cover failed entry point loads; narrow optional types for ty

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com>
Co-authored-by: deeleeramone <>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* perf(core): vectorize the date conversion in `basemodel_to_df` (#7622)

* perf(core): vectorize the date conversion in basemodel_to_df

basemodel_to_df ran three per-element Python passes over the date column of
every payload it converted. Series.apply(to_datetime) calls pandas.to_datetime
once per row, and because each call receives a lone scalar with no format hint,
pandas also re-runs its datetime format guesser on every row.

Parse the column in one call instead, and use vectorized comparisons for the
all-midnight test and the reduction to datetime.date.

The element-wise parse is kept as a fallback for one case that the vectorized
path cannot represent: a column mixing UTC offsets, such as a series crossing a
daylight-savings boundary. Parsing per element lets each value keep its own
offset and yields an object column of tz-aware Timestamps, whereas a single
to_datetime call over that column raises "Mixed timezones detected". That
behaviour is covered by test_to_df_daylight_savings and is preserved exactly.

Measured on a payload built through EquityHistoricalData, median of 9 under an
exclusive CPU lock:

  250 daily bars     8.92 ms -> 1.46 ms
  1,000 daily bars  32.93 ms -> 3.13 ms
  5,000 daily bars 162.25 ms -> 12.23 ms

* fix a broken test - unrelated to this PR...

---------

Co-authored-by: Danglewood <85772166+deeleeramone@users.noreply.github.com>
Co-authored-by: deeleeramone <>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix lint errors

* fix oecd lint error

* [V5] `openbb-mcp-server`: Refactor for v5 - Spec, Config, Packaging, Test Coverage (#7495)

* stash some changes

* add more robust testing

* mypy

* point PR at V5

* introduce spec file

* codespell

* test fix

* fix workflow environment setup

* fix workflow environment setup

* fix workflow environment setup

* add pyyaml to dependencies

* split lint jobs

* fix workflow environment setup

* fix workflow environment setup

* workflow env setup

* workflow env setup

* clean up code comments

* add auth hook entrypoints

* codespell

* add codegen feature

* codespell

* move _unpack into dispatchers for consistency with codegen packages

* surface nested models in the response

* fix missing coverage in CI

* socrata updates

* test fix

* detect plotly output

* add --include and --exclude flags from generate-extension command

* refactor mcp server for v5

* test action

* fix tests

* tests

* test fixture name

* revert: drop unrelated provider/README changes from mcp-server refactor

Strip the changes to ``README.md`` and ``openbb_platform/providers/{imf,
tradingeconomics,yfinance}`` that snuck into 4ac3602 (refactor mcp server
for v5). Those files are out of scope for the mcp-server work and were
modified inadvertently — restore them to their pre-4ac3602 state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* tests and workflows

* fix workflow

* tests and workflows

* tests and workflows

* tests and workflows

* tests and workflows

* beartype shim for 3.15

* muck around with beartype for 3.15

* muck around with beartype for 3.15

* no 3.15 runners for mcp-server

* update skill files

* cap test matrix at python 3.14

* merge branch v5

* no useless comments

* platform controller command description split

* merge URL overloads from path params

* exclude none and unset from model dump

* refactor for fastmcp4

* wrap markdown lines that are too long

---------

Co-authored-by: deeleeramone <>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* update pre-commit, noxfile, and uv locks

* fixes for docstring generation

* test coverage

* fix cli renderer helper with figure object JSON

* fix the figure font when rendered

* refactor cookiecutter

* bump cookiecutter packagge version

* fix sec test by adding fixture

* fix desktop environments test

* fix(cli): inherit path-item-level OpenAPI parameters (#7606)

* fix(cli): inherit path-item-level OpenAPI parameters

OpenAPI 3.x lets a Path Item Object declare `parameters` shared by every
operation under that path, with operation-level entries overriding them by
(name, in). The codegen read only `op.get("parameters")`, so inherited
parameters were dropped silently — the generated command kept the URL
template but had nothing registered to fill it.

Real-world impact: the Xero Accounting API declares its required
`xero-tenant-id` header once per path item across all 138 paths. Before this
change `build_command_spec` produced 78 commands and none carried the
header, so every generated command 403s. Nothing warned.

Adds `operation_parameters()`, which merges the two levels and dereferences
both, and routes the pre-computed spec path (`build_command_spec`) and the
interactive path (`build_command_index` -> `build_parser_from_operation`)
through it. Unresolvable refs, non-dict entries, and nameless parameters are
skipped as before. Specs without path-item parameters are unaffected.

* fix(cli): narrow path-item inheritance to path and query parameters

Per review: headers do not translate into a Python interface function, and
outbound headers are transport configuration
(SystemSettings.PythonSettings.http.headers, the CLI's -H) or a security-scheme
concern — not a per-command argument.

Path-item `in: header` and `in: cookie` entries are now dropped rather than
inherited. Operation-level parameters are untouched, so an operation that
declares its own header keeps today's behavior.

Scope is now only what cannot be supplied any other way: `in: path` and
`in: query` parameters declared on the Path Item Object.

* fix(cli): merge allOf compositions in response schemas

Response-schema $refs were already expanded by deref_schema, but a schema
whose top level is an allOf composition was left unflattened. The properties
live inside the members, so a consumer reading them off the composition finds
none. fetcher_gen._data_schema looks for `properties.results`, gets nothing,
and falls through to _unwrap_schema_envelopes, which handles oneOf and anyOf
but not allOf. The generated Data model ends up with no fields.

Codat's listBills response is `allOf: [{properties: {results}}, {properties:
{_links, pageNumber, pageSize, totalResults}}]`, which produced an empty model.

merge_allof collapses a composition of object subschemas into one object
schema: properties unioned with the first declaration winning on conflict,
required unioned, and sibling keywords on the allOf node outranking members.
A composition that is not a plain intersection of object subschemas (a member
carrying its own oneOf/anyOf, or a non-object type) is returned unchanged
rather than flattened into something the spec does not describe. Nested
compositions are merged too, so a $ref inside a merged member still resolves.

Applied to extract_response_schema and extract_response_schemas.

Measured across five specs that use the pattern, counting operations whose
response schema exposes readable properties: 244 of 588 before, 394 after
(Codat Accounting 36 to 88, Codat Banking 0 to 8, Codat Commerce 1 to 11,
Rebilly 62 to 104, Box 145 to 183).

* fix(cli): merge allOf compositions in request body schemas

Same gap as the response side. request_body_parameters needs `type: object`
or `properties` to flatten a body into parameters, and an allOf composition
carries neither, so a POST or PUT generated from one gets no body parameters
at all.

extract_request_body_schema now runs merge_allof after deref_schema, matching
extract_response_schema.

Across the same five specs, counting request bodies that produce parameters:
138 of 206 before, 177 after (Codat Accounting 1 to 26 of its 30 bodies,
Rebilly 50 to 63, Box 87 to 88).

* fix(cli): stop annotation keywords blocking an allOf merge

_mergeable_allof_members required every member's keys to come from a small
whitelist, so any unrecognized keyword disqualified the whole composition.
Codat's PagingInfo carries `definitions` next to its properties, which meant
the live Codat-Accounting.yaml never merged and its response models stayed
empty. The APIs.guru copy of the same API omits that key, which is why the
earlier measurements looked clean.

The check is now a blocklist. A member blocks the merge when it carries its
own combinator (oneOf/anyOf/allOf/not), an unresolved $ref left by a cycle,
a scalar or array constraint (enum/const/items), or a type other than object.
Annotation keywords that do not affect the intersection (title, description,
examples, definitions, additionalProperties, vendor x- keys) no longer block
it, and are dropped from the merged result along with the members.

On the live Codat spec, operations whose response schema exposes readable
properties go from 36 of 127 to 114. Across the five specs measured earlier:
244 of 588 before, 488 after (was 394 under the whitelist).

Corpus check over 2,816 response and request-body extractions in 6 specs:
567 changed, every one attributable to an allOf in the pre-merge output,
0 unexplained. Twilio declares no allOf compositions and is unchanged.

* fix(cli): emit a name once when it is both a parameter and a body property

_signature_params appended request-body properties and then operation
parameters without deduplicating, so a name declared in both places produced
two function parameters with the same name and the generated module raised
SyntaxError.

Codat's push endpoints hit this: /push/bankAccounts/{accountId}/bankTransactions
declares accountId as a path parameter and repeats it in the request body, so
connections.push.bankAccounts.bankTransactions generated `accountId: str` and
`accountId: str = None` in one signature.

The operation parameter wins, since it carries the path placeholder and its own
required flag. The request payload is unchanged: _render_body_block derives body
fields from request_body_schema rather than from this list, so accountId is
still sent in the body.

This predates the path-item and allOf work in this PR. On origin/v5 the same
spec produces the same duplicate; on this branch the Codat spec generates 91
commands with 0 duplicated signatures, down from 1.

* fix(cli): use the safe identifier for keyword-named body properties

_render_body_block already writes `{'from': from_}`, keying the payload by the
wire name and reading a safe identifier, but _signature_params declared the raw
name. A body property named after a Python keyword therefore emitted
`from: str = None` and the generated module raised SyntaxError.

This did not fire before the allOf work in this PR, because a composed request
body produced no body parameters at all. Codat's Transfer body is such a
composition and has a `from` property, so connections.push.transfers started
generating invalid syntax once the body flattened.

_signature_params now emits safe_field_name(name)[0], the same helper
fetcher_gen and pydantic_gen already use. The body/query split in
_render_body_block compares against the safe names too: it previously matched
signature names against wire names, so `from_` was not recognized as a body
field and leaked into the query string as a `from_` parameter.

End to end on Codat-Accounting.yaml, generating the full extension and byte
compiling every emitted module: 136 files, 0 failures. origin/v5 emits 1 file
that does not compile (duplicate argument 'accountId').

Known gap left alone: an operation parameter named after a keyword would still
break, since the query and path lines use the raw name as a local variable.
None of the five specs checked declares one.

* fix(cli): do not treat instance data as schema, and sanitize POST identifiers

Two defects found by generating extensions from real specs and byte compiling
every emitted module.

merge_allof and deref_schema walked every value in a schema dict, including
keywords that hold instance data rather than subschemas. A `default` of
`{"allOf": [{"a": 1}, {"b": 2}]}` was rewritten to `{"type": "object"}`, and a
`$ref` key inside an example was resolved as if it were a reference. Both now
copy `default`, `example`, `examples`, `enum`, `const` and vendor `x-` values
through untouched.

_signature_params emitted operation parameter names verbatim, so a wire name
that is not a Python identifier produced invalid syntax. Rebilly declares an
`Organization-Id` header and a `REB-APIKEY` security header, which emitted
`REB-APIKEY: str = None`. It now uses safe_field_name, matching fetcher_gen,
and _render_body_block keys the query dict by the wire name while reading the
safe identifier so the request is unchanged.

This surfaced through the allOf work rather than being caused by it: a composed
request body used to yield no properties, so `has_body` was false in
package_gen and the command was generated as a GET fetcher, where identifiers
were already sanitized. With bodies flattening, 14 of Codat's 24 POST endpoints
correctly move to the POST path, which had never been exercised by these specs.

Generating the full extension and compiling every module:

  Codat-Accounting.yaml   v5: 1 failure    branch: 0   (136 files)
  Rebilly 2.1             v5: 14 failures  branch: 1   (97 files)

The one remaining Rebilly failure is `async def 3dsecure(` in the router, which
fails identically on v5. Sanitizing that is a naming decision about the command
surface, so it is left alone here.

Known gap: a non-credential header parameter is still placed in the query
string on the POST path. Pre-existing, and unchanged by this PR.

* fix(cli): resolve signature collisions on the emitted identifier

The previous dedupe compared wire names, but the failure it prevents depends on
the sanitized identifier. Two wire names can collapse onto one (`from` and
`from_`, `Organization-Id` and `Organization_Id`), and operation_parameters
keeps a name declared twice under different `in` locations by design, so both
cases could still emit a duplicate argument.

_signature_params now tracks the identifiers it has emitted and skips anything
that would repeat one. Emission order is unchanged, so existing signatures are
byte-identical; only genuinely colliding names are dropped, and operation
parameters still win over body properties.

Neither collision occurs in Codat-Accounting.yaml or Rebilly 2.1. This closes
the class rather than the instance, since the instances are what this PR has
been finding.

Generation unchanged: Codat 136 files 0 failures, Rebilly 97 files 1 failure
(`async def 3dsecure(`, identical on v5).

* test(cli): cover the instance-data guard, and drop a redundant branch

Mutation checking the new tests (revert each fix, confirm its test fails)
found two problems with the previous commits.

The guard that stops merge_allof and deref_schema from walking into `default`,
`example`, `enum`, `const` and vendor values shipped with no test at all. It is
now covered from both directions: a default holding an `allOf` key keeps its
value, and a `$ref` inside a default stays a literal while a real reference
beside it still resolves.

The body-property loop also carried a redundant check. It tested the raw wire
name against the operation parameter names before testing the sanitized one,
but `operation_param_names` holds both forms and `safe_field_name` is idempotent
on identifiers, so the first test could never fire alone. Reverting it left
every test passing, which is what surfaced it. Removed, and the sanitized name
is now computed once.

All nine mutations are caught by the suite: both data guards, the allOf
blocklist in each direction, the response merge wiring, body/parameter
precedence, the keyword identifier, the body/query split and the
emitted-identifier dedupe.

cli/tests at 1,954 passed against the same 80 environment failures as the base.
Generation unchanged: Codat 136 files 0 failures, Rebilly 97 files 1 failure.

* fix(cli): route required path-item headers/cookies through credential handling

* fix(cli): narrow Popen.stdin/stdout for ty in run_in_obb fixture

---------

Co-authored-by: deeleeramone <>

* fix warnings raised by federal_reserve on some xlsx workbook parsing

* fix publish workflows

* test publish pipeline with openbb-core-v2.0.0rc0 (#7681)

* fix publish workflows with missing tag job, some tests and bump fastapi

* fix python 3.10 test failure in core

* actually fix python 3.10 test failure in core

* deincrement openbb-core version to allow release pattern

* bump core version (#7682)

* remove uv.sources from pyproject.toml files and point to openbb-core-2.0.0 on PyPI

* remove oecd cache asset that wasn't supposed to be commuitted

* remove poetry.lock, update pyproject.tml and uv.lock for openbb-platform-api-v2.0.0 (#7684)

Co-authored-by: deeleeramone <>

* add the missing publish-openbb-platform-api.yml workflow file

* remove poetry.lock, update pyproject.tml and uv.lock for openbb-platform-api-v2.0.0 (#7685)

Co-authored-by: deeleeramone <>

* fix the tag generation after publish

* [Release] `openbb-charting-v4.0.0` (#7686)

* up openbb-charting to 4.0.0 and fill missing test coverage in chart_style

* test coverage and format markdown files

---------

Co-authored-by: deeleeramone <>

* remove scikit-learn from dependencies, update tests, and format markdown files (#7687)

Co-authored-by: deeleeramone <>

* [Release] `openbb-cli-v2.0.0` (#7692)

* fix missing test coverage in openbb_cli

* fix conftest

---------

Co-authored-by: deeleeramone <>

* add missing publish-openbb-cli.yml workflow

* [Release] `openbb-cli-v2.0.0` (#7693)

* fix missing test coverage in openbb_cli

* fix conftest

---------

Co-authored-by: deeleeramone <>

* test fixes for cli...

* [Release] `openbb-cli-v.2.0.0` (#7694)

* fix missing test coverage in openbb_cli

* fix conftest

---------

Co-authored-by: deeleeramone <>

* fix hanging mcp server test

* [Release] `openbb-mcp-server-v2.0.0` (#7695)

Co-authored-by: deeleeramone <>

* update uv.lock (#7696)

Co-authored-by: deeleeramone <>

* release openbb-commodity-v2.0.0 (#7697)

Co-authored-by: deeleeramone <>

* update uv.lock (#7698)

Co-authored-by: deeleeramone <>

* update uv.lock (#7699)

Co-authored-by: deeleeramone <>

* update uv.lock (#7700)

Co-authored-by: deeleeramone <>

* [Release] `openbb-econometrics-v2.0.0` (#7701)

* update uv.lock

* update pyproject.toml

---------

Co-authored-by: deeleeramone <>

* update uv.lock (#7703)

Co-authored-by: deeleeramone <>

* update uv.lock (#7702)

Co-authored-by: deeleeramone <>

* update uv.lock (#7704)

Co-authored-by: deeleeramone <>

* update uv.lock (#7705)

Co-authored-by: deeleeramone <>

* update uv.lock (#7706)

Co-authored-by: deeleeramone <>

* [V5] Fix a Number of V5 Bugs (#7707)

* fix a number of v5 bugs

* fix lint error

* more lint

* fixes for quantitative, technical, charting, famafrench for version bumps

---------

Co-authored-by: deeleeramone <>

* update uv.lock (#7709)

Co-authored-by: deeleeramone <>

* update uv.lock (#7708)

Co-authored-by: deeleeramone <>

* update uv.lock (#7710)

Co-authored-by: deeleeramone <>

* update uv.lock (#7711)

Co-authored-by: deeleeramone <>

* [Release] `openbb-cboe-v2.0.0` (#7713)

Co-authored-by: deeleeramone <>

* update uv.lock (#7714)

Co-authored-by: deeleeramone <>

* update uv.lock (#7717)

Co-authored-by: deeleeramone <>

* [Release] `openbb-ecb-v2.0.0` (#7715)

* update uv.lock

* update uv.lock

---------

Co-authored-by: deeleeramone <>

* update uv.lock (#7718)

Co-authored-by: deeleeramone <>

* update uv.lock (#7719)

Co-authored-by: deeleeramone <>

* update uv.lock (#7720)

Co-authored-by: deeleeramone <>

* add missing publish-openbb-fred.yml to repo

* update uv.lock (#7723)

Co-authored-by: deeleeramone <>

* update uv.lock (#7722)

Co-authored-by: deeleeramone <>

* update uv.lock (#7716)

Co-authored-by: deeleeramone <>

* update uv.lock (#7724)

Co-authored-by: deeleeramone <>

* update uv.lock (#7726)

Co-authored-by: deeleeramone <>

* [Release] `openbb-fred-v2.0.0` (#7725)

* update uv.lock

* fix tests and dependencies

* fix test_fred_fetchers

---------

Co-authored-by: deeleeramone <>

* [Release] `openbb-imf-v3.0.0` (#7727)

* update uv.lock

* fix tests and dependencies

* fix test_fred_fetchers

* bump openbb-imf version to 3.0.0

---------

Co-authored-by: deeleeramone <>

* update uv.lock (#7729)

Co-authored-by: deeleeramone <>

* update uv.lock (#7728)

Co-authored-by: deeleeramone <>

* update uv.lock (#7730)

Co-authored-by: deeleeramone <>

* update uv.lock (#7731)

Co-authored-by: deeleeramone <>

* update uv.lock (#7732)

Co-authored-by: deeleeramone <>

* update uv.lock (#7733)

Co-authored-by: deeleeramone <>

* cookiecutter pyproject and lock

* update pyproject.toml (#7734)

Co-authored-by: deeleeramone <>

* update pyproject.toml (#7735)

Co-authored-by: deeleeramone <>

* update uv.lock (#7736)

Co-authored-by: deeleeramone <>

* fix some sec issues, allegedly (#7737)

Co-authored-by: deeleeramone <>

* update uv.lock (#7738)

Co-authored-by: deeleeramone <>

* update main pyproject.toml

* lint fixes

* update assets (#7739)

Co-authored-by: deeleeramone <>

* [Release] `openbb-v5.0.0` (#7740)

* update assets

* add missing workflow

---------

Co-authored-by: deeleeramone <>

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: deeleeramone <>
Co-authored-by: Theodore Aptekarev <aptekarev@gmail.com>
Co-authored-by: Dex <i@dex.moe>
Co-authored-by: Monte Hoover <monte.b.hoover@gmail.com>
Co-authored-by: Boris Li <65254053+BorisQuanLi@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Lucas Zimmermann <lucaszmayer@gmail.com>
2026-09-29 14:45:53 +00:00

2962 lines
102 KiB
Python

"""Tests for openbb_cli.dispatchers.http — HttpDispatcher against openbb-platform-api."""
import json
import httpx
import pytest
from openbb_cli.dispatchers.http import HttpDispatcher
from openbb_cli.dispatchers.protocol import Request
def _make_dispatcher(handler) -> HttpDispatcher:
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
return HttpDispatcher("http://test", client=client)
@pytest.mark.asyncio
async def test_dispatch_success_translates_dotted_path():
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["body"] = json.loads(request.content) if request.content else {}
return httpx.Response(200, json={"echo": captured["body"]})
d = _make_dispatcher(handler)
try:
resp = await d.dispatch(
Request(
id="x", command="equity.price.historical", params={"symbol": "AAPL"}
)
)
finally:
await d.aclose()
assert resp.ok is True
assert resp.id == "x"
# The single-key ``echo`` wrapper gets stripped by the response
# unwrap, matching how an installed extension surfaces the same row.
assert resp.result == {"symbol": "AAPL"}
assert "/api/v1/equity/price/historical" in captured["url"]
@pytest.mark.asyncio
async def test_dispatch_4xx_yields_http_error():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(404, json={"detail": "not found"})
d = _make_dispatcher(handler)
try:
resp = await d.dispatch(Request(command="nope"))
finally:
await d.aclose()
assert resp.ok is False
assert resp.error is not None
assert resp.error.type == "HTTP404"
@pytest.mark.asyncio
async def test_dispatch_5xx_with_text_body():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, text="internal explosion")
d = _make_dispatcher(handler)
try:
resp = await d.dispatch(Request(command="equity.q"))
finally:
await d.aclose()
assert resp.ok is False
assert resp.error is not None
assert resp.error.type == "HTTP500"
@pytest.mark.asyncio
async def test_dispatch_request_error():
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("dns failed")
d = _make_dispatcher(handler)
try:
resp = await d.dispatch(Request(command="x"))
finally:
await d.aclose()
assert resp.ok is False
assert resp.error is not None
assert resp.error.type == "ConnectError"
@pytest.mark.asyncio
async def test_dispatch_unexpected_exception_isolated():
def handler(request: httpx.Request) -> httpx.Response:
raise RuntimeError("boom")
d = _make_dispatcher(handler)
try:
resp = await d.dispatch(Request(command="x"))
finally:
await d.aclose()
assert resp.ok is False
assert resp.error is not None
assert resp.error.type in {"RuntimeError", "TransportError"}
@pytest.mark.asyncio
async def test_aclose_owns_client_when_constructed_internally():
d = HttpDispatcher("http://x")
assert d._owns_client is True
await d.aclose()
@pytest.mark.asyncio
async def test_aclose_does_not_close_external_client():
transport = httpx.MockTransport(lambda r: httpx.Response(200, json={}))
client = httpx.AsyncClient(transport=transport)
d = HttpDispatcher("http://x", client=client)
await d.aclose()
response = await client.get("http://x/")
assert response.status_code == 200
await client.aclose()
def test_url_for_strips_separators():
d = HttpDispatcher("http://srv/")
assert d._url_for("equity.price") == "http://srv/api/v1/equity/price"
def test_custom_api_prefix():
d = HttpDispatcher("http://srv", api_prefix="custom/v2")
assert d._url_for("ping") == "http://srv/custom/v2/ping"
@pytest.mark.asyncio
async def test_dispatch_unwraps_single_array_envelope():
"""``{"refRates": [...]}`` -> the rows list, matching codegen behavior."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"refRates": [
{"effectiveDate": "2026-04-30", "type": "TGCR"},
{"effectiveDate": "2026-04-29", "type": "TGCR"},
]
},
)
d = _make_dispatcher(handler)
try:
resp = await d.dispatch(Request(command="rates.last"))
finally:
await d.aclose()
assert resp.ok is True
assert resp.result == [
{"effectiveDate": "2026-04-30", "type": "TGCR"},
{"effectiveDate": "2026-04-29", "type": "TGCR"},
]
@pytest.mark.asyncio
async def test_dispatch_splits_metadata_from_rows():
"""Sibling API metadata (NY-Fed-style ``asOfDate`` next to a row array)
lands at its own key under ``extra`` of an OBBject — NOT folded into
``results_metadata`` (which is per-column data) and NOT folded into
``metadata`` (which is execution telemetry). Three distinct concepts,
three distinct keys.
"""
from openbb_core.app.model.obbject import OBBject
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={"asOfDate": "2026-04-30", "operations": [{"id": 1}, {"id": 2}]},
)
d = _make_dispatcher(handler)
try:
resp = await d.dispatch(Request(command="x"))
finally:
await d.aclose()
assert resp.ok is True
assert isinstance(resp.result, OBBject)
assert resp.result.results == [{"id": 1}, {"id": 2}]
# ``asOfDate`` lands directly under ``extra`` — its own key, not
# buried inside ``results_metadata`` (which is per-column) and not
# inside ``metadata`` (which is execution info).
assert resp.result.extra["asOfDate"] == "2026-04-30"
assert "results_metadata" not in resp.result.extra
# ``metadata`` is NOT a top-level OBBject property — it lives under
# ``extra``. The dump confirms the shape.
dumped = resp.result.model_dump()
assert "metadata" not in dumped # never at top level
assert "results_metadata" not in dumped # ditto
@pytest.mark.asyncio
async def test_dispatch_unwraps_array_of_scalars_into_value_field_then_flattens():
"""Single-key envelope around a string array -> a flat list of strings."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"asOfDates": ["2026-04-30", "2026-04-23"]})
d = _make_dispatcher(handler)
try:
resp = await d.dispatch(Request(command="x"))
finally:
await d.aclose()
# Each scalar gets wrapped as ``{value: x}`` by ``unpack_response``;
# ``_shape_result`` then unwraps the synthetic ``value`` key for clean
# rendering.
assert resp.result == ["2026-04-30", "2026-04-23"]
@pytest.mark.asyncio
async def test_dispatch_passes_text_response_through_unchanged():
"""Non-JSON responses (XML, CSV, plain text) bypass the unwrap entirely."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200, text="<xml>raw</xml>", headers={"content-type": "application/xml"}
)
d = _make_dispatcher(handler)
try:
resp = await d.dispatch(Request(command="x"))
finally:
await d.aclose()
assert resp.result == "<xml>raw</xml>"
@pytest.mark.asyncio
async def test_dispatch_returns_payload_unchanged_when_unwrap_yields_nothing():
"""Empty list response: ``unpack_response`` produces ``([], {})``;
the dispatcher leaves the original payload untouched rather than
collapsing to ``None``."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=[])
d = _make_dispatcher(handler)
try:
resp = await d.dispatch(Request(command="x"))
finally:
await d.aclose()
assert resp.result == []
@pytest.mark.asyncio
async def test_dispatch_uses_get_when_command_methods_says_so():
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["method"] = request.method
captured["url"] = str(request.url)
return httpx.Response(200, json={"ok": True})
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher(
"http://t",
client=client,
command_methods={"equity.quote": "get"},
)
try:
await d.dispatch(Request(command="equity.quote", params={"symbol": "AAPL"}))
finally:
await d.aclose()
assert captured["method"] == "GET"
assert "symbol=AAPL" in captured["url"]
@pytest.mark.asyncio
async def test_dispatch_falls_back_to_post_without_method_map():
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["method"] = request.method
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher("http://t", client=client)
try:
await d.dispatch(Request(command="equity.quote", params={"symbol": "X"}))
finally:
await d.aclose()
assert captured["method"] == "POST"
@pytest.mark.asyncio
async def test_dispatch_explicit_method_overrides_map():
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["method"] = request.method
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher(
"http://t",
client=client,
command_methods={"x": "post"},
)
try:
await d.dispatch(Request(command="x"), method="get")
finally:
await d.aclose()
assert captured["method"] == "GET"
def test_http_dispatcher_from_spec_extracts_methods():
from openbb_cli.dispatchers.http import http_dispatcher_from_spec
spec_doc = {
"base_url": "http://h",
"api_prefix": "/api/v1",
"commands": {
"equity.quote": {"method": "get"},
"scratch.rebuild": {"method": "post"},
},
}
d = http_dispatcher_from_spec(spec_doc)
assert d._command_methods == {"equity.quote": "get", "scratch.rebuild": "post"}
assert d._base_url == "http://h"
def test_http_dispatcher_from_server_fetches_and_maps(monkeypatch):
"""``http_dispatcher_from_server`` synthesizes a methods map from openapi.json."""
from openbb_cli.dispatchers import http as http_mod
fake_openapi = {
"paths": {
"/api/v1/x/y": {"get": {}},
"/api/v1/foo/bar": {"post": {}},
"/api/v1/skip": {"delete": {}},
}
}
def fake_fetch(
base_url, *, timeout=10.0, path=None, headers=None, query_params=None
):
return fake_openapi
monkeypatch.setattr(
"openbb_cli.dispatchers.openapi_schema.fetch_openapi", fake_fetch
)
d = http_mod.http_dispatcher_from_server("http://h")
assert d._command_methods == {"x.y": "get", "foo.bar": "post"}
@pytest.mark.asyncio
async def test_dispatch_sends_constructor_headers():
"""Headers passed at construction time go on every request."""
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["headers"] = dict(request.headers)
return httpx.Response(200, json={"ok": True})
transport = httpx.MockTransport(handler)
dispatcher = HttpDispatcher(
"http://t",
headers={"Authorization": "Bearer xyz", "X-Tenant": "acme"},
client=httpx.AsyncClient(
transport=transport,
base_url="http://t",
headers={"Authorization": "Bearer xyz", "X-Tenant": "acme"},
),
command_methods={"x": "get"},
)
try:
await dispatcher.dispatch(Request(command="x"))
finally:
await dispatcher.aclose()
assert captured["headers"]["authorization"] == "Bearer xyz"
assert captured["headers"]["x-tenant"] == "acme"
def test_http_dispatcher_owns_client_when_headers_passed():
"""No explicit client → dispatcher remembers headers + builds a fresh
client per ``dispatch()`` call. We assert the bookkeeping; the actual
header round-trip is covered by the ``handler``-based dispatch tests
above which observe headers on the wire.
"""
d = HttpDispatcher(
"http://h",
headers={"X-Foo": "bar"},
)
assert d._headers == {"X-Foo": "bar"}
assert d._owns_client is True
assert d._client is None
def test_http_dispatcher_from_spec_passes_headers():
"""``http_dispatcher_from_spec`` forwards headers to the dispatcher."""
from openbb_cli.dispatchers.http import http_dispatcher_from_spec
spec_doc = {
"base_url": "http://h",
"api_prefix": "/api/v1",
"commands": {"foo": {"method": "get", "url_path": "/api/v1/foo"}},
}
d = http_dispatcher_from_spec(spec_doc, headers={"X-API-Key": "k"})
assert d._headers == {"X-API-Key": "k"}
@pytest.mark.asyncio
async def test_dispatch_substitutes_path_params_into_url_template():
"""``{format}``-style placeholders are substituted from the request params."""
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
return httpx.Response(200, json={"ok": True})
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher(
"http://t",
client=client,
command_methods={"fxs.list.counterparties": "get"},
command_url_paths={
"fxs.list.counterparties": "/api/fxs/list/counterparties.{format}"
},
)
try:
await d.dispatch(
Request(
command="fxs.list.counterparties",
params={"format": "json", "extra": "value"},
)
)
finally:
await d.aclose()
assert "/api/fxs/list/counterparties.json" in captured["url"]
assert "format=" not in captured["url"]
assert "extra=value" in captured["url"]
@pytest.mark.asyncio
async def test_dispatch_keeps_placeholder_when_param_missing():
"""A missing path param leaves the placeholder so the server returns a clean error."""
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher(
"http://t",
client=client,
command_methods={"x": "get"},
command_url_paths={"x": "/api/x/{missing}"},
)
try:
await d.dispatch(Request(command="x", params={}))
finally:
await d.aclose()
assert "%7Bmissing%7D" in captured["url"] or "{missing}" in captured["url"]
@pytest.mark.asyncio
async def test_dispatch_picks_longest_satisfied_url_template():
"""When a command has multiple URL templates, pick the longest one whose placeholders are filled."""
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher(
"http://t",
client=client,
command_methods={"x": "get"},
command_url_paths={
"x": [
"/api/ratings/historical/{country}",
"/api/ratings/historical/{country}/{d1}/{d2}",
]
},
)
try:
await d.dispatch(
Request(
command="x",
params={"country": "USA", "d1": "2024-01-01", "d2": "2024-12-31"},
)
)
finally:
await d.aclose()
assert "/api/ratings/historical/USA/2024-01-01/2024-12-31" in captured["url"]
@pytest.mark.asyncio
async def test_dispatch_falls_back_to_shorter_template_when_extras_missing():
"""When the longer template is missing extra path params, fall back to the shorter."""
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher(
"http://t",
client=client,
command_methods={"x": "get"},
command_url_paths={
"x": [
"/api/ratings/historical/{country}",
"/api/ratings/historical/{country}/{d1}/{d2}",
]
},
)
try:
await d.dispatch(Request(command="x", params={"country": "USA"}))
finally:
await d.aclose()
assert "/api/ratings/historical/USA" in captured["url"]
assert "{d1}" not in captured["url"]
assert "%7Bd1%7D" not in captured["url"]
@pytest.mark.asyncio
async def test_dispatch_falls_back_to_no_placeholder_template_when_path_params_missing():
"""When no path params are supplied, a placeholder-free template still wins."""
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher(
"http://t",
client=client,
command_methods={"x": "get"},
command_url_paths={"x": ["/api/ratings", "/api/ratings/{country}"]},
)
try:
await d.dispatch(Request(command="x", params={}))
finally:
await d.aclose()
assert captured["url"].endswith("/api/ratings")
def test_http_dispatcher_from_spec_uses_url_templates_when_present():
"""The spec-doc factory routes ``url_templates`` through to the dispatcher."""
from openbb_cli.dispatchers.http import http_dispatcher_from_spec
spec_doc = {
"base_url": "http://h",
"api_prefix": "/api/v1",
"commands": {
"x": {
"method": "get",
"url_path": "/api/x",
"url_templates": ["/api/x", "/api/x/{id}"],
}
},
}
d = http_dispatcher_from_spec(spec_doc)
assert d._command_url_paths["x"] == ["/api/x", "/api/x/{id}"]
@pytest.mark.asyncio
async def test_dispatch_invokes_sync_auth_hook_and_merges_headers():
"""Sync hook returning headers/query gets merged into the request."""
from openbb_cli.auth import AuthContext, AuthDecision
captured: dict[str, object] = {}
seen: list[AuthContext] = []
def hook(ctx):
seen.append(ctx)
return AuthDecision(headers={"X-User": "alice"}, query_params={"trace": "1"})
def handler(request: httpx.Request) -> httpx.Response:
captured["headers"] = dict(request.headers)
captured["url"] = str(request.url)
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
d = HttpDispatcher(
"http://test",
client=client,
command_methods={"foo": "get"},
auth_hook=hook,
namespace="ns1",
)
try:
resp = await d.dispatch(Request(command="foo", params={"a": 1}))
finally:
await d.aclose()
assert resp.ok
assert captured["headers"]["x-user"] == "alice"
assert "trace=1" in captured["url"]
assert seen[0].namespace == "ns1"
assert seen[0].command == "foo"
assert seen[0].params == {"a": 1}
@pytest.mark.asyncio
async def test_dispatch_supports_async_auth_hook():
"""Awaited coroutine hooks work the same as sync ones."""
from openbb_cli.auth import AuthDecision
async def hook(ctx):
return AuthDecision(headers={"X-Async": "yes"})
captured: dict[str, dict[str, str]] = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["h"] = dict(request.headers)
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
d = HttpDispatcher(
"http://test",
client=client,
command_methods={"foo": "get"},
auth_hook=hook,
)
try:
await d.dispatch(Request(command="foo"))
finally:
await d.aclose()
assert captured["h"]["x-async"] == "yes"
@pytest.mark.asyncio
async def test_dispatch_auth_hook_deny_short_circuits():
"""``allow=False`` produces an AccessDenied error and skips the network call."""
from openbb_cli.auth import AuthDecision
network_calls: list[str] = []
def hook(ctx):
return AuthDecision(allow=False, deny_reason="role too low")
def handler(request: httpx.Request) -> httpx.Response:
network_calls.append(str(request.url))
return httpx.Response(200, json={})
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
d = HttpDispatcher("http://test", client=client, auth_hook=hook)
try:
resp = await d.dispatch(Request(command="x"))
finally:
await d.aclose()
assert not resp.ok
assert resp.error.type == "AccessDenied"
assert "role too low" in resp.error.message
assert network_calls == []
@pytest.mark.asyncio
async def test_dispatch_auth_hook_raises_becomes_deny():
"""A raising hook becomes an ``AccessDenied`` response, not a crash."""
def hook(ctx):
raise RuntimeError("vault unreachable")
transport = httpx.MockTransport(lambda r: httpx.Response(500))
client = httpx.AsyncClient(transport=transport, base_url="http://test")
d = HttpDispatcher("http://test", client=client, auth_hook=hook)
try:
resp = await d.dispatch(Request(command="x"))
finally:
await d.aclose()
assert not resp.ok
assert resp.error.type == "AccessDenied"
assert "vault unreachable" in resp.error.message
@pytest.mark.asyncio
async def test_dispatch_auth_hook_wrong_return_type_denies():
"""A hook returning the wrong shape is treated as a deny, not silent ok."""
def hook(ctx):
return {"headers": {"x": "1"}} # not an AuthDecision
transport = httpx.MockTransport(lambda r: httpx.Response(200))
client = httpx.AsyncClient(transport=transport, base_url="http://test")
d = HttpDispatcher("http://test", client=client, auth_hook=hook)
try:
resp = await d.dispatch(Request(command="x"))
finally:
await d.aclose()
assert not resp.ok
assert resp.error.type == "AccessDenied"
assert "expected AuthDecision" in resp.error.message
@pytest.mark.asyncio
async def test_list_commands_filters_out_denied_entries():
"""``__commands__`` runs the auth hook for each command; deny → drop."""
from openbb_cli.auth import AuthDecision
def hook(ctx):
# Only allow ``allowed.cmd``
if ctx.command == "allowed.cmd":
return AuthDecision()
return AuthDecision(allow=False, deny_reason="rbac")
d = HttpDispatcher(
"http://test",
spec_doc={
"commands": {
"allowed.cmd": {"description": "Visible."},
"denied.cmd": {"description": "Hidden."},
}
},
auth_hook=hook,
)
resp = await d.dispatch(Request(command="__commands__"))
assert resp.ok
names = [r["name"] for r in resp.result]
assert names == ["allowed.cmd"]
@pytest.mark.asyncio
async def test_describe_denied_returns_access_denied():
"""``__schema__`` for a denied command returns ``AccessDenied``."""
from openbb_cli.auth import AuthDecision
d = HttpDispatcher(
"http://test",
spec_doc={
"commands": {
"secret.thing": {
"description": "Hidden.",
"parameters": [],
"providers": [],
"request_body_schema": None,
"response_schema": None,
}
}
},
auth_hook=lambda ctx: AuthDecision(allow=False, deny_reason="rbac"),
)
resp = await d.dispatch(
Request(command="__schema__", params={"name": "secret.thing"})
)
assert not resp.ok
assert resp.error.type == "AccessDenied"
assert "rbac" in resp.error.message
@pytest.mark.asyncio
async def test_describe_returns_slim_shape_for_non_multi_provider():
"""Single-provider commands get the flat ``{name, parameters, output_schema}``."""
d = HttpDispatcher(
"http://test",
spec_doc={
"commands": {
"foo": {
"description": "F",
"parameters": [
{
"name": "x",
"in": "query",
"type": "string",
"is_list": False,
"required": True,
"default": None,
"choices": [],
"help": None,
"providers": [],
}
],
"providers": [],
"request_body_schema": None,
"response_schema": {"type": "object"},
}
}
},
)
resp = await d._describe_command(
Request(command="__schema__", params={"name": "foo"})
)
assert resp.ok
assert set(resp.result) == {"name", "parameters", "output_schema"}
p = resp.result["parameters"][0]
# Empty/falsy fields stripped
assert "is_list" not in p
assert "default" not in p
assert "choices" not in p
assert "help" not in p
assert p["required"] is True
@pytest.mark.asyncio
async def test_describe_groups_by_provider_when_present():
"""Multi-provider commands return ``{name, providers: {ns: {...}, ...}}``."""
d = HttpDispatcher(
"http://test",
spec_doc={
"commands": {
"q": {
"description": "Q",
"parameters": [
{
"name": "symbol",
"in": "query",
"type": "string",
"required": True,
"providers": [],
},
{
"name": "use_cache",
"in": "query",
"type": "boolean",
"providers": ["cboe"],
},
{
"name": "source",
"in": "query",
"type": "string",
"providers": ["intrinio"],
},
],
"providers": ["cboe", "intrinio"],
"request_body_schema": None,
"response_schema": None,
}
}
},
)
resp = await d._describe_command(
Request(command="__schema__", params={"name": "q"})
)
assert resp.ok
by = resp.result["providers"]
assert set(by) == {"cboe", "intrinio"}
cboe_params = [p["name"] for p in by["cboe"]["parameters"]]
intrinio_params = [p["name"] for p in by["intrinio"]["parameters"]]
assert cboe_params == ["symbol", "use_cache"]
assert intrinio_params == ["symbol", "source"]
@pytest.mark.asyncio
async def test_describe_with_provider_suffix_returns_single_slice():
"""Passing ``provider`` narrows the output to that provider's slice."""
d = HttpDispatcher(
"http://test",
spec_doc={
"commands": {
"q": {
"description": "Q",
"parameters": [
{
"name": "symbol",
"in": "query",
"type": "string",
"required": True,
"providers": [],
},
{
"name": "source",
"in": "query",
"type": "string",
"providers": ["intrinio"],
},
],
"providers": ["cboe", "intrinio"],
"request_body_schema": None,
"response_schema": None,
}
}
},
)
resp = await d._describe_command(
Request(command="__schema__", params={"name": "q", "provider": "intrinio"})
)
assert resp.ok
assert resp.result["provider"] == "intrinio"
assert set(resp.result) == {"name", "provider", "parameters", "output_schema"}
names = [p["name"] for p in resp.result["parameters"]]
assert names == ["symbol", "source"]
@pytest.mark.asyncio
async def test_describe_unknown_provider_returns_error():
d = HttpDispatcher(
"http://test",
spec_doc={
"commands": {
"q": {
"description": "Q",
"parameters": [],
"providers": ["cboe"],
"request_body_schema": None,
"response_schema": None,
}
}
},
)
resp = await d._describe_command(
Request(command="__schema__", params={"name": "q", "provider": "bogus"})
)
assert not resp.ok
assert resp.error.type == "UnknownProvider"
assert "Available: cboe" in resp.error.message
def test_help_for_provider_keeps_only_relevant_sections():
"""Per-provider help filtering: drops ``(provider: X)`` blocks for other providers."""
from openbb_cli.dispatchers.http import _help_for_provider
text = (
"Shared header for everyone.;\n "
"FMP says hi. (provider: fmp);\n "
"Intrinio says hi. (provider: intrinio)"
)
assert _help_for_provider(text, "fmp") == (
"Shared header for everyone.\nFMP says hi."
)
assert _help_for_provider(text, "intrinio") == (
"Shared header for everyone.\nIntrinio says hi."
)
def test_help_for_provider_returns_none_when_nothing_applies():
from openbb_cli.dispatchers.http import _help_for_provider
text = "Only for cboe. (provider: cboe)"
assert _help_for_provider(text, "fmp") is None
def test_help_for_provider_passthrough_for_empty():
from openbb_cli.dispatchers.http import _help_for_provider
assert _help_for_provider(None, "fmp") is None
assert _help_for_provider("", "fmp") == ""
def test_provider_output_schema_picks_matching_variant():
"""``IntrinioEquityQuoteData`` matches provider ``intrinio`` by case-insensitive prefix."""
from openbb_cli.dispatchers.http import _provider_output_schema
schema = {
"properties": {
"results": {
"anyOf": [
{
"items": {
"oneOf": [
{"title": "IntrinioEquityQuoteData", "type": "object"},
{"title": "FMPEquityQuoteData", "type": "object"},
]
},
"type": "array",
},
{"type": "null"},
]
}
}
}
intrinio = _provider_output_schema(schema, "intrinio")
assert intrinio is not None
assert intrinio["title"] == "IntrinioEquityQuoteData"
def test_provider_output_schema_handles_non_array_results():
"""Single-record results with a top-level ``oneOf`` work the same way."""
from openbb_cli.dispatchers.http import _provider_output_schema
schema = {
"properties": {
"results": {
"oneOf": [
{"title": "IntrinioFoo", "type": "object"},
{"title": "FMPFoo", "type": "object"},
]
}
}
}
out = _provider_output_schema(schema, "fmp")
assert out is not None
assert out["title"] == "FMPFoo"
def test_provider_output_schema_returns_none_for_invalid_input():
from openbb_cli.dispatchers.http import _provider_output_schema
assert _provider_output_schema(None, "fmp") is None
assert _provider_output_schema({}, "fmp") is None
def test_http_dispatcher_from_server_passes_headers(monkeypatch):
"""``http_dispatcher_from_server`` forwards headers to both fetch and dispatcher."""
from openbb_cli.dispatchers import http as http_mod
captured: dict[str, dict[str, str] | None] = {}
def fake_fetch(
base_url, *, timeout=10.0, path=None, headers=None, query_params=None
):
captured["headers"] = headers
return {"paths": {"/api/v1/x": {"get": {}}}}
monkeypatch.setattr(
"openbb_cli.dispatchers.openapi_schema.fetch_openapi", fake_fetch
)
d = http_mod.http_dispatcher_from_server(
"http://h", headers={"Authorization": "Bearer abc"}
)
assert captured["headers"] == {"Authorization": "Bearer abc"}
assert d._headers == {"Authorization": "Bearer abc"}
@pytest.mark.asyncio
async def test_describe_missing_name_returns_error():
"""``__schema__`` without ``--name`` returns a structured error."""
d = HttpDispatcher("http://test", spec_doc={"commands": {}})
resp = await d._describe_command(Request(command="__schema__", params={}))
assert not resp.ok
assert resp.error.type == "MissingParameter"
assert "--name" in resp.error.message
@pytest.mark.asyncio
async def test_describe_unknown_command_returns_error():
"""Asking for a command that isn't in the spec returns ``UnknownCommand``."""
d = HttpDispatcher("http://test", spec_doc={"commands": {"a": {}}})
resp = await d._describe_command(
Request(command="__schema__", params={"name": "missing.cmd"})
)
assert not resp.ok
assert resp.error.type == "UnknownCommand"
assert "missing.cmd" in resp.error.message
# --- _slim_param branch coverage ---
def test_slim_param_drops_empty_choices_and_default_zero():
"""Falsy fields (empty choices, missing default) are stripped."""
from openbb_cli.dispatchers.http import _slim_param
out = _slim_param(
{
"name": "x",
"in": "query",
"type": "integer",
"is_list": False,
"required": False,
"default": None,
"choices": [],
"help": None,
}
)
assert out == {"name": "x", "in": "query", "type": "integer"}
def test_slim_param_keeps_default_zero():
"""``default=0`` is meaningful — must NOT be stripped (only None is)."""
from openbb_cli.dispatchers.http import _slim_param
out = _slim_param({"name": "x", "in": "query", "type": "integer", "default": 0})
assert out["default"] == 0
def test_slim_param_keeps_choices_and_help_when_set():
from openbb_cli.dispatchers.http import _slim_param
out = _slim_param(
{
"name": "x",
"in": "query",
"type": "string",
"choices": ["a", "b"],
"help": "pick",
}
)
assert out["choices"] == ["a", "b"]
assert out["help"] == "pick"
def test_slim_param_keeps_json_arg_flag():
"""A non-scalar body field carries its ``json_arg`` flag through ``--describe``."""
from openbb_cli.dispatchers.http import _slim_param
out = _slim_param(
{"name": "data", "in": "body", "type": "string", "json_arg": True}
)
assert out["json_arg"] is True
def test_partition_params_splits_query_and_body_by_in_tag():
"""POST params split by ``in`` tag: query-tagged to the query string, rest to body."""
spec_doc = {
"commands": {
"x.y": {
"parameters": [{"name": "fmt", "in": "query", "type": "string"}],
"request_body_schema": {
"type": "object",
"properties": {
"data": {"type": "array", "items": {"type": "object"}}
},
},
}
}
}
d = HttpDispatcher("http://t", spec_doc=spec_doc)
query, body, headers = d._partition_params(
"x.y", {"fmt": "json", "data": [1], "extra": 9}
)
assert query == {"fmt": "json"}
assert body == {"data": [1], "extra": 9}
assert headers == {}
def test_partition_params_surfaces_header_tagged_params():
"""Params tagged ``in: header`` are routed to the headers map, not query/body."""
spec_doc = {
"commands": {
"x.y": {
"parameters": [
{"name": "x-api-key", "in": "header", "type": "string"},
{"name": "fmt", "in": "query", "type": "string"},
],
}
}
}
d = HttpDispatcher("http://t", spec_doc=spec_doc)
query, body, headers = d._partition_params(
"x.y", {"x_api_key": "secret", "fmt": "json", "payload": 1}
)
assert query == {"fmt": "json"}
assert body == {"payload": 1}
assert headers == {"x-api-key": "secret"}
def test_partition_params_drops_none_header_value():
"""A header-tagged param with a ``None`` value is omitted from headers."""
spec_doc = {
"commands": {
"x.y": {
"parameters": [{"name": "x-api-key", "in": "header", "type": "string"}],
}
}
}
d = HttpDispatcher("http://t", spec_doc=spec_doc)
_query, _body, headers = d._partition_params("x.y", {"x_api_key": None})
assert headers == {}
# --- streaming (SSE) ---
def test_is_streaming_command_detects_event_stream():
spec_doc = {
"commands": {
"x.feed": {"response_schemas": {"200": {"text/event-stream": {}}}},
"x.plain": {"response_schemas": {"200": {"application/json": {}}}},
}
}
d = HttpDispatcher("http://t", spec_doc=spec_doc)
assert d._is_streaming_command("x.feed") is True
assert d._is_streaming_command("x.plain") is False
assert d._is_streaming_command("missing") is False
@pytest.mark.asyncio
async def test_dispatch_streaming_get_returns_obbstream():
from openbb_core.app.model.stream import OBBStream
spec_doc = {
"commands": {
"x.feed": {
"method": "get",
"response_schemas": {"200": {"text/event-stream": {}}},
}
}
}
d = HttpDispatcher("http://t", spec_doc=spec_doc, command_methods={"x.feed": "get"})
resp = await d.dispatch(Request(command="x.feed", params={"limit": 2}))
await d.aclose()
assert resp.ok
assert isinstance(resp.result, OBBStream)
@pytest.mark.asyncio
async def test_dispatch_streaming_post_returns_obbstream():
from openbb_core.app.model.stream import OBBStream
spec_doc = {
"commands": {
"x.feed": {
"method": "post",
"response_schemas": {"200": {"text/event-stream": {}}},
"request_body_schema": {
"type": "object",
"properties": {"data": {"type": "array"}},
},
}
}
}
d = HttpDispatcher(
"http://t", spec_doc=spec_doc, command_methods={"x.feed": "post"}
)
resp = await d.dispatch(Request(command="x.feed", params={"data": [1]}))
await d.aclose()
assert resp.ok
assert isinstance(resp.result, OBBStream)
@pytest.mark.asyncio
async def test_http_sse_source_iterates_get(monkeypatch):
import openbb_cli.dispatchers.http as http_mod
from openbb_cli.dispatchers.http import _HttpSSESource
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["method"] = request.method
captured["url"] = str(request.url)
return httpx.Response(200, text="data: 0\n\ndata: 1\n")
real_client = httpx.AsyncClient
def make_client(*args, **kwargs):
kwargs["transport"] = httpx.MockTransport(handler)
return real_client(*args, **kwargs)
monkeypatch.setattr(http_mod.httpx, "AsyncClient", make_client)
source = _HttpSSESource("http://t/feed", {"limit": 2}, None, "get", None)
lines = [line async for line in source.body_iterator]
assert captured["method"] == "GET"
assert "limit=2" in captured["url"]
assert any("data: 0" in line for line in lines)
@pytest.mark.asyncio
async def test_http_sse_source_iterates_post_with_body(monkeypatch):
import openbb_cli.dispatchers.http as http_mod
from openbb_cli.dispatchers.http import _HttpSSESource
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["method"] = request.method
captured["body"] = request.content.decode()
return httpx.Response(200, text="data: 0\n")
real_client = httpx.AsyncClient
def make_client(*args, **kwargs):
kwargs["transport"] = httpx.MockTransport(handler)
return real_client(*args, **kwargs)
monkeypatch.setattr(http_mod.httpx, "AsyncClient", make_client)
source = _HttpSSESource("http://t/feed", None, None, "post", {"data": [1]})
lines = [line async for line in source.body_iterator]
assert captured["method"] == "POST"
assert "data" in captured["body"]
assert any("data: 0" in line for line in lines)
@pytest.mark.asyncio
async def test_http_sse_source_consumes_warning_header(monkeypatch):
import json as _json
from openbb_core.app.model.abstract.warning import OpenBBWarning
import openbb_cli.dispatchers.http as http_mod
from openbb_cli.dispatchers.http import _HttpSSESource
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
text="data: 0\n",
headers={
"X-OpenBB-Provider": "fmp",
"X-OpenBB-Warning": _json.dumps(
[{"category": "OpenBBWarning", "message": "heads up"}]
),
},
)
real_client = httpx.AsyncClient
def make_client(*args, **kwargs):
kwargs["transport"] = httpx.MockTransport(handler)
return real_client(*args, **kwargs)
monkeypatch.setattr(http_mod.httpx, "AsyncClient", make_client)
source = _HttpSSESource("http://t/feed", None, None, "get", None)
with pytest.warns(OpenBBWarning, match="heads up"):
_lines = [line async for line in source.body_iterator]
assert source.provider == "fmp"
assert source.warnings is not None
assert source.warnings[0]["message"] == "heads up"
@pytest.mark.asyncio
async def test_http_sse_source_ignores_malformed_warning_header(monkeypatch):
import openbb_cli.dispatchers.http as http_mod
from openbb_cli.dispatchers.http import _HttpSSESource
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200, text="data: 0\n", headers={"X-OpenBB-Warning": "not-json"}
)
real_client = httpx.AsyncClient
def make_client(*args, **kwargs):
kwargs["transport"] = httpx.MockTransport(handler)
return real_client(*args, **kwargs)
monkeypatch.setattr(http_mod.httpx, "AsyncClient", make_client)
source = _HttpSSESource("http://t/feed", None, None, "get", None)
_lines = [line async for line in source.body_iterator]
assert source.warnings is None
# --- _help_for_provider edge: empty section ---
def test_help_for_provider_skips_empty_sections_between_separators():
from openbb_cli.dispatchers.http import _help_for_provider
text = "shared.;\n ;\n Foo. (provider: fmp)"
out = _help_for_provider(text, "fmp")
assert out == "shared.\nFoo."
# --- _provider_output_schema edges ---
def test_provider_output_schema_returns_none_when_results_field_absent():
from openbb_cli.dispatchers.http import _provider_output_schema
schema = {"properties": {}}
assert _provider_output_schema(schema, "fmp") is None
def test_provider_output_schema_skips_unmatched_titles():
"""When no oneOf variant matches the provider, return None (not arbitrary first)."""
from openbb_cli.dispatchers.http import _provider_output_schema
schema = {
"properties": {
"results": {
"anyOf": [
{
"items": {
"oneOf": [
{"title": "AlphaData", "type": "object"},
{"title": "BetaData", "type": "object"},
]
},
"type": "array",
},
{"type": "null"},
]
}
}
}
assert _provider_output_schema(schema, "gamma") is None
def test_provider_output_schema_skips_non_dict_oneof_entries():
from openbb_cli.dispatchers.http import _provider_output_schema
schema = {
"properties": {
"results": {"oneOf": ["not-a-dict", {"title": "FMPx", "type": "object"}]}
}
}
out = _provider_output_schema(schema, "fmp")
assert out is not None
assert out["title"] == "FMPx"
# --- _decode_response branches ---
def test_decode_response_text_for_xml_content_type():
"""Non-JSON content type returns the raw text body."""
from openbb_cli.dispatchers.http import _decode_response
response = httpx.Response(
200, text="<root>x</root>", headers={"content-type": "application/xml"}
)
assert _decode_response(response) == "<root>x</root>"
def test_decode_response_falls_back_to_text_when_json_invalid():
"""JSON content type with a body that won't parse falls back to text."""
from openbb_cli.dispatchers.http import _decode_response
response = httpx.Response(
200, text="not json", headers={"content-type": "application/json"}
)
assert _decode_response(response) == "not json"
def test_decode_response_no_content_type_attempts_json():
"""Empty content type defaults to JSON parsing."""
from openbb_cli.dispatchers.http import _decode_response
response = httpx.Response(200, text='{"a": 1}', headers={"content-type": ""})
assert _decode_response(response) == {"a": 1}
# --- _client_context external client branch ---
@pytest.mark.asyncio
async def test_client_context_yields_external_client_when_not_owned():
"""External clients are yielded as-is, not closed by the dispatcher."""
transport = httpx.MockTransport(lambda r: httpx.Response(200, json={}))
external = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher("http://t", client=external)
async with d._client_context() as c:
assert c is external
# Not closed
response = await external.get("http://t/")
assert response.status_code == 200
await external.aclose()
def test_slim_param_keeps_is_list_when_true():
"""``is_list: True`` lands in the slim output."""
from openbb_cli.dispatchers.http import _slim_param
out = _slim_param({"name": "x", "in": "query", "type": "string", "is_list": True})
assert out["is_list"] is True
def test_provider_output_schema_falls_back_to_only_candidate_with_no_title_match():
"""Single candidate that doesn't title-match the provider is returned anyway."""
from openbb_cli.dispatchers.http import _provider_output_schema
schema = {
"properties": {
"results": {
"anyOf": [
{"oneOf": [{"title": "GenericData", "type": "object"}]},
{"type": "null"},
]
}
}
}
out = _provider_output_schema(schema, "fmp")
assert out is not None
assert out["title"] == "GenericData"
@pytest.mark.asyncio
async def test_dispatch_routes_list_commands_through_dispatch():
"""``dispatch(Request(command="__commands__"))`` reaches the introspection path."""
d = HttpDispatcher(
"http://test",
spec_doc={"commands": {"foo": {"description": "F"}}},
)
resp = await d.dispatch(Request(command="__commands__"))
assert resp.ok
assert resp.result == [{"name": "foo", "description": "F"}]
@pytest.mark.asyncio
async def test_dispatch_routes_describe_through_dispatch():
"""``dispatch(Request(command="__schema__"))`` reaches ``_describe_command``."""
d = HttpDispatcher(
"http://test",
spec_doc={
"commands": {
"foo": {
"description": "F",
"parameters": [],
"providers": [],
"request_body_schema": None,
"response_schema": None,
}
}
},
)
resp = await d.dispatch(Request(command="__schema__", params={"name": "foo"}))
assert resp.ok
assert resp.result["name"] == "foo"
def test_group_by_provider_skips_provider_discriminator():
"""The ``provider`` flag itself is filtered out per-provider — already implicit."""
from openbb_cli.dispatchers.http import _group_by_provider
raw_params = [
{"name": "provider", "type": "string", "providers": []},
{"name": "symbol", "type": "string", "providers": []},
]
grouped = _group_by_provider(raw_params, ["cboe"], None)
names = [p["name"] for p in grouped["cboe"]["parameters"]]
assert names == ["symbol"] # ``provider`` itself dropped
@pytest.mark.asyncio
async def test_dispatch_with_owned_client_uses_fresh_async_client(monkeypatch):
"""An owned client (no ``client=`` arg) takes the ``owns`` branch in ``_client_context``,
constructing a fresh ``httpx.AsyncClient`` per dispatch.
"""
constructed: list[bool] = []
transport = httpx.MockTransport(lambda r: httpx.Response(200, json={"ok": True}))
real_async_client = httpx.AsyncClient
def factory(*args, **kwargs):
constructed.append(True)
kwargs.pop("transport", None)
return real_async_client(*args, transport=transport, **kwargs)
from openbb_cli.dispatchers import http as http_mod
monkeypatch.setattr(http_mod.httpx, "AsyncClient", factory)
d = HttpDispatcher("http://t", command_methods={"foo": "get"})
resp = await d.dispatch(Request(command="foo"))
assert resp.ok
assert constructed == [True]
def test_row_column_metadata_extracts_description_format_and_socrata_format():
"""``_row_column_metadata`` mirrors codegen's metadata extraction."""
from openbb_cli.dispatchers.http import _row_column_metadata
cmd_spec = {
"response_schema": {
"properties": {
"results": {
"items": {
"properties": {
"price": {
"type": "number",
"description": "Price, measured in dollars per ton",
"socrata_format": {"precisionStyle": "standard"},
},
"obs_date": {"type": "string", "format": "date"},
"raw_id": {"type": "string"},
}
}
}
}
}
}
out = _row_column_metadata(cmd_spec)
assert out == {
"price": {
"description": "Price, measured in dollars per ton",
"socrata_format": {"precisionStyle": "standard"},
},
"obs_date": {"format": "date"},
}
def test_row_column_metadata_returns_empty_when_schema_absent():
"""No ``response_schema`` → empty dict, no error."""
from openbb_cli.dispatchers.http import _row_column_metadata
assert _row_column_metadata({}) == {}
assert _row_column_metadata({"response_schema": None}) == {}
assert _row_column_metadata({"response_schema": {"type": "object"}}) == {}
@pytest.mark.asyncio
async def test_dispatch_injects_column_metadata_into_results_envelope():
"""Spec-mode dispatch surfaces column metadata under ``metadata.columns``.
Mirrors what the installed-extension fetcher embeds in
``AnnotatedResult.metadata['columns']`` so spec-mode and codegen
paths look identical to downstream consumers (e.g. the OBBject
``extra['results_metadata']`` surface).
"""
spec_doc = {
"commands": {
"ag.prices": {
"method": "get",
"response_schema": {
"properties": {
"results": {
"items": {
"properties": {
"price": {
"type": "number",
"description": "Price in $/ton",
"socrata_format": {"noCommas": "true"},
},
"obs_date": {
"type": "string",
"format": "date",
},
}
}
}
}
},
}
}
}
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json=[
{"price": "12.5", "obs_date": "2026-04-30T00:00:00.000"},
{"price": "13.0", "obs_date": "2026-04-23T00:00:00.000"},
],
)
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher("http://t", client=client, spec_doc=spec_doc)
try:
resp = await d.dispatch(Request(command="ag.prices"))
finally:
await d.aclose()
assert resp.ok
# Dispatcher returns a live OBBject instance (not a dict) — the
# registry can register it directly and JSON serialization still
# works through pydantic's recursive model_dump.
from openbb_core.app.model.obbject import OBBject
assert isinstance(resp.result, OBBject)
# Rows coerced (numeric strings → numbers, date timestamps → date).
assert resp.result.results == [
{"price": 12.5, "obs_date": "2026-04-30"},
{"price": 13.0, "obs_date": "2026-04-23"},
]
# Column metadata under extra.results_metadata.columns — same path
# the installed-extension AnnotatedResult flow produces.
assert resp.result.extra["results_metadata"]["columns"] == {
"price": {
"description": "Price in $/ton",
"socrata_format": {"noCommas": "true"},
},
"obs_date": {"format": "date"},
}
# Standard OBBject fields are all defaulted.
assert resp.result.provider is None
assert resp.result.warnings is None
assert resp.result.chart is None
assert resp.result.id # Tagged base class auto-generates this
# Route lives where it canonically belongs — extra.metadata.route —
# not duplicated as a synthetic top-level attr.
assert resp.result.extra["metadata"].route == "/ag/prices"
# ``metadata`` is never a top-level OBBject property.
dumped = resp.result.model_dump()
assert "metadata" not in dumped
@pytest.mark.asyncio
async def test_dispatch_skips_metadata_envelope_when_schema_has_none():
"""No metadata in schema → no metadata envelope added; raw shape preserved."""
spec_doc = {
"commands": {
"x": {
"method": "get",
"response_schema": {
"properties": {
"results": {"items": {"properties": {"id": {"type": "string"}}}}
}
},
}
}
}
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=[{"id": "a"}, {"id": "b"}])
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher("http://t", client=client, spec_doc=spec_doc)
try:
resp = await d.dispatch(Request(command="x"))
finally:
await d.aclose()
assert resp.ok
# No description / format / socrata_format in the schema → flat list,
# no synthetic metadata envelope.
assert resp.result == [{"id": "a"}, {"id": "b"}]
@pytest.mark.asyncio
async def test_dispatch_attaches_execution_metadata_to_obbject():
"""Spec-mode wraps in an OBBject AND attaches command_runner-style execution
metadata under ``extra['metadata']`` — route, arguments, duration, timestamp.
Without this, downstream registries / loggers can't tell what was
called, with what args, or how long it took. Mirrors what
``StaticCommandRunner`` writes when ``preferences.metadata`` is on.
"""
spec_doc = {
"commands": {
"ag.fertilizer_prices_by_region": {
"method": "get",
"response_schema": {
"properties": {
"results": {
"items": {
"properties": {
"price": {
"type": "number",
"description": "Price in $/ton",
}
}
}
}
}
},
}
}
}
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=[{"price": 12.5}])
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher("http://t", client=client, spec_doc=spec_doc)
try:
resp = await d.dispatch(
Request(
command="ag.fertilizer_prices_by_region",
params={"region": "Cornbelt", "limit": 1},
)
)
finally:
await d.aclose()
assert resp.ok
meta = resp.result.extra["metadata"]
# Route uses slash form, mirroring command_runner's convention.
assert meta.route == "/ag/fertilizer_prices_by_region"
# Arguments organized into the canonical three-key shape.
assert meta.arguments == {
"provider_choices": {},
"standard_params": {"region": "Cornbelt", "limit": 1},
"extra_params": {},
}
# Duration is a positive integer (nanoseconds).
assert isinstance(meta.duration, int) and meta.duration > 0
# Timestamp captured.
assert meta.timestamp is not None
# ``metadata`` is not a top-level OBBject property — only at extra.metadata.
dumped = resp.result.model_dump()
assert "metadata" not in dumped
assert dumped["extra"]["metadata"]["arguments"]["standard_params"] == {
"region": "Cornbelt",
"limit": 1,
}
@pytest.mark.asyncio
async def test_dispatch_keeps_api_siblings_separate_from_results_metadata():
"""API-level sibling fields (NY-Fed ``asOfDate``) and per-column
descriptions are *distinct* concepts and ride at distinct keys —
sibling fields land directly under ``extra`` (their own key),
column hints land under ``extra.results_metadata.columns``. Folding
them together would lie about what each piece of data describes.
"""
spec_doc = {
"commands": {
"ops.list": {
"method": "get",
"response_schema": {
"properties": {
"results": {
"items": {
"properties": {
"id": {
"type": "string",
"description": "Operation ID",
}
}
}
}
}
},
}
}
}
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={"asOfDate": "2026-04-30", "operations": [{"id": "a"}, {"id": "b"}]},
)
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher("http://t", client=client, spec_doc=spec_doc)
try:
resp = await d.dispatch(Request(command="ops.list"))
finally:
await d.aclose()
assert resp.ok
assert resp.result.results == [{"id": "a"}, {"id": "b"}]
# Per-column metadata stays in ``results_metadata.columns``.
assert resp.result.extra["results_metadata"]["columns"] == {
"id": {"description": "Operation ID"}
}
# API sibling field at its own top-level extra key — never folded
# into ``results_metadata`` (different concept).
assert resp.result.extra["asOfDate"] == "2026-04-30"
assert "asOfDate" not in resp.result.extra["results_metadata"]
# --- Regression coverage for the OBBject-wrap edge cases ---
def test_is_plotly_figure_recognizes_canonical_shape():
"""A real Plotly figure dict (``data`` list of typed traces +
``layout`` dict) trips the detector — without this guard the generic
unpack would tear the figure apart."""
from openbb_cli.dispatchers.http import _is_plotly_figure
fig = {
"data": [
{"type": "scattergeo", "lat": [0], "lon": [0]},
{"type": "scatter", "x": [1, 2], "y": [3, 4]},
],
"layout": {"title": "Map"},
}
assert _is_plotly_figure(fig) is True
def test_is_plotly_figure_rejects_lookalikes():
"""Conservative: random ``{data, layout}`` payloads from APIs that
happen to share the key names but aren't Plotly figures don't
trigger the chart wrap."""
from openbb_cli.dispatchers.http import _is_plotly_figure
# Not a dict.
assert _is_plotly_figure([{"data": [], "layout": {}}]) is False
# Missing ``layout``.
assert _is_plotly_figure({"data": [{"type": "scatter"}]}) is False
# ``layout`` not a dict.
assert _is_plotly_figure({"data": [{"type": "scatter"}], "layout": []}) is False
# ``data`` not a list.
assert _is_plotly_figure({"data": {"type": "scatter"}, "layout": {}}) is False
# Empty ``data``.
assert _is_plotly_figure({"data": [], "layout": {"x": 1}}) is False
# ``data`` items don't carry a ``type`` (look like generic rows).
assert _is_plotly_figure({"data": [{"x": 1}], "layout": {"y": 2}}) is False
@pytest.mark.asyncio
async def test_dispatch_routes_plotly_figure_payload_to_obbject_chart():
"""A Plotly-figure-shaped server response lands at
``OBBject.chart.content`` with ``format="plotly"`` — preserves the
full figure structure (data + layout) instead of letting the
generic unwrap shred it into a list of half-traces.
"""
from openbb_core.app.model.obbject import OBBject
figure = {
"data": [
{
"type": "scattergeo",
"lat": [12.5, 13.0],
"lon": [-77.0, -76.5],
"name": "Red",
}
],
"layout": {
"title": {"text": "Disruptions"},
"geo": {"projection": "natural earth"},
},
}
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=figure)
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher("http://t", client=client, command_methods={"chart_cmd": "get"})
try:
resp = await d.dispatch(
Request(command="chart_cmd", params={"region": "global"})
)
finally:
await d.aclose()
assert resp.ok
assert isinstance(resp.result, OBBject)
# The figure rides at BOTH ``results`` and ``chart.content`` so
# callers reading ``obbject.results`` see the actual figure (not
# ``None``), and chart-aware renderers (``obbject.charting.show()``)
# also keep working off the canonical ``chart`` slot.
assert resp.result.results == figure
assert resp.result.chart is not None
assert resp.result.chart.format == "plotly"
assert resp.result.chart.content == figure
# Execution metadata still rides under ``extra.metadata`` like
# every other dispatch.
meta = resp.result.extra["metadata"]
assert meta.route == "/chart_cmd"
assert meta.arguments["standard_params"] == {"region": "global"}
@pytest.mark.asyncio
async def test_dispatch_plotly_figure_falls_back_to_raw_dict_without_openbb_core(
monkeypatch,
):
"""Without ``openbb_core``, the Plotly wrap can't construct an
OBBject. Fall back to returning the raw figure dict so the figure
structure is still preserved end-to-end."""
import builtins
real_import = builtins.__import__
def _block_chart_imports(name, *args, **kwargs):
if name.startswith("openbb_core.app.model"):
raise ImportError(f"simulated missing {name}")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _block_chart_imports)
figure = {
"data": [{"type": "scatter", "x": [1], "y": [2]}],
"layout": {"title": "x"},
}
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=figure)
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher("http://t", client=client, command_methods={"x": "get"})
try:
resp = await d.dispatch(Request(command="x"))
finally:
await d.aclose()
# Raw figure dict preserved — both data and layout intact.
assert resp.result == figure
def test_command_to_route_translates_dotted_to_slash_form():
"""Dotted command paths land in ``Metadata.route`` as the slash form
``command_runner`` uses, with a leading slash."""
from openbb_cli.dispatchers.http import _command_to_route
assert _command_to_route("equity.price.historical") == "/equity/price/historical"
assert _command_to_route("law") == "/law"
# Strips redundant slashes so callers don't have to normalize.
assert _command_to_route("/foo.bar/") == "/foo/bar"
@pytest.mark.asyncio
async def test_shape_and_coerce_returns_payload_unchanged_for_non_collection():
"""A scalar / non-list / non-dict shape from ``_shape_result`` short-
circuits the wrap — there's no row payload to type-coerce or wrap."""
spec_doc = {
"commands": {
"x": {
"method": "get",
"response_schema": {
"properties": {
"results": {"items": {"properties": {"id": {"type": "string"}}}}
}
},
}
}
}
def handler(request: httpx.Request) -> httpx.Response:
# ``"plain text"`` is not JSON content-type, falls through as-is.
return httpx.Response(
200, text="plain text", headers={"content-type": "text/plain"}
)
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher("http://t", client=client, spec_doc=spec_doc)
try:
resp = await d.dispatch(Request(command="x"))
finally:
await d.aclose()
assert resp.ok
# Non-JSON text bypasses unwrap entirely — stays a plain string.
assert resp.result == "plain text"
@pytest.mark.asyncio
async def test_dispatch_handles_single_row_envelope_with_sibling_metadata():
"""Sibling-metadata API returning a single-row array — after unpack +
``_shape_result``'s single-row collapse, the inner ``results`` field
arrives at ``_shape_and_coerce`` as a single dict (not a list).
Exercises the ``elif inner is not None`` branch.
"""
from openbb_core.app.model.obbject import OBBject
spec_doc = {
"commands": {
"y": {
"method": "get",
"response_schema": {
"properties": {
"results": {"items": {"properties": {"x": {"type": "number"}}}}
}
},
}
}
}
def handler(request: httpx.Request) -> httpx.Response:
# ``operations`` (single row) + sibling ``asOfDate`` →
# ``unpack_response`` extracts metadata, ``_shape_result``
# collapses the single-row list, and ``_shape_and_coerce`` sees
# ``{results: {dict}, metadata: {asOfDate}}``.
return httpx.Response(
200, json={"asOfDate": "2026-04-30", "operations": [{"x": "1.5"}]}
)
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher("http://t", client=client, spec_doc=spec_doc)
try:
resp = await d.dispatch(Request(command="y"))
finally:
await d.aclose()
assert resp.ok
# Wrap produces a real OBBject; the sibling ``asOfDate`` lands at
# ``extra.asOfDate`` (its own concept), separate from per-row
# metadata.
assert isinstance(resp.result, OBBject)
assert resp.result.extra["asOfDate"] == "2026-04-30"
# Type coercion ran through the single-row branch — string ``"1.5"``
# → float.
assert resp.result.results == {"x": 1.5}
@pytest.mark.asyncio
async def test_dispatch_obbject_wrap_falls_back_to_dict_when_openbb_core_missing(
monkeypatch,
):
"""Without ``openbb_core`` available, the wrap can't construct a real
OBBject — fall back to a plain dict that still puts metadata under
``extra`` so the schema never sprouts a top-level ``metadata`` field.
"""
import builtins
real_import = builtins.__import__
def _block_openbb_core(name, *args, **kwargs):
if name.startswith("openbb_core.app.model"):
raise ImportError(f"simulated missing {name}")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", _block_openbb_core)
spec_doc = {
"commands": {
"z": {
"method": "get",
"response_schema": {
"properties": {
"results": {
"items": {
"properties": {
"id": {
"type": "string",
"description": "An ID",
}
}
}
}
}
},
}
}
}
def handler(request: httpx.Request) -> httpx.Response:
# Two rows — avoid the single-row collapse so we can assert on
# the list shape unambiguously.
return httpx.Response(200, json=[{"id": "a"}, {"id": "b"}])
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher("http://t", client=client, spec_doc=spec_doc)
try:
resp = await d.dispatch(Request(command="z"))
finally:
await d.aclose()
assert resp.ok
# Plain dict fallback — but metadata still under ``extra``, never
# at top level.
assert isinstance(resp.result, dict)
assert "metadata" not in resp.result
assert resp.result["results"] == [{"id": "a"}, {"id": "b"}]
assert resp.result["extra"]["results_metadata"]["columns"] == {
"id": {"description": "An ID"}
}
@pytest.mark.asyncio
async def test_dispatch_metadata_construction_failure_does_not_kill_response():
"""Telemetry is best-effort — when ``Metadata`` construction raises
(e.g. malformed timestamp), the OBBject still gets returned with
the rest of its metadata intact.
"""
from openbb_core.app.model.obbject import OBBject
spec_doc = {
"commands": {
"w": {
"method": "get",
"response_schema": {
"properties": {
"results": {
"items": {
"properties": {
"id": {
"type": "string",
"description": "An ID",
}
}
}
}
}
},
}
}
}
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=[{"id": "a"}])
# Patch ``Metadata`` to raise so we exercise the suppress branch.
import openbb_core.app.model.metadata as metadata_mod
def _raise(*args, **kwargs):
raise RuntimeError("simulated metadata failure")
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher("http://t", client=client, spec_doc=spec_doc)
original = metadata_mod.Metadata
metadata_mod.Metadata = _raise # type: ignore[assignment]
try:
resp = await d.dispatch(Request(command="w"))
finally:
metadata_mod.Metadata = original # type: ignore[assignment]
await d.aclose()
assert resp.ok
assert isinstance(resp.result, OBBject)
# Column metadata still surfaced; ``extra.metadata`` simply absent
# because telemetry construction failed and got suppressed.
assert resp.result.extra["results_metadata"]["columns"] == {
"id": {"description": "An ID"}
}
assert "metadata" not in resp.result.extra
# --- Helper-function coverage ---
def test_user_limit_for_per_item_handles_none_invalid_negative_zero():
"""``_user_limit_for_per_item`` returns ``None`` for missing / non-numeric /
negative / zero limits — the per-item collapse pass treats all of these
the same way (no cap)."""
from openbb_cli.dispatchers.http import _user_limit_for_per_item
# No limit at all → ``None``.
assert _user_limit_for_per_item(Request(command="x", params={})) is None
# Non-numeric → ``None`` via the ``ValueError`` branch.
assert (
_user_limit_for_per_item(Request(command="x", params={"limit": "abc"})) is None
)
# Negative → ``None`` (not a real cap).
assert _user_limit_for_per_item(Request(command="x", params={"limit": -5})) is None
# Zero → ``None`` (means "every row" — the collapse runs uncapped).
assert _user_limit_for_per_item(Request(command="x", params={"limit": 0})) is None
# Positive → integer.
assert _user_limit_for_per_item(Request(command="x", params={"limit": 5})) == 5
def test_request_with_page_size_limit_caps_user_limit_to_one_page():
"""``_request_with_page_size_limit`` clobbers ``limit`` with the page-size
constant so the most-recent-per-item path issues exactly one page-sized
fetch regardless of what the user asked for."""
from openbb_cli.dispatchers.http import (
_SOCRATA_PAGE_SIZE,
_request_with_page_size_limit,
)
out = _request_with_page_size_limit(
Request(id="abc", command="x", params={"limit": 3, "region": "Tampa"})
)
assert out.id == "abc"
assert out.params["limit"] == _SOCRATA_PAGE_SIZE
assert out.params["region"] == "Tampa"
def test_truncate_to_top_n_dates_handles_non_dict_rows_and_missing_dates():
"""Non-dict rows and rows missing the time-axis value are passed through
unchanged — only rows with a real date count toward the distinct-date cap."""
from openbb_cli.dispatchers.http import _truncate_to_top_n_dates
rows = [
"scalar-row", # non-dict, kept verbatim
{"date": "2026-04-30", "x": 1},
{"date": None, "x": 2}, # missing date, kept verbatim
{"date": "2026-04-30", "x": 3}, # same date, kept (still under cap)
{"date": "2026-04-29", "x": 4}, # second date — fills the cap
{"date": "2026-04-28", "x": 5}, # third date — break before this row
]
out = _truncate_to_top_n_dates(rows, "date", 2)
# Three "real" date rows kept (two in date 2026-04-30, one in 2026-04-29);
# the non-dict scalar and missing-date row also kept; the third-date row
# is dropped.
assert "scalar-row" in out
assert {"date": None, "x": 2} in out
assert {"date": "2026-04-30", "x": 1} in out
assert {"date": "2026-04-30", "x": 3} in out
assert {"date": "2026-04-29", "x": 4} in out
assert {"date": "2026-04-28", "x": 5} not in out
def test_truncate_to_top_n_dates_returns_unchanged_when_user_limit_is_none():
"""``user_limit=None`` (the ``limit=0`` case) means no truncation —
the rows pass through verbatim."""
from openbb_cli.dispatchers.http import _truncate_to_top_n_dates
rows = [{"date": "2026-04-30"}, {"date": "2026-04-01"}]
assert _truncate_to_top_n_dates(rows, "date", None) == rows
# Non-list shape with no extractable rows also passes through.
assert _truncate_to_top_n_dates("scalar", "date", 5) == "scalar"
def test_extract_rows_handles_duck_typed_obbject():
"""``_extract_rows`` recognizes a duck-typed OBBject (``results`` list +
``extra``) without importing ``openbb_core``. Other shapes return ``None``.
"""
from openbb_cli.dispatchers.http import _extract_rows
class _DuckOBBject:
results = [{"a": 1}, {"a": 2}]
extra: dict = {}
assert _extract_rows(_DuckOBBject()) == [{"a": 1}, {"a": 2}]
# ``{results: [...]}`` envelope dict → returns the inner list.
assert _extract_rows({"results": [{"x": 1}]}) == [{"x": 1}]
# No ``extra`` attr → not enough fingerprint, returns ``None``.
class _Almost:
results = [{"a": 1}]
assert _extract_rows(_Almost()) is None
# Plain string scalar → ``None``.
assert _extract_rows("scalar") is None
# Dict without a ``results`` list value → ``None``.
assert _extract_rows({"x": 1}) is None
assert _extract_rows({"results": "not-a-list"}) is None
def test_replace_rows_substitutes_into_envelope_dict():
"""A ``{results: [...]}`` envelope dict with extra siblings preserves
the siblings and only replaces the ``results`` key. Exercises the
dict branch at line 441."""
from openbb_cli.dispatchers.http import _replace_rows
out = _replace_rows({"results": [{"old": 1}], "metadata": {"k": "v"}}, [{"new": 1}])
assert out == {"results": [{"new": 1}], "metadata": {"k": "v"}}
def test_replace_rows_returns_shaped_for_unrecognized_object():
"""An object with neither ``results`` (as a list) nor ``extra`` falls
through the OBBject duck-type and gets returned verbatim."""
from openbb_cli.dispatchers.http import _replace_rows
class _NotAnOBBject:
results = "not-a-list" # disqualifies the duck-type fingerprint
obj = _NotAnOBBject()
assert _replace_rows(obj, [{"a": 1}]) is obj
def test_replace_rows_mutates_obbject_in_place_and_returns_it():
"""``_replace_rows`` mutates an OBBject's ``results`` in place, preserving
the original instance (and its private attrs) — important so registry
insertion later sees the canonical instance."""
from openbb_core.app.model.obbject import OBBject
from openbb_cli.dispatchers.http import _replace_rows
obb = OBBject(results=[{"old": 1}], extra={"x": 1})
obb._route = "/r"
new_rows = [{"new": 1}, {"new": 2}]
out = _replace_rows(obb, new_rows)
assert out is obb # same instance
assert obb.results == new_rows
assert obb._route == "/r" # private attr survived
def test_replace_rows_returns_unchanged_for_unrecognized_shape():
"""A shape that's neither list / dict-with-results / duck-OBBject is
returned verbatim — there's no row slot to substitute into."""
from openbb_cli.dispatchers.http import _replace_rows
assert _replace_rows("scalar", [{"a": 1}]) == "scalar"
assert _replace_rows({"no_results_key": True}, [{"a": 1}]) == {
"no_results_key": True
}
def test_coerce_response_value_numeric_parse_failure_returns_string():
"""Numeric coercion treats ``ValueError`` from ``float()`` as a signal
to return the raw string — protects against malformed wire payloads
sneaking through to downstream consumers as ``NaN``."""
from openbb_cli.dispatchers.http import _coerce_response_value
# ``"infinity-but-not"`` → float() raises → string passthrough.
assert _coerce_response_value("not-a-number", "number", "") == "not-a-number"
def test_coerce_response_value_handles_integer_boolean_and_date_branches():
"""``_coerce_response_value`` coerces wire strings into typed values across
each declared schema type."""
from openbb_cli.dispatchers.http import _coerce_response_value
# Integer happy-path.
assert _coerce_response_value("42", "integer", "") == 42
# Integer parse fail → string passthrough.
assert _coerce_response_value("not-an-int", "integer", "") == "not-an-int"
# Boolean happy-path (case-insensitive).
assert _coerce_response_value("True", "boolean", "") is True
assert _coerce_response_value("FALSE", "boolean", "") is False
# Boolean unrecognized → string passthrough.
assert _coerce_response_value("maybe", "boolean", "") == "maybe"
# Date drop time component when the string contains a ``T`` and is long
# enough to look like an ISO timestamp.
assert _coerce_response_value("2026-04-30T00:00:00.000", "string", "date") == (
"2026-04-30"
)
# Already a plain date string, no ``T`` → unchanged.
assert _coerce_response_value("2026-04-30", "string", "date") == "2026-04-30"
# Non-string input → unchanged.
assert _coerce_response_value(42, "integer", "") == 42
def test_row_column_types_skips_non_dict_property_entries():
"""Defensive: a non-dict entry in ``items.properties`` (malformed schema)
gets skipped instead of crashing the whole walk."""
from openbb_cli.dispatchers.http import _row_column_types
cmd_spec = {
"response_schema": {
"properties": {
"results": {
"items": {
"properties": {
"ok": {"type": "string"},
"garbage": "not-a-dict",
}
}
}
}
}
}
out = _row_column_types(cmd_spec)
assert "ok" in out
assert "garbage" not in out
def test_row_column_metadata_skips_non_dict_property_entries():
"""Same defensive skip for ``_row_column_metadata`` so codegen-style
schemas can't trip the column-metadata extraction."""
from openbb_cli.dispatchers.http import _row_column_metadata
cmd_spec = {
"response_schema": {
"properties": {
"results": {
"items": {
"properties": {
"ok": {"type": "string", "description": "Real"},
"garbage": "not-a-dict",
}
}
}
}
}
}
out = _row_column_metadata(cmd_spec)
assert "ok" in out
assert "garbage" not in out
def test_coerce_row_types_handles_empty_types_non_dict_rows_and_unknown_fields():
"""Three branches of ``_coerce_row_types``:
* empty ``column_types`` → no-op pass-through
* non-dict row → kept verbatim
* row field absent from ``column_types`` → value passed through unchanged
"""
from openbb_cli.dispatchers.http import _coerce_row_types
# Empty types — no-op.
assert _coerce_row_types([{"a": "1"}], {}) == [{"a": "1"}]
# Non-dict row — kept verbatim.
out = _coerce_row_types(["scalar", {"a": "1"}], {"a": ("integer", "")})
assert out == ["scalar", {"a": 1}]
# Field not in column_types — passed through unchanged.
out = _coerce_row_types([{"a": "1", "b": "stuff"}], {"a": ("integer", "")})
assert out == [{"a": 1, "b": "stuff"}]
@pytest.mark.asyncio
async def test_apply_param_transforms_folds_socrata_date_range_into_where():
"""``_apply_param_transforms`` rewrites ``start_date`` / ``end_date`` params
into a SoQL ``$where`` clause and applies ``wire_name`` renames so the
spec-mode dispatcher hits the upstream API with the same URL the
installed-extension fetcher would build.
"""
# No ``_socrata_time_axis`` — keeps the test off the date-snapshot
# pagination path so the user's ``limit`` survives intact for the
# ``$limit`` rename assertion.
spec_doc = {
"commands": {
"ag.prices": {
"method": "get",
"parameters": [
{
"name": "start_date",
"_socrata_op": "date_min",
"_socrata_column": "obs_date",
},
{
"name": "end_date",
"_socrata_op": "date_max",
"_socrata_column": "obs_date",
},
{"name": "limit", "wire_name": "$limit"},
"not-a-dict", # defensive non-dict skip
],
}
}
}
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["params"] = dict(request.url.params)
return httpx.Response(200, json=[])
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher(
"http://t",
client=client,
spec_doc=spec_doc,
command_methods={"ag.prices": "get"},
)
try:
await d.dispatch(
Request(
command="ag.prices",
params={
"start_date": "2026-01-01",
"end_date": "2026-04-30",
"limit": 10,
},
)
)
finally:
await d.aclose()
# Where-fold and wire_name rename applied.
assert (
captured["params"]["$where"]
== "obs_date >= '2026-01-01' AND obs_date <= '2026-04-30'"
)
assert captured["params"]["$limit"] == "10"
# User-facing names removed in favour of the wire form.
assert "start_date" not in captured["params"]
assert "end_date" not in captured["params"]
assert "limit" not in captured["params"]
# No default $order without a ``_socrata_time_axis``.
assert "$order" not in captured["params"]
@pytest.mark.asyncio
async def test_apply_param_transforms_default_order_with_time_axis_set():
"""A spec command with ``_socrata_time_axis`` gets ``$order=<axis> DESC``
auto-injected so ``limit=N`` returns the most-recent N rows by default.
User-supplied ``$order`` overrides it.
"""
spec_doc = {
"commands": {
"x": {
"method": "get",
"_socrata_time_axis": "obs_date",
"parameters": [{"name": "foo"}],
}
}
}
captured: dict = {"calls": []}
def handler(request: httpx.Request) -> httpx.Response:
captured["calls"].append(dict(request.url.params))
return httpx.Response(200, json=[])
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher(
"http://t",
client=client,
spec_doc=spec_doc,
command_methods={"x": "get"},
)
try:
# No user $order → auto-injected.
await d.dispatch(Request(command="x", params={"foo": "bar"}))
# User-supplied $order overrides the default.
await d.dispatch(
Request(command="x", params={"foo": "bar", "$order": "obs_date ASC"})
)
finally:
await d.aclose()
assert captured["calls"][0]["$order"] == "obs_date DESC"
assert captured["calls"][1]["$order"] == "obs_date ASC"
@pytest.mark.asyncio
async def test_apply_param_transforms_combines_existing_where_with_date_fold():
"""User-supplied ``$where`` + folded date-range get AND-combined into a
single SoQL clause, parenthesized to preserve precedence."""
spec_doc = {
"commands": {
"x": {
"method": "get",
"parameters": [
{
"name": "start_date",
"_socrata_op": "date_min",
"_socrata_column": "obs_date",
},
],
}
}
}
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["params"] = dict(request.url.params)
return httpx.Response(200, json=[])
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher(
"http://t",
client=client,
spec_doc=spec_doc,
command_methods={"x": "get"},
)
try:
await d.dispatch(
Request(
command="x",
params={"start_date": "2026-01-01", "$where": "region = 'Tampa'"},
)
)
finally:
await d.aclose()
assert (
captured["params"]["$where"]
== "(region = 'Tampa') AND (obs_date >= '2026-01-01')"
)
@pytest.mark.asyncio
async def test_apply_param_transforms_skips_date_range_when_user_omits_value():
"""A date-range param the user didn't supply gets skipped — no
``$where`` clause appended for it. Exercises the ``value is None``
continue at line 882."""
spec_doc = {
"commands": {
"x": {
"method": "get",
"parameters": [
{
"name": "start_date",
"_socrata_op": "date_min",
"_socrata_column": "obs_date",
},
],
}
}
}
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["params"] = dict(request.url.params)
return httpx.Response(200, json=[])
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher(
"http://t",
client=client,
spec_doc=spec_doc,
command_methods={"x": "get"},
)
try:
# No ``start_date`` in params → the date-range fold sees no
# value and skips, leaving no ``$where`` clause.
await d.dispatch(Request(command="x", params={"foo": "bar"}))
finally:
await d.aclose()
assert "$where" not in captured["params"]
@pytest.mark.asyncio
async def test_apply_param_transforms_skips_date_range_when_socrata_column_missing():
"""A date-range param flagged with ``_socrata_op`` but missing the
``_socrata_column`` (malformed spec) gets skipped — no fabricated
where clause. Exercises the ``not column`` continue at line 885."""
spec_doc = {
"commands": {
"x": {
"method": "get",
"parameters": [
{
"name": "start_date",
"_socrata_op": "date_min",
# ``_socrata_column`` deliberately absent.
},
],
}
}
}
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["params"] = dict(request.url.params)
return httpx.Response(200, json=[])
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher(
"http://t",
client=client,
spec_doc=spec_doc,
command_methods={"x": "get"},
)
try:
await d.dispatch(Request(command="x", params={"start_date": "2026-01-01"}))
finally:
await d.aclose()
# Where-clause never built; the user's start_date param ends up
# consumed (popped during the fold attempt) so it doesn't leak to
# the URL either.
assert "$where" not in captured["params"]
@pytest.mark.asyncio
async def test_apply_param_transforms_no_op_when_spec_has_no_parameters():
"""No parameters declared on the command → no transform; params pass
through to the URL exactly as supplied."""
spec_doc = {"commands": {"x": {"method": "get", "parameters": []}}}
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["params"] = dict(request.url.params)
return httpx.Response(200, json=[])
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher(
"http://t",
client=client,
spec_doc=spec_doc,
command_methods={"x": "get"},
)
try:
await d.dispatch(Request(command="x", params={"foo": "bar"}))
finally:
await d.aclose()
assert captured["params"]["foo"] == "bar"
@pytest.mark.asyncio
async def test_fetch_until_n_distinct_dates_paginates_and_breaks_on_short_page():
"""``_fetch_until_n_distinct_dates`` pages until either ``n+1`` distinct
dates are seen OR the upstream returns a short page, whichever comes
first."""
from openbb_cli.dispatchers.http import (
_SOCRATA_PAGE_SIZE,
_fetch_until_n_distinct_dates,
)
pages = [
# First page has ``_SOCRATA_PAGE_SIZE`` rows but only one distinct
# date — must keep paging.
[{"date": "2026-04-30", "x": i} for i in range(_SOCRATA_PAGE_SIZE)],
# Short second page introduces a second distinct date AND signals
# end-of-data via the < page-size length.
[{"date": "2026-04-29", "x": 1}, {"date": "2026-04-29", "x": 2}],
]
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
i = call_count["n"]
call_count["n"] += 1
if i < len(pages):
return httpx.Response(200, json=pages[i])
return httpx.Response(200, json=[])
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://t") as client:
rows = await _fetch_until_n_distinct_dates(
client, "http://t/x", {}, None, "date", 1
)
# Both pages consumed; short page breaks the loop.
assert call_count["n"] == 2
assert len(rows) == _SOCRATA_PAGE_SIZE + 2
@pytest.mark.asyncio
async def test_fetch_until_n_distinct_dates_breaks_on_empty_first_page():
"""Empty first page → exit immediately. Exercises the
``not isinstance(payload, list) or not payload`` break."""
from openbb_cli.dispatchers.http import _fetch_until_n_distinct_dates
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
call_count["n"] += 1
return httpx.Response(200, json=[])
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://t") as client:
rows = await _fetch_until_n_distinct_dates(
client, "http://t/x", {}, None, "date", 5
)
assert rows == []
assert call_count["n"] == 1
@pytest.mark.asyncio
async def test_fetch_until_n_distinct_dates_breaks_on_short_page_before_n_plus_one():
"""A page shorter than ``_SOCRATA_PAGE_SIZE`` arrives before the
(n+1)-th distinct date is seen → exit via the short-page break."""
from openbb_cli.dispatchers.http import _fetch_until_n_distinct_dates
# Page of 5 rows (way under page-size cap), all sharing the same date —
# exits via the ``len(payload) < _SOCRATA_PAGE_SIZE`` branch before
# the n+1 break.
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json=[{"date": "2026-04-30", "x": i} for i in range(5)],
)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://t") as client:
rows = await _fetch_until_n_distinct_dates(
client, "http://t/x", {}, None, "date", 10
)
assert len(rows) == 5
@pytest.mark.asyncio
async def test_fetch_until_n_distinct_dates_breaks_when_n_plus_one_seen():
"""Once we've seen ``n+1`` distinct dates, the loop exits even if the
upstream still has more rows."""
from openbb_cli.dispatchers.http import _fetch_until_n_distinct_dates
page = [
{"date": "2026-04-30", "x": 1},
{"date": "2026-04-29", "x": 2}, # distinct #2 — exceeds n=1.
# Skipped — non-dict row in payload triggers the defensive
# skip in the date-collection loop too.
"garbage",
]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=page)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://t") as client:
rows = await _fetch_until_n_distinct_dates(
client, "http://t/x", {}, None, "date", 1
)
# Single fetch sufficed because n+1 dates appeared.
assert {row.get("date") if isinstance(row, dict) else None for row in rows} == {
"2026-04-30",
"2026-04-29",
None,
}
@pytest.mark.asyncio
async def test_fetch_all_pages_paginates_until_short_page_or_empty():
"""``_fetch_all_pages`` keeps requesting until the upstream returns a
short page OR an empty list (the ``limit=0`` semantic)."""
from openbb_cli.dispatchers.http import _SOCRATA_PAGE_SIZE, _fetch_all_pages
pages = [
[{"x": i} for i in range(_SOCRATA_PAGE_SIZE)],
# Short page — terminates the loop.
[{"x": _SOCRATA_PAGE_SIZE}],
]
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
i = call_count["n"]
call_count["n"] += 1
if i < len(pages):
return httpx.Response(200, json=pages[i])
return httpx.Response(200, json=[])
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://t") as client:
rows = await _fetch_all_pages(client, "http://t/x", {}, None)
assert call_count["n"] == 2
assert len(rows) == _SOCRATA_PAGE_SIZE + 1
@pytest.mark.asyncio
async def test_fetch_all_pages_terminates_on_empty_first_page():
"""Empty first page → loop exits immediately, no second request."""
from openbb_cli.dispatchers.http import _fetch_all_pages
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
call_count["n"] += 1
return httpx.Response(200, json=[])
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, base_url="http://t") as client:
rows = await _fetch_all_pages(client, "http://t/x", {}, None)
assert rows == []
assert call_count["n"] == 1
@pytest.mark.asyncio
async def test_dispatch_engages_fetch_all_when_limit_zero():
"""``limit=0`` flips the dispatch into the all-pages path — exercises
the ``fetch_all`` branch in ``dispatch``.
"""
from openbb_cli.dispatchers.http import _SOCRATA_PAGE_SIZE
pages = [
[{"id": i} for i in range(_SOCRATA_PAGE_SIZE)],
[{"id": _SOCRATA_PAGE_SIZE}],
]
call_count = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
i = call_count["n"]
call_count["n"] += 1
if i < len(pages):
return httpx.Response(200, json=pages[i])
return httpx.Response(200, json=[])
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher(
"http://t",
client=client,
command_methods={"x": "get"},
)
try:
resp = await d.dispatch(Request(command="x", params={"limit": 0}))
finally:
await d.aclose()
assert resp.ok
assert call_count["n"] == 2 # Two pages, then short-page break.
@pytest.mark.asyncio
async def test_dispatch_engages_date_snapshot_truncation_when_time_axis_present():
"""Spec command with a ``_socrata_time_axis`` + a positive ``limit`` engages
the date-snapshot path: pagination via ``_fetch_until_n_distinct_dates``
then local truncation via ``_truncate_to_top_n_dates``.
"""
spec_doc = {
"commands": {
"ds": {
"method": "get",
"_socrata_time_axis": "obs_date",
"parameters": [],
}
}
}
def handler(request: httpx.Request) -> httpx.Response:
# Three distinct dates in a single short page so the n+1 break and
# short-page break both fire.
return httpx.Response(
200,
json=[
{"obs_date": "2026-04-30", "x": 1},
{"obs_date": "2026-04-30", "x": 2},
{"obs_date": "2026-04-29", "x": 3},
{"obs_date": "2026-04-28", "x": 4},
],
)
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher(
"http://t",
client=client,
spec_doc=spec_doc,
command_methods={"ds": "get"},
)
try:
# ``limit=2`` → keep top-2 most recent distinct dates.
resp = await d.dispatch(Request(command="ds", params={"limit": 2}))
finally:
await d.aclose()
assert resp.ok
# Two-date truncation: kept rows for 2026-04-30 and 2026-04-29; dropped 2026-04-28.
distinct = {row["obs_date"] for row in resp.result if isinstance(row, dict)}
assert distinct == {"2026-04-30", "2026-04-29"}
@pytest.mark.asyncio
async def test_dispatch_date_snapshot_skipped_when_limit_zero_or_invalid():
"""``limit=0`` or non-numeric ``limit`` short-circuits the date-
snapshot context — no truncation, no special pagination."""
spec_doc = {
"commands": {
"ds": {
"method": "get",
"_socrata_time_axis": "obs_date",
}
}
}
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=[{"obs_date": "2026-04-30"}])
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport, base_url="http://t")
d = HttpDispatcher("http://t", client=client, spec_doc=spec_doc)
try:
# ``limit=0`` short-circuits — no truncation engaged.
await d.dispatch(Request(command="ds", params={"limit": 0}))
# Non-numeric short-circuits via the int() ValueError path.
await d.dispatch(Request(command="ds", params={"limit": "not-a-number"}))
# No-limit also short-circuits via the ``raw_limit is None`` path.
await d.dispatch(Request(command="ds", params={}))
finally:
await d.aclose()