mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-11-08 18:32:21 +00:00
[V5] openbb-cli: Modular OpenAPI Refs (#7589)
* follow up to #7585 * add another codegen test
This commit is contained in:
@@ -455,6 +455,14 @@ def _generate_spec(
|
||||
else:
|
||||
source_url = server_url.rstrip("/") + "/openapi.json"
|
||||
spec_doc = build_spec_document(openapi, base_url=server_url, source_url=source_url)
|
||||
if not spec_doc["commands"]:
|
||||
sys.stderr.write(
|
||||
f"generated 0 commands from {source_url} — the document exposes no "
|
||||
"GET/POST operations OpenBB can map. Multi-file specs that split "
|
||||
"paths across documents with external $refs must be bundled into "
|
||||
"a single file first.\n"
|
||||
)
|
||||
return 2
|
||||
write_spec(output_path, spec_doc)
|
||||
sys.stdout.write(f"wrote {len(spec_doc['commands'])} commands to {output_path}\n")
|
||||
return 0
|
||||
@@ -539,6 +547,13 @@ def _generate_extension(
|
||||
|
||||
spec_doc = load_spec(spec_path)
|
||||
original_count = len(spec_doc.get("commands") or {})
|
||||
if original_count == 0:
|
||||
sys.stderr.write(
|
||||
f"--generate-extension: spec at {spec_path} contains 0 commands — "
|
||||
"nothing to generate. Regenerate the spec against a source that "
|
||||
"exposes GET/POST operations.\n"
|
||||
)
|
||||
return 2
|
||||
spec_doc = _filter_spec_commands(spec_doc, include=include, exclude=exclude)
|
||||
filtered_count = len(spec_doc.get("commands") or {})
|
||||
if (include or exclude) and filtered_count == 0:
|
||||
|
||||
@@ -9,6 +9,7 @@ CredentialLocation = Literal["query", "header"]
|
||||
_CREDENTIAL_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"api_key",
|
||||
"api_token",
|
||||
"apikey",
|
||||
"x_api_key",
|
||||
"x_apikey",
|
||||
|
||||
@@ -297,13 +297,38 @@ def _build_root_router(
|
||||
package_name: str,
|
||||
root_namespace: str,
|
||||
sub_routers: list[GeneratedRouter],
|
||||
root_commands: list[str] | None = None,
|
||||
root_post_imports: list[tuple[str, str]] | None = None,
|
||||
root_has_stream: bool = False,
|
||||
) -> GeneratedRouter:
|
||||
"""Emit a top-level router that mounts every namespace under ``root_namespace``."""
|
||||
"""Emit a top-level router that mounts every namespace under ``root_namespace``.
|
||||
|
||||
Top-level leaf commands (no namespace of their own) are rendered directly
|
||||
on this router; unused imports are cleaned up by the ruff pass.
|
||||
"""
|
||||
commands = root_commands or []
|
||||
post_imports = root_post_imports or []
|
||||
parts: list[str] = [
|
||||
f'"""Root router for {root_namespace} — generated from spec."""',
|
||||
"",
|
||||
"from openbb_core.app.router import Router",
|
||||
]
|
||||
if commands:
|
||||
parts.append("from openbb_core.app.model.command_context import CommandContext")
|
||||
parts.append("from openbb_core.app.model.obbject import OBBject")
|
||||
if root_has_stream:
|
||||
parts.append("from openbb_core.app.model.stream import OBBStream")
|
||||
parts.append(
|
||||
"from openbb_core.app.provider_interface import "
|
||||
"ExtraParams, ProviderChoices, StandardParams"
|
||||
)
|
||||
parts.append("from openbb_core.app.query import Query")
|
||||
parts.append("from openbb_core.app.router import Router")
|
||||
if post_imports:
|
||||
parts.append("")
|
||||
for module_path, function_name in post_imports:
|
||||
parts.append(
|
||||
f"from {module_path} import {function_name} as _{function_name}"
|
||||
)
|
||||
if sub_routers:
|
||||
parts.append("")
|
||||
for r in sub_routers:
|
||||
@@ -321,6 +346,9 @@ def _build_root_router(
|
||||
f"router.include_router(_{r.module_name}_router, "
|
||||
f'prefix="/{r.module_name}")'
|
||||
)
|
||||
if commands:
|
||||
parts.append("")
|
||||
parts.extend(commands)
|
||||
return GeneratedRouter(
|
||||
module_name=root_namespace,
|
||||
entry_point_name=root_namespace,
|
||||
@@ -466,6 +494,16 @@ def generate_packages(
|
||||
f"{package_name}.providers..models.",
|
||||
f"{package_name}.providers.tools.models.",
|
||||
)
|
||||
routers.root_post_imports = [
|
||||
(
|
||||
module_path.replace(
|
||||
f"{package_name}.providers..models.",
|
||||
f"{package_name}.providers.tools.models.",
|
||||
),
|
||||
function_name,
|
||||
)
|
||||
for module_path, function_name in routers.root_post_imports
|
||||
]
|
||||
|
||||
root_namespace = _slugify(provider_name or project_slug)
|
||||
sub_routers = [r for r in routers.routers if r.entry_point_name]
|
||||
@@ -475,6 +513,9 @@ def generate_packages(
|
||||
package_name=package_name,
|
||||
root_namespace=root_namespace,
|
||||
sub_routers=sub_routers,
|
||||
root_commands=routers.root_commands,
|
||||
root_post_imports=routers.root_post_imports,
|
||||
root_has_stream=routers.root_has_stream,
|
||||
)
|
||||
routers.routers.append(root_router)
|
||||
|
||||
|
||||
@@ -40,9 +40,19 @@ class GeneratedRouters:
|
||||
----------
|
||||
routers : list of GeneratedRouter
|
||||
All emitted router modules.
|
||||
root_commands : list of str
|
||||
Rendered command blocks for top-level leaf commands; they have no
|
||||
namespace router of their own and mount directly on the root router.
|
||||
root_post_imports : list of (str, str)
|
||||
``(module_path, function_name)`` imports the root command blocks need.
|
||||
root_has_stream : bool
|
||||
Whether any root command streams (root router imports ``OBBStream``).
|
||||
"""
|
||||
|
||||
routers: list[GeneratedRouter] = field(default_factory=list)
|
||||
root_commands: list[str] = field(default_factory=list)
|
||||
root_post_imports: list[tuple[str, str]] = field(default_factory=list)
|
||||
root_has_stream: bool = False
|
||||
|
||||
|
||||
def _safe_segment(name: str) -> str:
|
||||
@@ -107,15 +117,42 @@ def generate_routers(
|
||||
"""
|
||||
out = GeneratedRouters()
|
||||
for top_name, top_node in sorted(root.children.items()):
|
||||
_emit_router(
|
||||
top_node,
|
||||
package_name=package_name,
|
||||
provider_name=provider_name,
|
||||
fetchers_by_command=fetchers_by_command,
|
||||
post_commands_by_command=post_commands_by_command,
|
||||
collected=out,
|
||||
is_top_level=True,
|
||||
)
|
||||
if top_node.is_namespace:
|
||||
_emit_router(
|
||||
top_node,
|
||||
package_name=package_name,
|
||||
provider_name=provider_name,
|
||||
fetchers_by_command=fetchers_by_command,
|
||||
post_commands_by_command=post_commands_by_command,
|
||||
collected=out,
|
||||
is_top_level=True,
|
||||
)
|
||||
if top_node.cmd_spec is None:
|
||||
continue
|
||||
function_name = _safe_segment(top_name)
|
||||
description = (top_node.cmd_spec.get("description") or "").strip()
|
||||
fetcher = fetchers_by_command.get(top_node.full_path)
|
||||
if fetcher is not None:
|
||||
renderer = (
|
||||
_render_stream_command if fetcher.is_streaming else _render_get_command
|
||||
)
|
||||
out.root_has_stream = out.root_has_stream or fetcher.is_streaming
|
||||
out.root_commands.append(
|
||||
renderer(fetcher, function_name=function_name, description=description)
|
||||
)
|
||||
continue
|
||||
post = post_commands_by_command.get(top_node.full_path)
|
||||
if post is not None:
|
||||
out.root_post_imports.append(
|
||||
(
|
||||
f"{package_name}.providers.{provider_name}.models."
|
||||
f"{post.module_name}",
|
||||
post.function_name,
|
||||
)
|
||||
)
|
||||
out.root_commands.append(
|
||||
f'router.command(methods=["POST"])(_{post.function_name})\n'
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -314,12 +314,20 @@ def parse_json_arg(raw: str) -> Any:
|
||||
def request_body_parameters(
|
||||
body_schema: dict[str, Any] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Flatten a dereferenced request-body schema into OpenAPI parameter objects."""
|
||||
"""Flatten a dereferenced request-body schema into OpenAPI parameter objects.
|
||||
|
||||
A body counts as an object when it declares ``type: object`` or carries
|
||||
``properties`` — the latter is how a normalized 3.1 nullable object
|
||||
(``type: ["object", "null"]``) arrives: ``expand_type_arrays`` moves the
|
||||
type into ``anyOf`` variants while ``properties`` stays on the parent.
|
||||
Failing both, an ``anyOf`` with exactly one object variant is unwrapped
|
||||
to that variant.
|
||||
"""
|
||||
if not isinstance(body_schema, dict):
|
||||
return []
|
||||
body_type = body_schema.get("type")
|
||||
body_types = body_type if isinstance(body_type, list) else [body_type]
|
||||
if "object" not in body_types:
|
||||
if "object" not in body_types and "properties" not in body_schema:
|
||||
object_members = [
|
||||
member
|
||||
for member in body_schema.get("anyOf", [])
|
||||
@@ -478,12 +486,16 @@ def detect_api_prefix(spec: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def url_to_command(url: str, api_prefix: str = "/api/v1") -> str:
|
||||
"""Convert a URL path to a dotted command, dropping ``{path_params}``."""
|
||||
"""Convert a URL path to a dotted command, dropping ``{path_params}``.
|
||||
|
||||
A literal ``.`` inside a URL segment (``/v1.1/fundamentals``) becomes
|
||||
``_`` — dots in command names are exclusively namespace separators.
|
||||
"""
|
||||
prefix_parts = [p for p in api_prefix.strip("/").split("/") if p]
|
||||
parts = [p for p in url.strip("/").split("/") if p]
|
||||
if parts[: len(prefix_parts)] == prefix_parts:
|
||||
parts = parts[len(prefix_parts) :]
|
||||
cleaned = [_strip_placeholders(p) for p in parts]
|
||||
cleaned = [_strip_placeholders(p).replace(".", "_") for p in parts]
|
||||
return ".".join(p for p in cleaned if p)
|
||||
|
||||
|
||||
@@ -542,7 +554,11 @@ def build_reference(
|
||||
parts = [p for p in url.strip("/").split("/") if p]
|
||||
if parts[: len(prefix_parts)] == prefix_parts:
|
||||
parts = parts[len(prefix_parts) :]
|
||||
non_template = [p for p in parts if not (p.startswith("{") and p.endswith("}"))]
|
||||
non_template = [
|
||||
p.replace(".", "_")
|
||||
for p in parts
|
||||
if not (p.startswith("{") and p.endswith("}"))
|
||||
]
|
||||
cli_path = "/" + "/".join(non_template)
|
||||
op_desc = (op.get("description") or op.get("summary") or "").strip()
|
||||
if cli_path not in paths_out or not paths_out[cli_path].get("description"):
|
||||
@@ -732,15 +748,6 @@ def _bundle_external_refs(
|
||||
documents[url] = parsed
|
||||
return parsed
|
||||
|
||||
def normalize_schema_type(value: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert OpenAPI 3.1 type arrays for the existing code generator."""
|
||||
schema_types = value.get("type")
|
||||
if not isinstance(schema_types, list):
|
||||
return value
|
||||
normalized = {key: item for key, item in value.items() if key != "type"}
|
||||
normalized["anyOf"] = [{"type": item} for item in schema_types]
|
||||
return normalized
|
||||
|
||||
def visit(
|
||||
node: Any,
|
||||
current_url: str,
|
||||
@@ -796,21 +803,19 @@ def _bundle_external_refs(
|
||||
if key != "$ref"
|
||||
}
|
||||
if isinstance(resolved, dict):
|
||||
return normalize_schema_type({**resolved, **siblings})
|
||||
return {**resolved, **siblings}
|
||||
return resolved
|
||||
return normalize_schema_type(
|
||||
{
|
||||
key: visit(
|
||||
value,
|
||||
current_url,
|
||||
current_document,
|
||||
imported=imported,
|
||||
seen=seen,
|
||||
depth=depth + 1,
|
||||
)
|
||||
for key, value in node.items()
|
||||
}
|
||||
)
|
||||
return {
|
||||
key: visit(
|
||||
value,
|
||||
current_url,
|
||||
current_document,
|
||||
imported=imported,
|
||||
seen=seen,
|
||||
depth=depth + 1,
|
||||
)
|
||||
for key, value in node.items()
|
||||
}
|
||||
if isinstance(node, list):
|
||||
return [
|
||||
visit(
|
||||
@@ -907,7 +912,7 @@ def fetch_openapi(
|
||||
embedded = _extract_embedded_spec(landing.text)
|
||||
if embedded is not None:
|
||||
return _bundle_external_refs(
|
||||
embedded,
|
||||
_ensure_openapi_dict(embedded, landing_url),
|
||||
str(getattr(landing, "url", landing_url)),
|
||||
timeout=timeout,
|
||||
headers=merged_headers,
|
||||
@@ -938,4 +943,28 @@ def _ensure_openapi_dict(parsed: Any, source_url: str) -> dict[str, Any]:
|
||||
"Pass --openapi-path to point at the real spec endpoint "
|
||||
"(e.g. /swagger/v1/swagger.json)."
|
||||
)
|
||||
return parsed
|
||||
return expand_type_arrays(parsed)
|
||||
|
||||
|
||||
def expand_type_arrays(node: Any) -> Any:
|
||||
"""Rewrite JSON-Schema ``type`` arrays (OpenAPI 3.1) into ``anyOf`` unions.
|
||||
|
||||
``{"type": ["number", "null"]}`` becomes
|
||||
``{"anyOf": [{"type": "number"}, {"type": "null"}]}`` — the OpenAPI 3.0
|
||||
shape every schema consumer already understands. Sibling keywords are
|
||||
copied into each non-null variant so type-scoped keys (``format``,
|
||||
``items``, ``enum``) survive the split. Schemas that already declare
|
||||
``anyOf`` / ``oneOf`` are left alone: consumers resolve the combinator
|
||||
before ever reading ``type``.
|
||||
"""
|
||||
if isinstance(node, list):
|
||||
return [expand_type_arrays(v) for v in node]
|
||||
if not isinstance(node, dict):
|
||||
return node
|
||||
out = {k: expand_type_arrays(v) for k, v in node.items()}
|
||||
types = out.get("type")
|
||||
if not isinstance(types, list) or "anyOf" in out or "oneOf" in out:
|
||||
return out
|
||||
rest = {k: v for k, v in out.items() if k != "type"}
|
||||
variants = [{"type": "null"} if t == "null" else {**rest, "type": t} for t in types]
|
||||
return {**rest, "anyOf": variants}
|
||||
|
||||
@@ -108,7 +108,7 @@ def _normalize_parameter(
|
||||
required = bool(param.get("required")) or location == "path"
|
||||
default = schema.get("default")
|
||||
lower_choices = {str(c).lower() for c in choices}
|
||||
if "json" in lower_choices and "xml" in lower_choices:
|
||||
if "json" in lower_choices and lower_choices & {"xml", "csv"}:
|
||||
default = "json"
|
||||
if default is not None:
|
||||
required = False
|
||||
@@ -150,6 +150,45 @@ def _normalize_parameter(
|
||||
}
|
||||
|
||||
|
||||
def _security_parameters(
|
||||
spec: dict[str, Any], op: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Materialize ``apiKey`` security schemes as optional operation parameters.
|
||||
|
||||
Auth declared only through ``components.securitySchemes`` (no per-operation
|
||||
parameter) would otherwise vanish from the spec — codegen and dispatch both
|
||||
read the parameter list. Per-operation ``security`` overrides the
|
||||
document-level default, including the explicit-public ``security: []``.
|
||||
"""
|
||||
requirements = op.get("security")
|
||||
if requirements is None:
|
||||
requirements = spec.get("security") or []
|
||||
schemes = (spec.get("components") or {}).get("securitySchemes") or {}
|
||||
out: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for requirement in requirements:
|
||||
if not isinstance(requirement, dict):
|
||||
continue
|
||||
for scheme_name in requirement:
|
||||
scheme = schemes.get(scheme_name)
|
||||
if not isinstance(scheme, dict) or scheme.get("type") != "apiKey":
|
||||
continue
|
||||
name = scheme.get("name")
|
||||
location = scheme.get("in", "query")
|
||||
if not name or name in seen or location not in ("query", "header"):
|
||||
continue
|
||||
seen.add(name)
|
||||
out.append(
|
||||
{
|
||||
"name": name,
|
||||
"in": location,
|
||||
"schema": {"type": "string"},
|
||||
"description": scheme.get("description"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _build_operation_entry(
|
||||
spec: dict[str, Any], url: str, method: str, op: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
@@ -164,6 +203,13 @@ def _build_operation_entry(
|
||||
normalized = _normalize_parameter(resolved, providers_set)
|
||||
if normalized is not None:
|
||||
params.append(normalized)
|
||||
declared = {p["name"] for p in params}
|
||||
for raw in _security_parameters(spec, op):
|
||||
if raw["name"] in declared:
|
||||
continue
|
||||
normalized = _normalize_parameter(raw)
|
||||
if normalized is not None:
|
||||
params.append(normalized)
|
||||
return {
|
||||
"url_path": url,
|
||||
"method": method,
|
||||
|
||||
@@ -964,6 +964,54 @@ def test_generate_extension_aborts_when_filter_matches_nothing(tmp_path, capsys)
|
||||
assert "matched no commands" in err
|
||||
|
||||
|
||||
def test_generate_spec_errors_when_zero_commands(tmp_path, capsys):
|
||||
"""A source that maps to no commands is a loud error, not a skeleton spec."""
|
||||
with patch(
|
||||
"openbb_cli.dispatchers.openapi_schema.fetch_openapi",
|
||||
return_value={
|
||||
"openapi": "3.1.0",
|
||||
"paths": {"/x": {"$ref": "./paths/x.yaml#/~1x"}},
|
||||
},
|
||||
):
|
||||
rc = cli._generate_spec(
|
||||
"http://localhost:8000",
|
||||
str(tmp_path / "x.spec"),
|
||||
"/openapi.yaml",
|
||||
)
|
||||
assert rc == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "0 commands" in err
|
||||
assert "external $refs" in err
|
||||
assert not (tmp_path / "x.spec").exists()
|
||||
|
||||
|
||||
def test_generate_extension_aborts_when_spec_has_zero_commands(tmp_path, capsys):
|
||||
"""An empty spec never reaches codegen — no zero-command skeleton package."""
|
||||
from openbb_cli.dispatchers.spec import SPEC_VERSION, write_spec
|
||||
|
||||
spec_path = tmp_path / "empty.spec"
|
||||
write_spec(
|
||||
spec_path,
|
||||
{
|
||||
"version": SPEC_VERSION,
|
||||
"base_url": "http://x",
|
||||
"api_prefix": "/api/v1",
|
||||
"commands": {},
|
||||
},
|
||||
)
|
||||
rc = cli._generate_extension(
|
||||
[(None, str(spec_path))],
|
||||
str(tmp_path / "out"),
|
||||
provider_name=None,
|
||||
project_name=None,
|
||||
package_name=None,
|
||||
router_name=None,
|
||||
)
|
||||
assert rc == 2
|
||||
assert "0 commands" in capsys.readouterr().err
|
||||
assert not (tmp_path / "out").exists()
|
||||
|
||||
|
||||
def test_generate_extension_filters_commands_before_codegen(tmp_path, capsys):
|
||||
"""``--include`` reaches all the way through to ``generate_packages``:
|
||||
the filtered spec is what codegen actually sees, so the emitted
|
||||
|
||||
@@ -32,6 +32,8 @@ def test_normalize_credential_key_canonicalizes_separators_and_case(raw, expecte
|
||||
"name",
|
||||
[
|
||||
"api_key",
|
||||
"api_token",
|
||||
"api-token",
|
||||
"apikey",
|
||||
"API_KEY",
|
||||
"X-API-Key",
|
||||
|
||||
@@ -83,6 +83,39 @@ def test_generate_packages_returns_single_package_with_provider_and_router(tmp_p
|
||||
assert package.fetchers_by_provider["fmp"][0].model_name == "EquitySearch"
|
||||
|
||||
|
||||
def test_generate_packages_top_level_command_mounts_on_root_router(tmp_path):
|
||||
spec = {
|
||||
"base_url": "https://api.example.com",
|
||||
"api_prefix": "",
|
||||
"commands": {
|
||||
"eod": {
|
||||
"providers": ["eodhd"],
|
||||
"method": "get",
|
||||
"url_path": "/eod/{ticker}",
|
||||
"description": "End of day.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ticker",
|
||||
"in": "path",
|
||||
"type": "string",
|
||||
"required": True,
|
||||
}
|
||||
],
|
||||
"response_schema": {"type": "object"},
|
||||
}
|
||||
},
|
||||
}
|
||||
out = pkg.generate_packages(spec, output_root=tmp_path, provider_name="eodhd")
|
||||
package = out.packages[0]
|
||||
by_module = {r.module_name: r for r in package.routers.routers}
|
||||
# No dedicated (empty) router module for the top-level command
|
||||
assert "eod" not in by_module
|
||||
root = by_module["eodhd"]
|
||||
assert '@router.command(model="Eod")' in root.source
|
||||
assert "async def eod(" in root.source
|
||||
assert "from openbb_core.app.query import Query" in root.source
|
||||
|
||||
|
||||
def test_generate_packages_skips_coverage_namespace(tmp_path):
|
||||
spec = {
|
||||
"base_url": "https://api.example.com",
|
||||
|
||||
@@ -403,6 +403,31 @@ def test_generate_class_required_and_optional_fields():
|
||||
assert "label: str | None = Field(default=None, description='Pretty label.')" in src
|
||||
|
||||
|
||||
def test_generate_class_normalized_nullable_fields_keep_format_and_enum():
|
||||
"""The 3.1 chain end-to-end: ``expand_type_arrays`` copies siblings into
|
||||
``anyOf`` variants, so nullable date/enum fields type as ``datetime.date``
|
||||
/ ``Literal`` — not ``str``. Guards against a bare-variant normalizer
|
||||
(one that emits ``{"type": t}`` only) ever reappearing upstream."""
|
||||
from openbb_cli.dispatchers.openapi_schema import expand_type_arrays
|
||||
|
||||
schema = expand_type_arrays(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date": {"type": ["string", "null"], "format": "date"},
|
||||
"rating": {"type": ["string", "null"], "enum": ["AAA", "AA"]},
|
||||
"close": {"type": ["number", "null"]},
|
||||
},
|
||||
}
|
||||
)
|
||||
cls = pg.generate_class(schema, class_name="Row")
|
||||
src = cls.source
|
||||
assert "date: datetime.date | None" in src
|
||||
assert "rating: Literal['AAA', 'AA'] | None" in src
|
||||
assert "close: float | None" in src
|
||||
assert "import datetime" in cls.collect_imports()
|
||||
|
||||
|
||||
def test_generate_class_field_alias_for_dotted_property_name():
|
||||
schema = {
|
||||
"type": "object",
|
||||
|
||||
@@ -244,3 +244,124 @@ def test_generate_routers_skips_command_without_matching_fetcher_or_post():
|
||||
assert "async def" not in src
|
||||
assert "router.command" not in src
|
||||
ast.parse(src)
|
||||
|
||||
|
||||
# --- top-level leaf commands land on the root router ---
|
||||
|
||||
|
||||
def test_generate_routers_top_level_command_becomes_root_command():
|
||||
tree = nt.build_namespace_tree(
|
||||
{"eod": {"providers": ["eodhd"], "description": "End of day."}}
|
||||
)
|
||||
fetchers = {
|
||||
"eod": _Fetcher(
|
||||
module_name="eod",
|
||||
fetcher_class="EodFetcher",
|
||||
model_name="Eod",
|
||||
)
|
||||
}
|
||||
out = rg.generate_routers(
|
||||
tree,
|
||||
package_name="openbb_codegen",
|
||||
provider_name="eodhd",
|
||||
fetchers_by_command=fetchers,
|
||||
post_commands_by_command={},
|
||||
)
|
||||
# No dedicated router module for a pure top-level command
|
||||
assert out.routers == []
|
||||
assert len(out.root_commands) == 1
|
||||
block = out.root_commands[0]
|
||||
assert '@router.command(model="Eod")' in block
|
||||
assert "async def eod(" in block
|
||||
assert out.root_has_stream is False
|
||||
|
||||
|
||||
def test_generate_routers_top_level_stream_command_sets_flag():
|
||||
tree = nt.build_namespace_tree(
|
||||
{"feed": {"providers": ["wargame"], "description": "Live feed."}}
|
||||
)
|
||||
fetchers = {
|
||||
"feed": _Fetcher(
|
||||
module_name="feed",
|
||||
fetcher_class="FeedFetcher",
|
||||
model_name="Feed",
|
||||
is_streaming=True,
|
||||
)
|
||||
}
|
||||
out = rg.generate_routers(
|
||||
tree,
|
||||
package_name="openbb_codegen",
|
||||
provider_name="wargame",
|
||||
fetchers_by_command=fetchers,
|
||||
post_commands_by_command={},
|
||||
)
|
||||
assert out.root_has_stream is True
|
||||
assert "OBBStream" in out.root_commands[0]
|
||||
|
||||
|
||||
def test_generate_routers_top_level_post_command_records_import():
|
||||
tree = nt.build_namespace_tree(
|
||||
{"regression": {"providers": [], "description": "Linear fit."}}
|
||||
)
|
||||
posts = {
|
||||
"regression": _PostCommand(
|
||||
module_name="regression",
|
||||
function_name="regression",
|
||||
)
|
||||
}
|
||||
out = rg.generate_routers(
|
||||
tree,
|
||||
package_name="openbb_codegen",
|
||||
provider_name="tools",
|
||||
fetchers_by_command={},
|
||||
post_commands_by_command=posts,
|
||||
)
|
||||
assert out.root_post_imports == [
|
||||
("openbb_codegen.providers.tools.models.regression", "regression")
|
||||
]
|
||||
assert 'router.command(methods=["POST"])(_regression)' in out.root_commands[0]
|
||||
|
||||
|
||||
def test_generate_routers_top_level_command_without_fetcher_is_dropped():
|
||||
tree = nt.build_namespace_tree(
|
||||
{"orphan": {"providers": ["fmp"], "description": "No fetcher."}}
|
||||
)
|
||||
out = rg.generate_routers(
|
||||
tree,
|
||||
package_name="openbb_codegen",
|
||||
provider_name="fmp",
|
||||
fetchers_by_command={},
|
||||
post_commands_by_command={},
|
||||
)
|
||||
assert out.routers == []
|
||||
assert out.root_commands == []
|
||||
|
||||
|
||||
def test_generate_routers_mixes_namespaces_and_root_commands():
|
||||
tree = nt.build_namespace_tree(
|
||||
{
|
||||
"eod": {"providers": ["eodhd"], "description": "End of day."},
|
||||
"calendar.ipos": {"providers": ["eodhd"], "description": "IPOs."},
|
||||
}
|
||||
)
|
||||
fetchers = {
|
||||
"eod": _Fetcher(
|
||||
module_name="eod", fetcher_class="EodFetcher", model_name="Eod"
|
||||
),
|
||||
"calendar.ipos": _Fetcher(
|
||||
module_name="calendar_ipos",
|
||||
fetcher_class="CalendarIposFetcher",
|
||||
model_name="CalendarIpos",
|
||||
),
|
||||
}
|
||||
out = rg.generate_routers(
|
||||
tree,
|
||||
package_name="openbb_codegen",
|
||||
provider_name="eodhd",
|
||||
fetchers_by_command=fetchers,
|
||||
post_commands_by_command={},
|
||||
)
|
||||
assert [r.module_name for r in out.routers] == ["calendar"]
|
||||
assert "async def ipos(" in out.routers[0].source
|
||||
assert len(out.root_commands) == 1
|
||||
assert "async def eod(" in out.root_commands[0]
|
||||
|
||||
@@ -21,6 +21,7 @@ from openbb_cli.dispatchers.openapi_schema import (
|
||||
build_parser_from_operation,
|
||||
build_reference,
|
||||
build_router_map,
|
||||
expand_type_arrays,
|
||||
parameter_to_kwargs,
|
||||
parse_json_arg,
|
||||
request_body_parameters,
|
||||
@@ -1461,6 +1462,186 @@ def test_fetch_openapi_rejects_non_dict_body(monkeypatch):
|
||||
openapi_schema.fetch_openapi("http://h", path="/swagger/v1/swagger.json")
|
||||
|
||||
|
||||
# --- expand_type_arrays (OpenAPI 3.1 type arrays → anyOf unions) ---
|
||||
|
||||
|
||||
def test_expand_type_arrays_nullable_scalar_becomes_anyof():
|
||||
out = expand_type_arrays({"type": ["number", "null"], "description": "px"})
|
||||
assert "type" not in out
|
||||
assert out["description"] == "px"
|
||||
assert out["anyOf"] == [
|
||||
{"type": "number", "description": "px"},
|
||||
{"type": "null"},
|
||||
]
|
||||
|
||||
|
||||
def test_expand_type_arrays_copies_structural_siblings_into_variants():
|
||||
out = expand_type_arrays(
|
||||
{"type": ["string", "null"], "format": "date", "enum": ["a", None]}
|
||||
)
|
||||
non_null = [v for v in out["anyOf"] if v.get("type") != "null"]
|
||||
assert non_null == [{"type": "string", "format": "date", "enum": ["a", None]}]
|
||||
|
||||
|
||||
def test_expand_type_arrays_multi_type_union_keeps_every_variant():
|
||||
out = expand_type_arrays({"type": ["string", "number", "integer", "null"]})
|
||||
assert [v["type"] for v in out["anyOf"]] == [
|
||||
"string",
|
||||
"number",
|
||||
"integer",
|
||||
"null",
|
||||
]
|
||||
|
||||
|
||||
def test_expand_type_arrays_leaves_existing_combinators_alone():
|
||||
node = {"type": ["string", "null"], "anyOf": [{"type": "string"}]}
|
||||
assert expand_type_arrays(node) == node
|
||||
one_of = {"type": ["string", "null"], "oneOf": [{"type": "string"}]}
|
||||
assert expand_type_arrays(one_of) == one_of
|
||||
|
||||
|
||||
def test_expand_type_arrays_recurses_into_nested_schemas():
|
||||
doc = {
|
||||
"paths": {
|
||||
"/x": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"close": {"type": ["number", "null"]}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out = expand_type_arrays(doc)
|
||||
close = out["paths"]["/x"]["get"]["responses"]["200"]["content"][
|
||||
"application/json"
|
||||
]["schema"]["items"]["properties"]["close"]
|
||||
assert close == {"anyOf": [{"type": "number"}, {"type": "null"}]}
|
||||
|
||||
|
||||
def test_expand_type_arrays_passthrough_for_scalars_and_plain_types():
|
||||
assert expand_type_arrays("x") == "x"
|
||||
assert expand_type_arrays({"type": "string"}) == {"type": "string"}
|
||||
assert expand_type_arrays([{"type": ["integer", "null"]}]) == [
|
||||
{"anyOf": [{"type": "integer"}, {"type": "null"}]}
|
||||
]
|
||||
|
||||
|
||||
def test_request_body_parameters_accepts_normalized_nullable_object():
|
||||
"""A 3.1 nullable-object body still yields body params after normalization."""
|
||||
body = expand_type_arrays(
|
||||
{
|
||||
"type": ["object", "null"],
|
||||
"required": ["symbol"],
|
||||
"properties": {"symbol": {"type": "string"}},
|
||||
}
|
||||
)
|
||||
assert "type" not in body # moved into anyOf variants
|
||||
params = request_body_parameters(body)
|
||||
assert [p["name"] for p in params] == ["symbol"]
|
||||
assert params[0]["required"] is True
|
||||
|
||||
|
||||
def test_fetch_openapi_normalizes_type_arrays(monkeypatch):
|
||||
from openbb_cli.dispatchers import openapi_schema
|
||||
|
||||
class _R:
|
||||
status_code = 200
|
||||
text = (
|
||||
'{"openapi": "3.1.0", "paths": {"/x": {"get": {"responses": {"200": '
|
||||
'{"content": {"application/json": {"schema": '
|
||||
'{"type": ["number", "null"]}}}}}}}}}'
|
||||
)
|
||||
headers = {"content-type": "application/json"}
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(openapi_schema.httpx, "get", lambda *a, **k: _R())
|
||||
spec = openapi_schema.fetch_openapi("https://api.example.com")
|
||||
schema = spec["paths"]["/x"]["get"]["responses"]["200"]["content"][
|
||||
"application/json"
|
||||
]["schema"]
|
||||
assert schema == {"anyOf": [{"type": "number"}, {"type": "null"}]}
|
||||
|
||||
|
||||
def test_fetch_openapi_normalizes_type_arrays_in_embedded_spec(monkeypatch):
|
||||
from openbb_cli.dispatchers import openapi_schema
|
||||
|
||||
class _R:
|
||||
status_code = 404
|
||||
text = "<html>not here</html>"
|
||||
headers = {"content-type": "text/html"}
|
||||
|
||||
def raise_for_status(self):
|
||||
raise openapi_schema.httpx.HTTPStatusError(
|
||||
"404", request=None, response=None
|
||||
)
|
||||
|
||||
class _Landing:
|
||||
status_code = 200
|
||||
text = (
|
||||
'window.spec = {"openapi": "3.1.0", "paths": {"/x": {"get": '
|
||||
'{"responses": {"200": {"content": {"application/json": {"schema": '
|
||||
'{"type": ["string", "null"]}}}}}}}}};'
|
||||
)
|
||||
headers = {"content-type": "text/html"}
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
openapi_schema.httpx,
|
||||
"get",
|
||||
lambda url, **k: _R() if "openapi.json" in url else _Landing(),
|
||||
)
|
||||
spec = openapi_schema.fetch_openapi("https://h.example.com")
|
||||
schema = spec["paths"]["/x"]["get"]["responses"]["200"]["content"][
|
||||
"application/json"
|
||||
]["schema"]
|
||||
assert schema == {"anyOf": [{"type": "string"}, {"type": "null"}]}
|
||||
|
||||
|
||||
# --- literal dots inside URL segments ---
|
||||
|
||||
|
||||
def test_url_to_command_sanitizes_literal_dots_in_segments():
|
||||
assert (
|
||||
url_to_command("/v1.1/fundamentals/{ticker}", api_prefix="")
|
||||
== "v1_1.fundamentals"
|
||||
)
|
||||
|
||||
|
||||
def test_build_reference_sanitizes_dotted_segments_to_match_router_map():
|
||||
spec = {
|
||||
"paths": {
|
||||
"/v1.1/fundamentals/{ticker}": {"get": {"summary": "F"}},
|
||||
"/v1.1/bulk": {"get": {"summary": "B"}},
|
||||
}
|
||||
}
|
||||
ref = build_reference(spec, api_prefix="")
|
||||
assert "/v1_1/fundamentals" in ref["paths"]
|
||||
assert "/v1_1/bulk" in ref["paths"]
|
||||
assert "/v1_1/" in ref["routers"]
|
||||
|
||||
|
||||
# --- _bundle_external_refs (same-origin modular spec resolution) ---
|
||||
|
||||
|
||||
def test_bundle_external_refs_resolves_nested_same_origin_documents(monkeypatch):
|
||||
from openbb_cli.dispatchers import openapi_schema
|
||||
|
||||
@@ -1513,7 +1694,13 @@ def test_bundle_external_refs_resolves_nested_same_origin_documents(monkeypatch)
|
||||
schema = bundled["paths"]["/items"]["post"]["requestBody"]["content"][
|
||||
"application/json"
|
||||
]["schema"]
|
||||
assert schema["anyOf"] == [{"type": "object"}, {"type": "null"}]
|
||||
# ``_ensure_openapi_dict`` normalizes each loaded document, so the 3.1
|
||||
# type array arrives as sibling-preserving anyOf variants.
|
||||
assert schema["anyOf"] == [
|
||||
{"type": "object", "properties": {"symbol": {"type": "string"}}},
|
||||
{"type": "null"},
|
||||
]
|
||||
assert schema["properties"] == {"symbol": {"type": "string"}}
|
||||
assert request_body_parameters(schema)[0]["name"] == "symbol"
|
||||
|
||||
|
||||
@@ -1637,15 +1824,17 @@ def test_bundle_external_refs_rejects_resource_identifiers():
|
||||
)
|
||||
|
||||
|
||||
def test_bundle_external_refs_preserves_non_nullable_type_unions():
|
||||
def test_bundle_external_refs_leaves_type_arrays_to_ingestion_normalization():
|
||||
"""Bundling only inlines refs — 3.1 type arrays are ``expand_type_arrays``'
|
||||
job inside ``_ensure_openapi_dict``, which every fetched document passes
|
||||
through before bundling."""
|
||||
bundled = _bundle_external_refs(
|
||||
{"openapi": "3.1.0", "type": ["integer", "number"], "paths": {}},
|
||||
"https://api.example/openapi.json",
|
||||
timeout=1,
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert bundled["anyOf"] == [{"type": "integer"}, {"type": "number"}]
|
||||
assert bundled["type"] == ["integer", "number"]
|
||||
|
||||
|
||||
def test_bundle_external_refs_rejects_schema_ref_siblings(monkeypatch):
|
||||
|
||||
@@ -1175,6 +1175,123 @@ def test_normalize_parameter_xml_default_flips_to_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
|
||||
|
||||
Reference in New Issue
Block a user