mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-27 20:42:26 +00:00
* fix detection of sub-routes of a path * ruff fixes * more lint --------- Co-authored-by: dlee-bq <325002271+dlee-bq@users.noreply.github.com>
1591 lines
51 KiB
Python
1591 lines
51 KiB
Python
"""Tests for the precomputed .spec format and command lookup."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from openbb_cli.dispatchers.spec import (
|
|
SPEC_VERSION,
|
|
SpecCommandError,
|
|
_normalize_parameter,
|
|
build_command_spec,
|
|
build_spec_document,
|
|
command_parameters,
|
|
load_spec,
|
|
parse_command_argv,
|
|
parser_from_command_spec,
|
|
write_spec,
|
|
)
|
|
|
|
|
|
def test_normalize_parameter_skips_chart():
|
|
assert (
|
|
_normalize_parameter({"name": "chart", "schema": {"type": "boolean"}}) is None
|
|
)
|
|
|
|
|
|
def test_normalize_parameter_required_string():
|
|
out = _normalize_parameter(
|
|
{"name": "symbol", "required": True, "schema": {"type": "string"}}
|
|
)
|
|
assert out == {
|
|
"name": "symbol",
|
|
"in": "query",
|
|
"type": "string",
|
|
"is_list": False,
|
|
"required": True,
|
|
"default": None,
|
|
"choices": [],
|
|
"example": None,
|
|
"help": None,
|
|
"providers": [],
|
|
"json_arg": False,
|
|
}
|
|
|
|
|
|
def test_normalize_parameter_path_param_is_required_even_without_required_flag():
|
|
"""OpenAPI lets ``in: path`` parameters omit ``required: true`` — normalize."""
|
|
out = _normalize_parameter(
|
|
{"name": "operation", "in": "path", "schema": {"type": "string"}}
|
|
)
|
|
assert out["in"] == "path"
|
|
assert out["required"] is True
|
|
|
|
|
|
def test_normalize_parameter_records_query_location_by_default():
|
|
out = _normalize_parameter({"name": "x", "schema": {"type": "string"}})
|
|
assert out["in"] == "query"
|
|
|
|
|
|
def test_normalize_parameter_with_default_not_required():
|
|
"""A schema-level default flips ``required`` off — same as argparse semantics."""
|
|
out = _normalize_parameter(
|
|
{
|
|
"name": "limit",
|
|
"required": True,
|
|
"schema": {"type": "integer", "default": 10},
|
|
}
|
|
)
|
|
assert out["required"] is False
|
|
assert out["default"] == 10
|
|
|
|
|
|
def test_normalize_parameter_unions_provider_choices_with_enum():
|
|
out = _normalize_parameter(
|
|
{
|
|
"name": "interval",
|
|
"schema": {
|
|
"type": "string",
|
|
"enum": ["1m", "5m"],
|
|
"fmp": {"choices": ["5m", "1h"]},
|
|
"yfinance": {"choices": ["1d"]},
|
|
},
|
|
}
|
|
)
|
|
assert set(out["choices"]) == {"1m", "5m", "1h", "1d"}
|
|
|
|
|
|
def test_normalize_parameter_array_marked_as_list():
|
|
out = _normalize_parameter(
|
|
{"name": "symbols", "schema": {"type": "array", "items": {"type": "string"}}}
|
|
)
|
|
assert out["is_list"] is True
|
|
assert out["type"] == "string"
|
|
|
|
|
|
def test_normalize_parameter_help_falls_back_to_schema_description():
|
|
out = _normalize_parameter(
|
|
{"name": "x", "schema": {"type": "string", "description": "from schema"}}
|
|
)
|
|
assert out["help"] == "from schema"
|
|
|
|
|
|
def test_build_command_spec_keys_by_dotted_path():
|
|
openapi = {
|
|
"paths": {
|
|
"/api/v1/equity/quote": {
|
|
"get": {
|
|
"operationId": "eq",
|
|
"summary": "Quote",
|
|
"description": "Get quote.",
|
|
"parameters": [
|
|
{
|
|
"name": "symbol",
|
|
"required": True,
|
|
"schema": {"type": "string"},
|
|
}
|
|
],
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out = build_command_spec(openapi)
|
|
assert "equity.quote" in out
|
|
entry = out["equity.quote"]
|
|
assert entry["url_path"] == "/api/v1/equity/quote"
|
|
assert entry["method"] == "get"
|
|
assert entry["description"] == "Get quote."
|
|
assert entry["parameters"][0]["name"] == "symbol"
|
|
|
|
|
|
def test_build_command_spec_uses_post_when_get_missing():
|
|
openapi = {"paths": {"/api/v1/x": {"post": {"operationId": "x"}}}}
|
|
assert build_command_spec(openapi)["x"]["method"] == "post"
|
|
|
|
|
|
def test_build_command_spec_skips_other_methods():
|
|
openapi = {"paths": {"/api/v1/x": {"delete": {"operationId": "x"}}}}
|
|
assert build_command_spec(openapi) == {}
|
|
|
|
|
|
def test_build_command_spec_drops_chart_parameter():
|
|
openapi = {
|
|
"paths": {
|
|
"/api/v1/x": {
|
|
"get": {
|
|
"operationId": "x",
|
|
"parameters": [
|
|
{"name": "chart", "schema": {"type": "boolean"}},
|
|
{"name": "symbol", "schema": {"type": "string"}},
|
|
],
|
|
}
|
|
}
|
|
}
|
|
}
|
|
params = build_command_spec(openapi)["x"]["parameters"]
|
|
assert {p["name"] for p in params} == {"symbol"}
|
|
|
|
|
|
def test_build_spec_document_carries_metadata():
|
|
openapi = {
|
|
"paths": {
|
|
"/api/v1/x": {"get": {"operationId": "x", "summary": "X", "tags": ["x"]}}
|
|
},
|
|
"tags": [{"name": "x", "description": "X stuff."}],
|
|
}
|
|
doc = build_spec_document(openapi, base_url="http://h:1/")
|
|
assert doc["version"] == SPEC_VERSION
|
|
assert doc["base_url"] == "http://h:1"
|
|
assert doc["api_prefix"] == "/api/v1"
|
|
assert "x" in doc["commands"]
|
|
assert "x" in doc["routers"]
|
|
assert "/x" in doc["reference"]["paths"]
|
|
|
|
|
|
def test_write_then_load_round_trip(tmp_path):
|
|
openapi = {"paths": {"/api/v1/x": {"get": {"operationId": "x", "parameters": []}}}}
|
|
doc = build_spec_document(openapi, base_url="http://h")
|
|
target = tmp_path / "out.spec"
|
|
write_spec(target, doc)
|
|
loaded = load_spec(target)
|
|
assert loaded["base_url"] == "http://h"
|
|
assert "x" in loaded["commands"]
|
|
|
|
|
|
def test_load_spec_rejects_unknown_version(tmp_path):
|
|
target = tmp_path / "bad.spec"
|
|
target.write_text(json.dumps({"version": SPEC_VERSION + 99, "commands": {}}))
|
|
with pytest.raises(ValueError, match="version"):
|
|
load_spec(target)
|
|
|
|
|
|
def test_build_spec_document_records_provenance():
|
|
openapi = {
|
|
"openapi": "3.1.0",
|
|
"paths": {"/api/v1/x": {"get": {"operationId": "x", "parameters": []}}},
|
|
}
|
|
doc = build_spec_document(
|
|
openapi,
|
|
base_url="http://h",
|
|
source_url="https://example.com/openapi.json",
|
|
)
|
|
assert doc["source_url"] == "https://example.com/openapi.json"
|
|
assert doc["api_version"] == "3.1.0"
|
|
assert doc["generator"].startswith("openbb-cli")
|
|
assert doc["generated_at"]
|
|
|
|
|
|
def test_build_spec_document_falls_back_to_swagger_field():
|
|
"""Pre-OpenAPI 3 specs use ``swagger: "2.0"`` instead of ``openapi``."""
|
|
openapi = {"swagger": "2.0", "paths": {}}
|
|
doc = build_spec_document(openapi, base_url="http://h")
|
|
assert doc["api_version"] == "2.0"
|
|
assert doc["source_url"] == ""
|
|
|
|
|
|
def test_normalize_parameter_captures_example_from_param_object():
|
|
out = _normalize_parameter(
|
|
{
|
|
"name": "symbol",
|
|
"required": True,
|
|
"example": "AAPL",
|
|
"schema": {"type": "string"},
|
|
}
|
|
)
|
|
assert out["example"] == "AAPL"
|
|
|
|
|
|
def test_normalize_parameter_captures_example_from_examples_map():
|
|
"""OpenAPI 3.x ``examples`` map: pick the first entry's ``value`` field."""
|
|
out = _normalize_parameter(
|
|
{
|
|
"name": "symbol",
|
|
"required": True,
|
|
"examples": {"primary": {"value": "MSFT"}, "alt": {"value": "GOOG"}},
|
|
"schema": {"type": "string"},
|
|
}
|
|
)
|
|
assert out["example"] == "MSFT"
|
|
|
|
|
|
def test_normalize_parameter_captures_example_from_schema_when_param_has_none():
|
|
out = _normalize_parameter(
|
|
{
|
|
"name": "n",
|
|
"required": True,
|
|
"schema": {"type": "integer", "example": 5},
|
|
}
|
|
)
|
|
assert out["example"] == 5
|
|
|
|
|
|
def test_normalize_parameter_promotes_path_number_to_integer():
|
|
"""``--number 10`` against ``/last/{number}.{format}`` would otherwise
|
|
cast to ``10.0`` and produce ``/last/10.0.json`` which the API rejects."""
|
|
out = _normalize_parameter(
|
|
{
|
|
"name": "number",
|
|
"in": "path",
|
|
"required": True,
|
|
"schema": {"type": "number"},
|
|
}
|
|
)
|
|
assert out["type"] == "integer"
|
|
|
|
|
|
def test_normalize_parameter_keeps_query_number_as_number():
|
|
"""Non-path numerics stay floats — only path placeholders flip to int."""
|
|
out = _normalize_parameter(
|
|
{
|
|
"name": "ratio",
|
|
"in": "query",
|
|
"required": True,
|
|
"schema": {"type": "number"},
|
|
}
|
|
)
|
|
assert out["type"] == "number"
|
|
|
|
|
|
def test_normalize_parameter_examples_map_skips_non_dict_entries():
|
|
out = _normalize_parameter(
|
|
{
|
|
"name": "x",
|
|
"required": True,
|
|
"examples": {"bad": "not a dict", "good": {"value": "ok"}},
|
|
"schema": {"type": "string"},
|
|
}
|
|
)
|
|
assert out["example"] == "ok"
|
|
|
|
|
|
def test_generator_identifier_falls_back_when_package_not_installed(monkeypatch):
|
|
"""When ``importlib.metadata.version`` raises, the identifier degrades gracefully."""
|
|
import openbb_cli.dispatchers.spec as spec_mod
|
|
|
|
def _raise(_name):
|
|
raise spec_mod.PackageNotFoundError("openbb-cli")
|
|
|
|
monkeypatch.setattr(spec_mod, "_pkg_version", _raise)
|
|
assert spec_mod._generator_identifier() == "openbb-cli"
|
|
|
|
|
|
# --- Schema validation ---
|
|
|
|
|
|
def _minimal_valid_spec():
|
|
return {
|
|
"version": SPEC_VERSION,
|
|
"base_url": "https://example.com",
|
|
"api_prefix": "/api",
|
|
"commands": {
|
|
"x.y": {
|
|
"url_path": "/api/x/y",
|
|
"method": "get",
|
|
"description": "X",
|
|
"parameters": [
|
|
{
|
|
"name": "symbol",
|
|
"in": "query",
|
|
"type": "string",
|
|
"is_list": False,
|
|
"required": True,
|
|
"default": None,
|
|
"choices": [],
|
|
"help": "Ticker.",
|
|
"providers": [],
|
|
}
|
|
],
|
|
"providers": [],
|
|
}
|
|
},
|
|
"routers": {},
|
|
"reference": {},
|
|
}
|
|
|
|
|
|
def test_load_spec_accepts_minimal_valid_document(tmp_path):
|
|
target = tmp_path / "ok.spec"
|
|
target.write_text(json.dumps(_minimal_valid_spec()))
|
|
loaded = load_spec(target)
|
|
assert loaded["base_url"] == "https://example.com"
|
|
assert "x.y" in loaded["commands"]
|
|
|
|
|
|
def test_load_spec_rejects_missing_required_top_level_key(tmp_path):
|
|
spec = _minimal_valid_spec()
|
|
del spec["commands"]
|
|
target = tmp_path / "missing.spec"
|
|
target.write_text(json.dumps(spec))
|
|
with pytest.raises(ValueError, match="does not conform"):
|
|
load_spec(target)
|
|
|
|
|
|
def test_load_spec_rejects_command_missing_url_path(tmp_path):
|
|
spec = _minimal_valid_spec()
|
|
del spec["commands"]["x.y"]["url_path"]
|
|
target = tmp_path / "bad_cmd.spec"
|
|
target.write_text(json.dumps(spec))
|
|
with pytest.raises(ValueError, match="url_path"):
|
|
load_spec(target)
|
|
|
|
|
|
def test_load_spec_rejects_parameter_missing_name(tmp_path):
|
|
spec = _minimal_valid_spec()
|
|
spec["commands"]["x.y"]["parameters"][0].pop("name")
|
|
target = tmp_path / "bad_param.spec"
|
|
target.write_text(json.dumps(spec))
|
|
with pytest.raises(ValueError, match="name"):
|
|
load_spec(target)
|
|
|
|
|
|
def test_load_spec_rejects_wrong_type_for_field(tmp_path):
|
|
spec = _minimal_valid_spec()
|
|
spec["base_url"] = 123 # should be a string
|
|
target = tmp_path / "wrong_type.spec"
|
|
target.write_text(json.dumps(spec))
|
|
with pytest.raises(ValueError, match="does not conform"):
|
|
load_spec(target)
|
|
|
|
|
|
def test_load_spec_tolerates_unknown_top_level_fields(tmp_path):
|
|
"""Forward-compat: extra fields from a newer generator must not break loading."""
|
|
spec = _minimal_valid_spec()
|
|
spec["future_field"] = {"some": "metadata"}
|
|
spec["commands"]["x.y"]["future_command_field"] = "ok"
|
|
target = tmp_path / "future.spec"
|
|
target.write_text(json.dumps(spec))
|
|
loaded = load_spec(target)
|
|
assert loaded["future_field"] == {"some": "metadata"}
|
|
|
|
|
|
def test_round_trip_real_built_spec_validates(tmp_path):
|
|
"""A spec produced by ``build_spec_document`` must round-trip through validation."""
|
|
openapi = {
|
|
"openapi": "3.0.0",
|
|
"paths": {
|
|
"/api/v1/x": {
|
|
"get": {
|
|
"operationId": "x",
|
|
"parameters": [
|
|
{
|
|
"name": "symbol",
|
|
"in": "query",
|
|
"required": True,
|
|
"schema": {"type": "string"},
|
|
}
|
|
],
|
|
}
|
|
}
|
|
},
|
|
}
|
|
doc = build_spec_document(openapi, base_url="http://h")
|
|
target = tmp_path / "real.spec"
|
|
write_spec(target, doc)
|
|
loaded = load_spec(target)
|
|
assert loaded["commands"]["x"]["url_path"] == "/api/v1/x"
|
|
|
|
|
|
# --- SHA-256 integrity check ---
|
|
|
|
|
|
def test_write_spec_stamps_content_sha256(tmp_path):
|
|
spec = _minimal_valid_spec()
|
|
target = tmp_path / "stamped.spec"
|
|
write_spec(target, spec)
|
|
raw = json.loads(target.read_text())
|
|
assert "content_sha256" in raw
|
|
assert len(raw["content_sha256"]) == 64 # hex-encoded sha256
|
|
|
|
|
|
def test_load_spec_accepts_genuine_hash(tmp_path):
|
|
spec = _minimal_valid_spec()
|
|
target = tmp_path / "ok.spec"
|
|
write_spec(target, spec)
|
|
loaded = load_spec(target)
|
|
assert loaded["content_sha256"]
|
|
|
|
|
|
def test_load_spec_rejects_tampered_content(tmp_path):
|
|
"""Hand-edits after generation must trip the integrity check."""
|
|
spec = _minimal_valid_spec()
|
|
target = tmp_path / "tamper.spec"
|
|
write_spec(target, spec)
|
|
raw = json.loads(target.read_text())
|
|
raw["base_url"] = "https://attacker.example"
|
|
target.write_text(json.dumps(raw, separators=(",", ":")))
|
|
with pytest.raises(ValueError, match="failed integrity check"):
|
|
load_spec(target)
|
|
|
|
|
|
def test_load_spec_skips_hash_check_when_field_absent(tmp_path):
|
|
"""Specs from older generators predate the hash field — must still load."""
|
|
spec = _minimal_valid_spec()
|
|
target = tmp_path / "legacy.spec"
|
|
target.write_text(json.dumps(spec, separators=(",", ":")))
|
|
loaded = load_spec(target)
|
|
assert loaded.get("content_sha256") is None
|
|
|
|
|
|
def test_content_hash_is_stable_under_unrelated_field_order(tmp_path):
|
|
"""Canonical JSON ordering means a re-saved doc with identical content
|
|
must hash identically regardless of original key insertion order."""
|
|
from openbb_cli.dispatchers.spec import _content_hash
|
|
|
|
spec_a = {"version": SPEC_VERSION, "base_url": "h", "commands": {}, "x": 1}
|
|
spec_b = {"x": 1, "commands": {}, "base_url": "h", "version": SPEC_VERSION}
|
|
assert _content_hash(spec_a) == _content_hash(spec_b)
|
|
|
|
|
|
def test_write_spec_is_compact(tmp_path):
|
|
"""Spec files should be compact (no whitespace) to keep them small on disk."""
|
|
target = tmp_path / "x.spec"
|
|
write_spec(target, {"version": SPEC_VERSION, "commands": {}})
|
|
text = target.read_text()
|
|
assert "\n" not in text
|
|
assert ", " not in text
|
|
assert ": " not in text
|
|
|
|
|
|
def test_parser_from_command_spec_required_arg_enforced():
|
|
cmd_spec = {
|
|
"url_path": "/api/v1/x",
|
|
"method": "get",
|
|
"description": "x",
|
|
"parameters": [
|
|
{
|
|
"name": "symbol",
|
|
"type": "string",
|
|
"is_list": False,
|
|
"required": True,
|
|
"default": None,
|
|
"choices": [],
|
|
"help": None,
|
|
}
|
|
],
|
|
}
|
|
parser = parser_from_command_spec(cmd_spec)
|
|
with pytest.raises(SystemExit):
|
|
parser.parse_args([])
|
|
ns = parser.parse_args(["--symbol", "AAPL"])
|
|
assert ns.symbol == "AAPL"
|
|
|
|
|
|
def test_command_parameters_fuses_request_body_fields():
|
|
"""``command_parameters`` flattens request-body fields into the parameter list."""
|
|
cmd_spec = {
|
|
"url_path": "/api/v1/technical/sma",
|
|
"method": "post",
|
|
"parameters": [],
|
|
"request_body_schema": {
|
|
"type": "object",
|
|
"required": ["data"],
|
|
"properties": {
|
|
"data": {"type": "array", "items": {"type": "object"}},
|
|
"length": {"type": "integer", "default": 14},
|
|
},
|
|
},
|
|
}
|
|
by_name = {p["name"]: p for p in command_parameters(cmd_spec)}
|
|
assert by_name["data"]["in"] == "body"
|
|
assert by_name["data"]["json_arg"] is True
|
|
assert by_name["data"]["type"] == "string"
|
|
assert by_name["length"]["json_arg"] is False
|
|
assert by_name["length"]["type"] == "integer"
|
|
|
|
|
|
def test_parser_from_command_spec_json_arg_decodes_body_fields():
|
|
"""``json_arg`` body fields parse their value as a JSON document."""
|
|
cmd_spec = {
|
|
"url_path": "/api/v1/technical/screen",
|
|
"method": "post",
|
|
"parameters": [],
|
|
"request_body_schema": {
|
|
"type": "object",
|
|
"required": ["conditions"],
|
|
"properties": {
|
|
"conditions": {"type": "array", "items": {"type": "object"}},
|
|
"data": {"type": "array", "items": {"type": "object"}},
|
|
},
|
|
},
|
|
}
|
|
parser = parser_from_command_spec(cmd_spec)
|
|
with pytest.raises(SystemExit):
|
|
parser.parse_args([])
|
|
ns = parser.parse_args(
|
|
["--conditions", '[{"indicator": "rsi"}]', "--data", '[{"close": 1}]']
|
|
)
|
|
assert ns.conditions == [{"indicator": "rsi"}]
|
|
assert ns.data == [{"close": 1}]
|
|
|
|
|
|
def test_parser_from_command_spec_choices_list_default():
|
|
cmd_spec = {
|
|
"url_path": "/api/v1/x",
|
|
"method": "get",
|
|
"description": "",
|
|
"parameters": [
|
|
{
|
|
"name": "interval",
|
|
"type": "string",
|
|
"is_list": False,
|
|
"required": False,
|
|
"default": "1d",
|
|
"choices": ["1m", "5m", "1d"],
|
|
"help": None,
|
|
},
|
|
{
|
|
"name": "symbols",
|
|
"type": "string",
|
|
"is_list": True,
|
|
"required": False,
|
|
"default": None,
|
|
"choices": [],
|
|
"help": None,
|
|
},
|
|
],
|
|
}
|
|
parser = parser_from_command_spec(cmd_spec)
|
|
ns = parser.parse_args(["--symbols", "A", "B", "C"])
|
|
assert ns.symbols == ["A", "B", "C"]
|
|
assert ns.interval == "1d"
|
|
with pytest.raises(SystemExit):
|
|
parser.parse_args(["--interval", "bogus"])
|
|
|
|
|
|
def test_parser_from_command_spec_boolean_flag():
|
|
cmd_spec = {
|
|
"url_path": "/api/v1/x",
|
|
"method": "get",
|
|
"description": "",
|
|
"parameters": [
|
|
{
|
|
"name": "verbose",
|
|
"type": "boolean",
|
|
"is_list": False,
|
|
"required": False,
|
|
"default": False,
|
|
"choices": [],
|
|
"help": None,
|
|
}
|
|
],
|
|
}
|
|
parser = parser_from_command_spec(cmd_spec)
|
|
ns = parser.parse_args(["--verbose"])
|
|
assert ns.verbose is True
|
|
|
|
|
|
def test_parser_from_command_spec_skips_duplicate_parameter():
|
|
"""Duplicate names in the spec — first wins, second silently dropped."""
|
|
cmd_spec = {
|
|
"url_path": "/api/v1/x",
|
|
"method": "get",
|
|
"description": "",
|
|
"parameters": [
|
|
{
|
|
"name": "x",
|
|
"type": "string",
|
|
"is_list": False,
|
|
"required": False,
|
|
"default": "first",
|
|
"choices": [],
|
|
"help": None,
|
|
},
|
|
{
|
|
"name": "x",
|
|
"type": "integer",
|
|
"is_list": False,
|
|
"required": False,
|
|
"default": 99,
|
|
"choices": [],
|
|
"help": None,
|
|
},
|
|
],
|
|
}
|
|
parser = parser_from_command_spec(cmd_spec)
|
|
ns = parser.parse_args([])
|
|
assert ns.x == "first"
|
|
|
|
|
|
def _spec_with(commands: dict) -> dict:
|
|
return {
|
|
"version": SPEC_VERSION,
|
|
"base_url": "http://h",
|
|
"api_prefix": "/api/v1",
|
|
"commands": commands,
|
|
"routers": {},
|
|
"reference": {"paths": {}, "routers": {}},
|
|
}
|
|
|
|
|
|
def test_parse_command_argv_returns_command_and_params():
|
|
spec_doc = _spec_with(
|
|
{
|
|
"equity.quote": {
|
|
"url_path": "/api/v1/equity/quote",
|
|
"method": "get",
|
|
"description": "",
|
|
"parameters": [
|
|
{
|
|
"name": "symbol",
|
|
"type": "string",
|
|
"is_list": False,
|
|
"required": True,
|
|
"default": None,
|
|
"choices": [],
|
|
"help": None,
|
|
}
|
|
],
|
|
}
|
|
}
|
|
)
|
|
cmd, params = parse_command_argv(spec_doc, ["equity.quote", "--symbol", "AAPL"])
|
|
assert cmd == "equity.quote"
|
|
assert params == {"symbol": "AAPL"}
|
|
|
|
|
|
def test_parse_command_argv_empty_argv_raises():
|
|
with pytest.raises(SpecCommandError, match="missing command"):
|
|
parse_command_argv(_spec_with({}), [])
|
|
|
|
|
|
def test_parse_command_argv_unknown_command_raises():
|
|
with pytest.raises(SpecCommandError, match="not in spec"):
|
|
parse_command_argv(_spec_with({}), ["nope"])
|
|
|
|
|
|
def test_build_command_spec_skips_empty_command():
|
|
"""A URL that strips to an empty dotted command is skipped."""
|
|
spec = {"paths": {"/api/v1": {"get": {"operationId": "x", "parameters": []}}}}
|
|
assert build_command_spec(spec) == {}
|
|
|
|
|
|
def test_build_command_spec_merges_url_overloads():
|
|
"""URLs that strip to the same dotted command merge into one entry with ``url_templates``."""
|
|
spec = {
|
|
"paths": {
|
|
"/api/x/{a}": {"get": {"operationId": "first", "parameters": []}},
|
|
"/api/x/{a}/{b}": {"get": {"operationId": "second", "parameters": []}},
|
|
"/api/x/{c}/{d}": {"get": {"operationId": "third", "parameters": []}},
|
|
}
|
|
}
|
|
out = build_command_spec(spec, api_prefix="/api")
|
|
assert list(out.keys()) == ["x"]
|
|
entry = out["x"]
|
|
assert sorted(entry["url_templates"]) == sorted(
|
|
["/api/x/{a}", "/api/x/{a}/{b}", "/api/x/{c}/{d}"]
|
|
)
|
|
assert entry["url_path"] == "/api/x/{a}"
|
|
|
|
|
|
def test_build_command_spec_marks_overload_path_params_optional():
|
|
"""Path params that only appear in some variants of an overload become optional."""
|
|
param_country = {
|
|
"name": "country",
|
|
"in": "path",
|
|
"required": True,
|
|
"schema": {"type": "string"},
|
|
}
|
|
param_d1 = {
|
|
"name": "d1",
|
|
"in": "path",
|
|
"required": True,
|
|
"schema": {"type": "string"},
|
|
}
|
|
param_d2 = {
|
|
"name": "d2",
|
|
"in": "path",
|
|
"required": True,
|
|
"schema": {"type": "string"},
|
|
}
|
|
spec = {
|
|
"paths": {
|
|
"/api/ratings/historical/{country}": {
|
|
"get": {"parameters": [param_country]}
|
|
},
|
|
"/api/ratings/historical/{country}/{d1}/{d2}": {
|
|
"get": {"parameters": [param_country, param_d1, param_d2]}
|
|
},
|
|
}
|
|
}
|
|
out = build_command_spec(spec, api_prefix="/api")
|
|
entry = out["ratings.historical"]
|
|
by_name = {p["name"]: p for p in entry["parameters"]}
|
|
assert by_name["country"]["required"] is True
|
|
assert by_name["d1"]["required"] is False
|
|
assert by_name["d2"]["required"] is False
|
|
|
|
|
|
def test_merge_overload_backfills_missing_param_metadata():
|
|
"""When one overload entry omits help/choices/default/example, fill from a sibling."""
|
|
long_param = {
|
|
"name": "fmt",
|
|
"in": "query",
|
|
"required": False,
|
|
"schema": {"type": "string"},
|
|
}
|
|
short_param = {
|
|
"name": "fmt",
|
|
"in": "query",
|
|
"required": False,
|
|
"description": "Response format.",
|
|
"schema": {
|
|
"type": "string",
|
|
"enum": ["json", "csv"],
|
|
"default": "json",
|
|
"example": "json",
|
|
},
|
|
}
|
|
spec = {
|
|
"paths": {
|
|
"/api/x/{a}/{b}": {"get": {"parameters": [long_param]}},
|
|
"/api/x/{a}": {"get": {"parameters": [short_param]}},
|
|
}
|
|
}
|
|
out = build_command_spec(spec, api_prefix="/api")
|
|
by_name = {p["name"]: p for p in out["x"]["parameters"]}
|
|
fmt = by_name["fmt"]
|
|
assert fmt["help"] == "Response format."
|
|
assert fmt["choices"] == ["json", "csv"]
|
|
assert fmt["default"] == "json"
|
|
assert fmt["example"] == "json"
|
|
|
|
|
|
def test_parse_command_argv_drops_none_params():
|
|
"""Optional flags left at their default ``None`` shouldn't go on the wire."""
|
|
spec_doc = _spec_with(
|
|
{
|
|
"x": {
|
|
"url_path": "/api/v1/x",
|
|
"method": "get",
|
|
"description": "",
|
|
"parameters": [
|
|
{
|
|
"name": "a",
|
|
"type": "string",
|
|
"is_list": False,
|
|
"required": False,
|
|
"default": None,
|
|
"choices": [],
|
|
"help": None,
|
|
},
|
|
{
|
|
"name": "b",
|
|
"type": "string",
|
|
"is_list": False,
|
|
"required": False,
|
|
"default": "hi",
|
|
"choices": [],
|
|
"help": None,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
)
|
|
_, params = parse_command_argv(spec_doc, ["x"])
|
|
assert params == {"b": "hi"}
|
|
|
|
|
|
# --- Multi-provider parser narrowing ---
|
|
|
|
|
|
def test_operation_providers_extracts_enum_from_provider_param():
|
|
from openbb_cli.dispatchers.spec import _operation_providers
|
|
|
|
op = {
|
|
"parameters": [
|
|
{"name": "provider", "schema": {"enum": ["cboe", "fmp", "intrinio"]}},
|
|
{"name": "symbol", "schema": {"type": "string"}},
|
|
]
|
|
}
|
|
assert _operation_providers(op) == ["cboe", "fmp", "intrinio"]
|
|
|
|
|
|
def test_operation_providers_returns_empty_when_no_provider_param():
|
|
from openbb_cli.dispatchers.spec import _operation_providers
|
|
|
|
op = {"parameters": [{"name": "x", "schema": {"type": "string"}}]}
|
|
assert _operation_providers(op) == []
|
|
|
|
|
|
def test_operation_providers_extracts_const_for_single_provider():
|
|
"""A single-provider endpoint uses ``const`` instead of ``enum``."""
|
|
from openbb_cli.dispatchers.spec import _operation_providers
|
|
|
|
op = {"parameters": [{"name": "provider", "schema": {"const": "eia"}}]}
|
|
assert _operation_providers(op) == ["eia"]
|
|
|
|
|
|
def test_operation_providers_returns_empty_when_provider_schema_lacks_enum_or_const():
|
|
"""``provider`` parameter without ``enum`` and without ``const`` → ``[]``."""
|
|
from openbb_cli.dispatchers.spec import _operation_providers
|
|
|
|
op = {"parameters": [{"name": "provider", "schema": {"type": "string"}}]}
|
|
assert _operation_providers(op) == []
|
|
|
|
|
|
def test_normalize_parameter_records_providers_for_multi_provider_op():
|
|
"""The ``providers`` field is populated only when ``providers_set`` is supplied."""
|
|
out = _normalize_parameter(
|
|
{"name": "use_cache", "schema": {"type": "boolean", "title": "cboe"}},
|
|
providers_set={"cboe", "fmp"},
|
|
)
|
|
assert out["providers"] == ["cboe"]
|
|
|
|
|
|
def test_normalize_parameter_provider_param_itself_has_no_providers_tag():
|
|
"""The ``provider`` discriminator applies to all backends; tags stay empty."""
|
|
out = _normalize_parameter(
|
|
{"name": "provider", "schema": {"enum": ["cboe", "fmp"], "type": "string"}},
|
|
providers_set={"cboe", "fmp"},
|
|
)
|
|
assert out["providers"] == []
|
|
|
|
|
|
def test_param_visible_for_provider_keeps_shared_and_matching():
|
|
from openbb_cli.dispatchers.spec import _param_visible_for_provider
|
|
|
|
shared = {"name": "symbol", "providers": []}
|
|
cboe_only = {"name": "use_cache", "providers": ["cboe"]}
|
|
intrinio_only = {"name": "source", "providers": ["intrinio"]}
|
|
provider_param = {"name": "provider", "providers": []}
|
|
|
|
# No selected provider: everything visible
|
|
for p in (shared, cboe_only, intrinio_only):
|
|
assert _param_visible_for_provider(p, None) is True
|
|
|
|
# provider=cboe: shared + cboe-only visible, intrinio hidden
|
|
assert _param_visible_for_provider(shared, "cboe") is True
|
|
assert _param_visible_for_provider(cboe_only, "cboe") is True
|
|
assert _param_visible_for_provider(intrinio_only, "cboe") is False
|
|
# provider discriminator always visible
|
|
assert _param_visible_for_provider(provider_param, "cboe") is True
|
|
|
|
|
|
def test_parser_from_command_spec_narrows_to_selected_provider():
|
|
cmd_spec = {
|
|
"url_path": "/q",
|
|
"parameters": [
|
|
{
|
|
"name": "provider",
|
|
"type": "string",
|
|
"required": True,
|
|
"choices": ["cboe", "intrinio"],
|
|
"providers": [],
|
|
},
|
|
{"name": "symbol", "type": "string", "required": True, "providers": []},
|
|
{"name": "use_cache", "type": "boolean", "providers": ["cboe"]},
|
|
{"name": "source", "type": "string", "providers": ["intrinio"]},
|
|
],
|
|
}
|
|
parser = parser_from_command_spec(cmd_spec, selected_provider="cboe")
|
|
flags = {a.option_strings[0] for a in parser._actions if a.option_strings}
|
|
# cboe sees use_cache (and its --no-use_cache pair) but NOT source
|
|
assert "--symbol" in flags
|
|
assert "--use_cache" in flags
|
|
assert "--source" not in flags
|
|
|
|
|
|
def test_parse_command_argv_two_pass_narrows_by_provider():
|
|
"""End-to-end: --provider intrinio --use_cache fails because use_cache is cboe-only."""
|
|
spec_doc = {
|
|
"version": SPEC_VERSION,
|
|
"commands": {
|
|
"q": {
|
|
"url_path": "/q",
|
|
"method": "get",
|
|
"providers": ["cboe", "intrinio"],
|
|
"parameters": [
|
|
{
|
|
"name": "provider",
|
|
"in": "query",
|
|
"type": "string",
|
|
"is_list": False,
|
|
"required": True,
|
|
"choices": ["cboe", "intrinio"],
|
|
"default": None,
|
|
"help": None,
|
|
"providers": [],
|
|
},
|
|
{
|
|
"name": "symbol",
|
|
"in": "query",
|
|
"type": "string",
|
|
"is_list": False,
|
|
"required": True,
|
|
"default": None,
|
|
"choices": [],
|
|
"help": None,
|
|
"providers": [],
|
|
},
|
|
{
|
|
"name": "use_cache",
|
|
"in": "query",
|
|
"type": "boolean",
|
|
"is_list": False,
|
|
"required": False,
|
|
"default": True,
|
|
"choices": [],
|
|
"help": None,
|
|
"providers": ["cboe"],
|
|
},
|
|
],
|
|
}
|
|
},
|
|
}
|
|
# Valid: cboe with use_cache toggled off
|
|
_, params = parse_command_argv(
|
|
spec_doc, ["q", "--provider", "cboe", "--symbol", "AAPL", "--no-use_cache"]
|
|
)
|
|
assert params == {"provider": "cboe", "symbol": "AAPL", "use_cache": False}
|
|
|
|
# Invalid: intrinio + use_cache → argparse SystemExit on unrecognized
|
|
with pytest.raises(SystemExit):
|
|
parse_command_argv(
|
|
spec_doc,
|
|
["q", "--provider", "intrinio", "--symbol", "AAPL", "--use_cache"],
|
|
)
|
|
|
|
|
|
def test_parse_command_argv_falls_back_to_full_parser_without_provider():
|
|
"""Single-provider commands skip the peek pass and use the full parser."""
|
|
spec_doc = {
|
|
"version": SPEC_VERSION,
|
|
"commands": {
|
|
"x": {
|
|
"url_path": "/x",
|
|
"method": "get",
|
|
"providers": [],
|
|
"parameters": [
|
|
{
|
|
"name": "y",
|
|
"in": "query",
|
|
"type": "string",
|
|
"is_list": False,
|
|
"required": True,
|
|
"default": None,
|
|
"choices": [],
|
|
"help": None,
|
|
"providers": [],
|
|
},
|
|
],
|
|
}
|
|
},
|
|
}
|
|
_, params = parse_command_argv(spec_doc, ["x", "--y", "1"])
|
|
assert params == {"y": "1"}
|
|
|
|
|
|
def test_boolean_param_supports_no_flag_form():
|
|
"""``BooleanOptionalAction`` lets ``--no-flag`` flip a True default."""
|
|
cmd_spec = {
|
|
"url_path": "/x",
|
|
"parameters": [
|
|
{"name": "use_cache", "type": "boolean", "default": True, "providers": []},
|
|
],
|
|
}
|
|
parser = parser_from_command_spec(cmd_spec)
|
|
ns = parser.parse_args(["--no-use_cache"])
|
|
assert ns.use_cache is False
|
|
ns = parser.parse_args(["--use_cache"])
|
|
assert ns.use_cache is True
|
|
|
|
|
|
# --- _resolve_base_url branches ---
|
|
|
|
|
|
def test_resolve_base_url_absolute_server_replaces_when_user_has_no_path():
|
|
from openbb_cli.dispatchers.spec import _resolve_base_url
|
|
|
|
openapi = {"servers": [{"url": "https://api.example.com/v3"}]}
|
|
assert _resolve_base_url(openapi, "https://api.example.com") == (
|
|
"https://api.example.com/v3"
|
|
)
|
|
|
|
|
|
def test_resolve_base_url_absolute_server_keeps_user_when_user_has_path():
|
|
"""If the user already supplied a path, don't replace it."""
|
|
from openbb_cli.dispatchers.spec import _resolve_base_url
|
|
|
|
openapi = {"servers": [{"url": "https://api.example.com/v3"}]}
|
|
assert _resolve_base_url(openapi, "https://api.example.com/custom") == (
|
|
"https://api.example.com/custom"
|
|
)
|
|
|
|
|
|
def test_resolve_base_url_relative_server_appends_to_user():
|
|
from openbb_cli.dispatchers.spec import _resolve_base_url
|
|
|
|
openapi = {"servers": [{"url": "/v3"}]}
|
|
assert _resolve_base_url(openapi, "https://api.example.com") == (
|
|
"https://api.example.com/v3"
|
|
)
|
|
|
|
|
|
def test_resolve_base_url_relative_server_skips_if_user_already_has_it():
|
|
"""User-supplied URL ending with the server path: no double-append."""
|
|
from openbb_cli.dispatchers.spec import _resolve_base_url
|
|
|
|
openapi = {"servers": [{"url": "/v3"}]}
|
|
assert _resolve_base_url(openapi, "https://api.example.com/v3") == (
|
|
"https://api.example.com/v3"
|
|
)
|
|
|
|
|
|
def test_resolve_base_url_no_servers_returns_user():
|
|
from openbb_cli.dispatchers.spec import _resolve_base_url
|
|
|
|
assert _resolve_base_url({}, "https://api.example.com/") == (
|
|
"https://api.example.com"
|
|
)
|
|
|
|
|
|
def test_resolve_base_url_empty_server_url_returns_user():
|
|
from openbb_cli.dispatchers.spec import _resolve_base_url
|
|
|
|
assert _resolve_base_url({"servers": [{"url": " "}]}, "http://x") == "http://x"
|
|
|
|
|
|
# --- _peek_provider edges ---
|
|
|
|
|
|
def test_peek_provider_returns_none_when_no_providers_declared():
|
|
from openbb_cli.dispatchers.spec import _peek_provider
|
|
|
|
assert _peek_provider({"providers": []}, ["--whatever"]) is None
|
|
|
|
|
|
def test_peek_provider_returns_chosen_provider():
|
|
from openbb_cli.dispatchers.spec import _peek_provider
|
|
|
|
assert (
|
|
_peek_provider(
|
|
{"providers": ["cboe", "intrinio"]},
|
|
["--symbol", "AAPL", "--provider", "intrinio", "--use_cache"],
|
|
)
|
|
== "intrinio"
|
|
)
|
|
|
|
|
|
def test_peek_provider_returns_none_when_provider_invalid():
|
|
"""A bogus --provider value triggers SystemExit in argparse → return None."""
|
|
from openbb_cli.dispatchers.spec import _peek_provider
|
|
|
|
assert (
|
|
_peek_provider(
|
|
{"providers": ["cboe", "intrinio"]},
|
|
["--provider", "bogus"],
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
def test_peek_provider_returns_none_when_provider_absent():
|
|
from openbb_cli.dispatchers.spec import _peek_provider
|
|
|
|
assert (
|
|
_peek_provider(
|
|
{"providers": ["cboe"]},
|
|
["--symbol", "AAPL"], # no --provider
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
# --- Datetime param coercion ---
|
|
|
|
|
|
def test_coerce_iso_datetime_expands_date_to_rfc3339():
|
|
from openbb_cli.dispatchers.spec import _coerce_iso_datetime
|
|
|
|
assert _coerce_iso_datetime("2025-01-01") == "2025-01-01T00:00:00Z"
|
|
|
|
|
|
def test_coerce_iso_datetime_appends_z_to_full_timestamp():
|
|
from openbb_cli.dispatchers.spec import _coerce_iso_datetime
|
|
|
|
assert _coerce_iso_datetime("2025-01-01T12:34:56") == "2025-01-01T12:34:56Z"
|
|
|
|
|
|
def test_coerce_iso_datetime_passthrough_for_other_forms():
|
|
from openbb_cli.dispatchers.spec import _coerce_iso_datetime
|
|
|
|
assert _coerce_iso_datetime("not-a-date") == "not-a-date"
|
|
|
|
|
|
def test_looks_like_datetime_param_detects_camelcase_suffix():
|
|
from openbb_cli.dispatchers.spec import _looks_like_datetime_param
|
|
|
|
assert _looks_like_datetime_param("fromDateTime", None) is True
|
|
assert _looks_like_datetime_param("from_datetime", None) is True
|
|
|
|
|
|
def test_looks_like_datetime_param_detects_timestamp_help():
|
|
from openbb_cli.dispatchers.spec import _looks_like_datetime_param
|
|
|
|
assert _looks_like_datetime_param("when", "ISO timestamp") is True
|
|
assert _looks_like_datetime_param("when", "format YYYY-MM-DD") is True
|
|
|
|
|
|
def test_looks_like_datetime_param_returns_false_for_unrelated():
|
|
from openbb_cli.dispatchers.spec import _looks_like_datetime_param
|
|
|
|
assert _looks_like_datetime_param("symbol", "stock ticker") is False
|
|
|
|
|
|
def test_normalize_parameter_xml_default_flips_to_json():
|
|
"""When ``xml`` and ``json`` are both offered, default flips to ``json``."""
|
|
out = _normalize_parameter(
|
|
{
|
|
"name": "format",
|
|
"schema": {"type": "string", "enum": ["xml", "json"]},
|
|
}
|
|
)
|
|
assert out["default"] == "json"
|
|
|
|
|
|
def test_normalize_parameter_csv_default_flips_to_json():
|
|
"""When ``csv`` and ``json`` are both offered, default flips to ``json``."""
|
|
out = _normalize_parameter(
|
|
{
|
|
"name": "fmt",
|
|
"schema": {"type": "string", "enum": ["json", "csv"]},
|
|
}
|
|
)
|
|
assert out["default"] == "json"
|
|
assert out["required"] is False
|
|
|
|
|
|
def test_normalize_parameter_json_only_enum_keeps_no_default():
|
|
"""A lone ``json`` choice is not a format toggle — no default injected."""
|
|
out = _normalize_parameter(
|
|
{
|
|
"name": "fmt",
|
|
"schema": {"type": "string", "enum": ["json"]},
|
|
}
|
|
)
|
|
assert out["default"] is None
|
|
|
|
|
|
# --- securitySchemes → synthetic credential parameters ---
|
|
|
|
|
|
def _apikey_spec(**op_extra):
|
|
return {
|
|
"security": [{"KeyAuth": []}],
|
|
"components": {
|
|
"securitySchemes": {
|
|
"KeyAuth": {
|
|
"type": "apiKey",
|
|
"in": "query",
|
|
"name": "api_token",
|
|
"description": "API key.",
|
|
}
|
|
}
|
|
},
|
|
"paths": {"/data": {"get": {"summary": "Data", **op_extra}}},
|
|
}
|
|
|
|
|
|
def test_build_command_spec_materializes_global_apikey_as_optional_param():
|
|
commands = build_command_spec(_apikey_spec(), api_prefix="")
|
|
params = {p["name"]: p for p in commands["data"]["parameters"]}
|
|
assert "api_token" in params
|
|
assert params["api_token"]["in"] == "query"
|
|
assert params["api_token"]["required"] is False
|
|
assert params["api_token"]["help"] == "API key."
|
|
|
|
|
|
def test_build_command_spec_operation_security_empty_suppresses_global():
|
|
commands = build_command_spec(_apikey_spec(security=[]), api_prefix="")
|
|
names = [p["name"] for p in commands["data"]["parameters"]]
|
|
assert "api_token" not in names
|
|
|
|
|
|
def test_build_command_spec_declared_param_not_duplicated_by_security():
|
|
spec = _apikey_spec(
|
|
parameters=[{"name": "api_token", "in": "query", "schema": {"type": "string"}}]
|
|
)
|
|
commands = build_command_spec(spec, api_prefix="")
|
|
names = [p["name"] for p in commands["data"]["parameters"]]
|
|
assert names.count("api_token") == 1
|
|
|
|
|
|
def test_security_parameters_header_scheme_and_non_apikey():
|
|
from openbb_cli.dispatchers.spec import _security_parameters
|
|
|
|
spec = {
|
|
"security": [{"HeaderAuth": []}, {"Bearer": []}, {"CookieAuth": []}],
|
|
"components": {
|
|
"securitySchemes": {
|
|
"HeaderAuth": {"type": "apiKey", "in": "header", "name": "X-Api-Key"},
|
|
"Bearer": {"type": "http", "scheme": "bearer"},
|
|
"CookieAuth": {"type": "apiKey", "in": "cookie", "name": "sid"},
|
|
}
|
|
},
|
|
}
|
|
out = _security_parameters(spec, {})
|
|
assert out == [
|
|
{
|
|
"name": "X-Api-Key",
|
|
"in": "header",
|
|
"schema": {"type": "string"},
|
|
"description": None,
|
|
}
|
|
]
|
|
|
|
|
|
def test_security_parameters_dedupes_repeated_scheme_name():
|
|
from openbb_cli.dispatchers.spec import _security_parameters
|
|
|
|
spec = {
|
|
"security": [{"KeyAuth": []}, {"KeyAuth": []}],
|
|
"components": {
|
|
"securitySchemes": {
|
|
"KeyAuth": {"type": "apiKey", "in": "query", "name": "api_token"}
|
|
}
|
|
},
|
|
}
|
|
assert len(_security_parameters(spec, {})) == 1
|
|
|
|
|
|
def test_security_parameters_skips_malformed_requirements():
|
|
from openbb_cli.dispatchers.spec import _security_parameters
|
|
|
|
spec = {
|
|
"security": ["not-a-dict", {"Missing": []}, {"NoName": []}],
|
|
"components": {
|
|
"securitySchemes": {"NoName": {"type": "apiKey", "in": "query"}}
|
|
},
|
|
}
|
|
assert _security_parameters(spec, {}) == []
|
|
|
|
|
|
def test_operation_providers_skips_non_dict_param_entries():
|
|
"""Defensive: a malformed parameter entry doesn't crash provider extraction."""
|
|
from openbb_cli.dispatchers.spec import _operation_providers
|
|
|
|
op = {
|
|
"parameters": [
|
|
"not-a-dict", # skipped
|
|
{"name": "other"}, # not provider
|
|
{"name": "provider", "schema": {"enum": ["cboe"]}},
|
|
]
|
|
}
|
|
assert _operation_providers(op) == ["cboe"]
|
|
|
|
|
|
def test_build_command_spec_skips_unresolvable_ref_param():
|
|
"""Param with a $ref that doesn't resolve gets dropped, not crashed."""
|
|
openapi = {
|
|
"paths": {
|
|
"/api/v1/x": {
|
|
"get": {
|
|
"operationId": "x",
|
|
"parameters": [
|
|
{"$ref": "#/components/parameters/missing"},
|
|
{"name": "kept", "schema": {"type": "string"}},
|
|
],
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out = build_command_spec(openapi)
|
|
names = [p["name"] for p in out["x"]["parameters"]]
|
|
assert names == ["kept"]
|
|
|
|
|
|
def test_add_normalized_parameter_with_datetime_param_uses_coercer():
|
|
"""Datetime-named params get ``_coerce_iso_datetime`` as the type converter."""
|
|
import argparse
|
|
|
|
from openbb_cli.dispatchers.spec import (
|
|
_add_normalized_parameter,
|
|
_coerce_iso_datetime,
|
|
)
|
|
|
|
p = argparse.ArgumentParser()
|
|
_add_normalized_parameter(
|
|
p,
|
|
{"name": "fromDateTime", "type": "string", "providers": []},
|
|
)
|
|
action = next(a for a in p._actions if a.dest == "fromDateTime")
|
|
assert action.type is _coerce_iso_datetime
|
|
|
|
|
|
def test_build_command_spec_inherits_path_item_parameters():
|
|
"""Path and query parameters declared on the path item apply to the operation."""
|
|
openapi = {
|
|
"paths": {
|
|
"/api/v1/x/{record_id}": {
|
|
"parameters": [
|
|
{"name": "record_id", "in": "path", "required": True},
|
|
{"name": "page", "in": "query", "schema": {"type": "integer"}},
|
|
],
|
|
"get": {
|
|
"operationId": "x",
|
|
"parameters": [{"name": "symbol", "schema": {"type": "string"}}],
|
|
},
|
|
}
|
|
}
|
|
}
|
|
out = build_command_spec(openapi)
|
|
names = [p["name"] for p in out["x"]["parameters"]]
|
|
assert names == ["record_id", "page", "symbol"]
|
|
record = next(p for p in out["x"]["parameters"] if p["name"] == "record_id")
|
|
assert record["required"] is True
|
|
|
|
|
|
def test_build_command_spec_routes_required_path_item_headers_to_credentials():
|
|
"""A required path-item header/cookie is kept as a credential, not a CLI arg.
|
|
|
|
An optional one (``session`` here) still has nowhere to go and is dropped,
|
|
same as before — only a required value is the "every operation needs
|
|
this static secret" profile that credential handling exists for.
|
|
"""
|
|
openapi = {
|
|
"paths": {
|
|
"/api/v1/x": {
|
|
"parameters": [
|
|
{"name": "x-tenant-id", "in": "header", "required": True},
|
|
{"name": "x-session", "in": "cookie", "required": True},
|
|
{"name": "session", "in": "cookie"},
|
|
{"name": "page", "in": "query", "schema": {"type": "integer"}},
|
|
],
|
|
"get": {"operationId": "x"},
|
|
}
|
|
}
|
|
}
|
|
out = build_command_spec(openapi)
|
|
params = {p["name"]: p for p in out["x"]["parameters"]}
|
|
assert set(params) == {"page", "x-tenant-id", "x-session"}
|
|
assert params["x-tenant-id"]["in"] == "header"
|
|
assert params["x-tenant-id"]["required"] is True
|
|
assert params["x-session"]["in"] == "cookie"
|
|
assert params["x-session"]["required"] is True
|
|
|
|
|
|
def test_build_command_spec_operation_header_overrides_path_item_credential():
|
|
"""An operation's own header wins over a same-named required path-item header."""
|
|
openapi = {
|
|
"paths": {
|
|
"/api/v1/x": {
|
|
"parameters": [
|
|
{"name": "x-tenant-id", "in": "header", "required": True}
|
|
],
|
|
"get": {
|
|
"operationId": "x",
|
|
"parameters": [
|
|
{
|
|
"name": "x-tenant-id",
|
|
"in": "header",
|
|
"description": "Tenant.",
|
|
}
|
|
],
|
|
},
|
|
}
|
|
}
|
|
}
|
|
out = build_command_spec(openapi)
|
|
params = [p for p in out["x"]["parameters"] if p["name"] == "x-tenant-id"]
|
|
assert len(params) == 1
|
|
assert params[0]["required"] is False
|
|
assert params[0]["help"] == "Tenant."
|
|
|
|
|
|
def test_build_command_spec_resolves_path_item_ref_parameters():
|
|
"""A path-item parameter given as a $ref is resolved before inheriting."""
|
|
openapi = {
|
|
"components": {
|
|
"parameters": {
|
|
"companyId": {
|
|
"name": "companyId",
|
|
"in": "path",
|
|
"required": True,
|
|
"schema": {"type": "string"},
|
|
}
|
|
}
|
|
},
|
|
"paths": {
|
|
"/api/v1/accounts/{companyId}": {
|
|
"parameters": [{"$ref": "#/components/parameters/companyId"}],
|
|
"get": {"operationId": "accounts"},
|
|
}
|
|
},
|
|
}
|
|
out = build_command_spec(openapi)
|
|
assert [p["name"] for p in out["accounts"]["parameters"]] == ["companyId"]
|
|
|
|
|
|
def test_build_command_spec_operation_parameter_overrides_path_item():
|
|
"""An operation parameter wins over an inherited one with the same name and location."""
|
|
openapi = {
|
|
"paths": {
|
|
"/api/v1/x": {
|
|
"parameters": [
|
|
{"name": "limit", "in": "query", "schema": {"type": "string"}}
|
|
],
|
|
"get": {
|
|
"operationId": "x",
|
|
"parameters": [
|
|
{
|
|
"name": "limit",
|
|
"in": "query",
|
|
"required": True,
|
|
"schema": {"type": "integer"},
|
|
}
|
|
],
|
|
},
|
|
}
|
|
}
|
|
}
|
|
out = build_command_spec(openapi)
|
|
params = out["x"]["parameters"]
|
|
assert len(params) == 1
|
|
assert params[0]["type"] == "integer"
|
|
assert params[0]["required"] is True
|
|
|
|
|
|
def test_build_command_spec_path_item_param_kept_when_location_differs():
|
|
"""Overriding is keyed on (name, in) — a same-named param elsewhere is kept."""
|
|
openapi = {
|
|
"paths": {
|
|
"/api/v1/x": {
|
|
"parameters": [
|
|
{"name": "id", "in": "path", "schema": {"type": "string"}}
|
|
],
|
|
"get": {
|
|
"operationId": "x",
|
|
"parameters": [
|
|
{"name": "id", "in": "query", "schema": {"type": "string"}}
|
|
],
|
|
},
|
|
}
|
|
}
|
|
}
|
|
out = build_command_spec(openapi)
|
|
locations = sorted(p["in"] for p in out["x"]["parameters"] if p["name"] == "id")
|
|
assert locations == ["path", "query"]
|
|
|
|
|
|
def test_build_command_spec_path_item_parameters_apply_to_every_operation():
|
|
"""Both operations under one path item inherit its shared parameters."""
|
|
openapi = {
|
|
"paths": {
|
|
"/api/v1/x": {
|
|
"parameters": [
|
|
{"name": "page", "in": "query", "schema": {"type": "integer"}}
|
|
],
|
|
"get": {"operationId": "x"},
|
|
},
|
|
"/api/v1/y": {
|
|
"parameters": [
|
|
{"name": "page", "in": "query", "schema": {"type": "integer"}}
|
|
],
|
|
"post": {"operationId": "y"},
|
|
},
|
|
}
|
|
}
|
|
out = build_command_spec(openapi)
|
|
assert [p["name"] for p in out["x"]["parameters"]] == ["page"]
|
|
assert [p["name"] for p in out["y"]["parameters"]] == ["page"]
|
|
|
|
|
|
def test_build_command_spec_skips_unresolvable_path_item_ref_param():
|
|
"""An unresolvable path-item $ref is dropped rather than crashing."""
|
|
openapi = {
|
|
"paths": {
|
|
"/api/v1/x": {
|
|
"parameters": [{"$ref": "#/components/parameters/missing"}],
|
|
"get": {
|
|
"operationId": "x",
|
|
"parameters": [{"name": "kept", "schema": {"type": "string"}}],
|
|
},
|
|
}
|
|
}
|
|
}
|
|
out = build_command_spec(openapi)
|
|
assert [p["name"] for p in out["x"]["parameters"]] == ["kept"]
|
|
|
|
|
|
def test_build_command_spec_ignores_malformed_path_item_parameters():
|
|
"""Non-dict path-item parameter entries are skipped defensively."""
|
|
openapi = {
|
|
"paths": {
|
|
"/api/v1/x": {
|
|
"parameters": ["not-a-dict", {"in": "query"}],
|
|
"get": {
|
|
"operationId": "x",
|
|
"parameters": [{"name": "kept", "schema": {"type": "string"}}],
|
|
},
|
|
}
|
|
}
|
|
}
|
|
out = build_command_spec(openapi)
|
|
assert [p["name"] for p in out["x"]["parameters"]] == ["kept"]
|
|
|
|
|
|
def test_description_stating_a_delimited_list_makes_the_param_an_array():
|
|
from openbb_cli.dispatchers.spec import _normalize_parameter
|
|
|
|
def norm(desc, **extra):
|
|
return _normalize_parameter(
|
|
{
|
|
"name": "op",
|
|
"in": "query",
|
|
"description": desc,
|
|
"schema": {"type": "string", "enum": ["Repo", "Reverse Repo"]},
|
|
**extra,
|
|
}
|
|
)
|
|
|
|
got = norm("The operation types (comma-delimited) by which to filter.")
|
|
assert got["is_list"] is True and got["list_style"] == "csv"
|
|
assert got["choices"] == ["Repo", "Reverse Repo"]
|
|
assert norm("Pipe separated list of ids.")["list_style"] == "pipe"
|
|
assert not norm("Values are not comma-delimited.")["is_list"]
|
|
assert not norm("A single value.")["is_list"]
|
|
# an explicit array schema keeps its own style
|
|
arr = _normalize_parameter(
|
|
{
|
|
"name": "x",
|
|
"in": "query",
|
|
"description": "comma-delimited",
|
|
"style": "form",
|
|
"explode": True,
|
|
"schema": {"type": "array", "items": {"type": "string"}},
|
|
}
|
|
)
|
|
assert arr["list_style"] == "multi"
|