Files
OpenBB/cli/openbb_cli/dispatchers/openapi_schema.py
Lucas Zimmermann 6e138eee4c 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 <>
2026-09-25 14:04:07 -07:00

1170 lines
40 KiB
Python

"""Build CLI menu / parser structures from an OpenAPI 3.x schema."""
from __future__ import annotations
import argparse
import json
import re
from typing import Any, NoReturn
from urllib.parse import unquote, urldefrag, urljoin, urlsplit
import httpx
_INHERITABLE_PARAMETER_LOCATIONS = frozenset({"path", "query"})
PROVIDER_TAG_RE = re.compile(r"\s*\(provider:\s*([^)]+)\)\s*$")
PROVIDER_SECTION_SPLIT_RE = re.compile(r";\s*\n\s*")
def parse_provider_sections(text: str) -> tuple[set[str], bool]:
"""Split an OpenBB-merged help string into ``(tagged_providers, has_untagged)``."""
tagged: set[str] = set()
has_untagged = False
for raw_section in PROVIDER_SECTION_SPLIT_RE.split(text):
section = raw_section.strip()
if not section:
continue
match = PROVIDER_TAG_RE.search(section)
if match is None:
has_untagged = True
continue
for p in match.group(1).split(","):
name = p.strip().lower()
if name:
tagged.add(name)
return tagged, has_untagged
def param_provider_membership(
schema: dict[str, Any],
description: str | None,
providers_set: set[str],
) -> list[str]:
"""Return the providers a parameter belongs to, or ``[]`` if shared by all."""
providers_lower = {p.lower(): p for p in providers_set}
if description:
tagged, has_untagged = parse_provider_sections(description)
if has_untagged:
return []
if tagged:
return [providers_lower[t] for t in tagged if t in providers_lower]
keys = [
k
for k, v in schema.items()
if k in providers_set
and k not in _OPENAPI_RESERVED_SCHEMA_KEYS
and isinstance(v, dict)
]
if keys:
return keys
title = schema.get("title")
if isinstance(title, str) and title.lower() in providers_lower:
return [providers_lower[title.lower()]]
return []
_TYPE_MAP: dict[str, type] = {
"string": str,
"integer": int,
"number": float,
"boolean": bool,
}
_OPENAPI_RESERVED_SCHEMA_KEYS = frozenset(
{
"type",
"format",
"title",
"description",
"default",
"enum",
"const",
"anyOf",
"oneOf",
"allOf",
"items",
"properties",
"additionalProperties",
"required",
"nullable",
"minimum",
"maximum",
"minLength",
"maxLength",
"pattern",
}
)
def resolve_ref(spec: dict[str, Any], ref: str) -> dict[str, Any]:
"""Resolve a local OpenAPI ``$ref`` JSON pointer against ``spec``."""
if not ref.startswith("#/"):
return {}
node: Any = spec
for raw_part in ref[2:].split("/"):
part = raw_part.replace("~1", "/").replace("~0", "~")
if not isinstance(node, dict) or part not in node:
return {}
node = node[part]
return node if isinstance(node, dict) else {}
def deref_parameter(spec: dict[str, Any], param: dict[str, Any]) -> dict[str, Any]:
"""Resolve a parameter that may be a ``$ref``."""
seen: set[str] = set()
while "$ref" in param:
ref = param["$ref"]
if ref in seen:
return {}
seen.add(ref)
param = resolve_ref(spec, ref) or {}
schema = param.get("schema")
if isinstance(schema, dict) and "$ref" in schema:
param = {**param, "schema": resolve_ref(spec, schema["$ref"]) or schema}
return param
def operation_parameters(
spec: dict[str, Any], path_item: dict[str, Any], op: dict[str, Any]
) -> list[dict[str, Any]]:
"""Return an operation's parameters, including those inherited from its path item.
OpenAPI 3.x lets a Path Item Object declare ``parameters`` that apply to every
operation under that path. An operation-level parameter overrides an inherited
one when both match on ``(name, in)``; anything else is inherited as-is.
Only ``in: path`` and ``in: query`` are inherited. A path-item ``in: header``
or ``in: cookie`` entry is deliberately dropped: those do not translate into a
Python interface function, and outbound headers belong to transport
configuration (``SystemSettings.PythonSettings.http.headers``, the CLI's
``-H``) or to security-scheme handling — not to a per-command argument.
Entries are returned dereferenced, inherited first, then the operation's own.
"""
inherited = path_item.get("parameters") or []
own_raw = op.get("parameters") or []
own = [deref_parameter(spec, p) for p in own_raw if isinstance(p, dict)]
own = [p for p in own if p]
if not inherited:
return own
overridden = {(p.get("name"), p.get("in")) for p in own}
merged: list[dict[str, Any]] = []
for raw in inherited:
if not isinstance(raw, dict):
continue
resolved = deref_parameter(spec, raw)
if not resolved or not resolved.get("name"):
continue
if resolved.get("in") not in _INHERITABLE_PARAMETER_LOCATIONS:
continue
if (resolved.get("name"), resolved.get("in")) in overridden:
continue
merged.append(resolved)
merged.extend(own)
return merged
def path_item_credential_parameters(
spec: dict[str, Any], path_item: dict[str, Any]
) -> list[dict[str, Any]]:
"""Return the path item's required ``header``/``cookie`` parameters, dereferenced.
These are exactly what :func:`operation_parameters` drops: a value every
operation under the path needs (a tenant id, a session cookie) that isn't a
per-call argument. They're a per-integration secret, not different per
request, so they route through ``openbb_cli.codegen.credentials`` the same
way an ``apiKey`` security scheme does, rather than becoming a required
argument the caller has to supply on every command.
A path-item parameter that is optional is left for the caller to notice —
only a required one is dead-on-arrival without this.
"""
out: list[dict[str, Any]] = []
for raw in path_item.get("parameters") or []:
if not isinstance(raw, dict):
continue
resolved = deref_parameter(spec, raw)
if not resolved or not resolved.get("name"):
continue
if resolved.get("in") not in ("header", "cookie"):
continue
if not resolved.get("required"):
continue
out.append(resolved)
return out
_NON_SCHEMA_KEYWORDS = frozenset({"default", "example", "examples", "enum", "const"})
def deref_schema(
spec: dict[str, Any],
node: Any,
seen: frozenset[str] | None = None,
max_depth: int = 32,
) -> Any:
"""Recursively expand every ``$ref`` inside an OpenAPI schema fragment."""
if max_depth <= 0:
return node
if seen is None:
seen = frozenset()
if isinstance(node, dict):
ref = node.get("$ref")
if isinstance(ref, str):
if ref in seen:
return {"$ref": ref}
target = resolve_ref(spec, ref)
if not target:
return node
return deref_schema(spec, target, seen | {ref}, max_depth - 1)
# Instance data (``default``, ``example(s)``, ``enum``, ``const``) and
# vendor extensions are not subschemas: a ``$ref`` key inside one is a
# literal value, not a reference to resolve.
return {
k: (
v
if k in _NON_SCHEMA_KEYWORDS or k.startswith("x-")
else deref_schema(spec, v, seen, max_depth - 1)
)
for k, v in node.items()
}
if isinstance(node, list):
return [deref_schema(spec, v, seen, max_depth - 1) for v in node]
return node
_ALLOF_BLOCKING_KEYS = frozenset(
{"oneOf", "anyOf", "allOf", "not", "$ref", "enum", "const", "items"}
)
def _mergeable_allof_members(members: list[Any]) -> list[dict[str, Any]] | None:
"""Return the members if every one is a plain object subschema, else ``None``.
A member is not mergeable when it carries its own combinator (``oneOf`` /
``anyOf`` / a nested ``allOf`` that did not collapse), an unresolved
``$ref`` left behind by a reference cycle, a scalar constraint (``enum`` /
``const`` / ``items``), or a ``type`` other than ``object``. Any of those
means the composition is not a plain intersection of property bags, and
merging it would describe something the spec does not.
Everything else is annotation that does not affect the intersection —
``title``, ``description``, ``examples``, ``definitions``, vendor ``x-``
keys — and does not block the merge. Keying off a blocklist rather than a
whitelist matters: Codat's ``PagingInfo`` carries ``definitions``, and
treating that as disqualifying left the composition unmerged.
"""
out: list[dict[str, Any]] = []
for member in members:
if not isinstance(member, dict):
return None
if member.keys() & _ALLOF_BLOCKING_KEYS:
return None
declared = member.get("type")
if declared is not None and declared != "object":
return None
out.append(member)
return out
def merge_allof(node: Any, max_depth: int = 32) -> Any:
"""Collapse ``allOf`` compositions of object subschemas into one object schema.
An ``allOf`` is an intersection: the instance must satisfy every member. A
consumer reading ``properties`` off the composition itself finds nothing,
because the properties live one level down inside the members. This merges
them into a single object schema so the composition can be read like any
other object.
``properties`` are unioned, with the first member to declare a name winning
on conflict, and any sibling ``properties`` on the ``allOf`` node itself
taking precedence over all members. ``required`` is unioned. Compositions
that are not a plain intersection of object subschemas are returned
unchanged.
"""
if max_depth <= 0:
return node
if isinstance(node, list):
return [merge_allof(v, max_depth - 1) for v in node]
if not isinstance(node, dict):
return node
# ``default``, ``example(s)``, ``enum`` and ``const`` hold instance data, not
# subschemas, and a vendor extension can hold anything. Recursing into them
# would rewrite a value that merely happens to contain an ``allOf`` key —
# a default of ``{"allOf": [...]}`` came back as ``{"type": "object"}``.
node = {
k: (
v
if k in _NON_SCHEMA_KEYWORDS or k.startswith("x-")
else merge_allof(v, max_depth - 1)
)
for k, v in node.items()
}
members = node.get("allOf")
if not isinstance(members, list) or not members:
return node
mergeable = _mergeable_allof_members(members)
if mergeable is None:
return node
properties: dict[str, Any] = {}
required: list[str] = []
for member in mergeable:
for name, subschema in (member.get("properties") or {}).items():
properties.setdefault(name, subschema)
for name in member.get("required") or []:
if name not in required:
required.append(name)
siblings = {k: v for k, v in node.items() if k != "allOf"}
# Keywords written next to the ``allOf`` describe the composition itself and
# outrank anything the members declare.
for name, subschema in (siblings.get("properties") or {}).items():
properties[name] = subschema
for name in siblings.get("required") or []:
if name not in required:
required.append(name)
merged: dict[str, Any] = {**siblings, "type": "object"}
if properties:
merged["properties"] = properties
if required:
merged["required"] = required
return merged
_SUCCESS_PRIORITY = ("200", "2XX", "201", "default")
_JSON_CONTENT_TYPES = ("application/json", "application/vnd.api+json")
def _deref_response(spec: dict[str, Any], response: dict[str, Any]) -> dict[str, Any]:
if "$ref" in response:
return resolve_ref(spec, response["$ref"]) or {}
return response
def extract_response_schema(
spec: dict[str, Any], operation: dict[str, Any]
) -> dict[str, Any] | None:
"""Pull the primary success-response JSON schema for an operation."""
responses = operation.get("responses") or {}
response: dict[str, Any] | None = None
for key in _SUCCESS_PRIORITY:
candidate = responses.get(key)
if isinstance(candidate, dict):
response = _deref_response(spec, candidate)
break
if response is None:
for value in responses.values():
if isinstance(value, dict):
response = _deref_response(spec, value)
break
if response is None:
return None
content = response.get("content") or {}
media: dict[str, Any] | None = None
for ct in _JSON_CONTENT_TYPES:
candidate = content.get(ct)
if isinstance(candidate, dict):
media = candidate
break
if media is None:
for value in content.values():
if isinstance(value, dict) and "schema" in value:
media = value
break
if media is None:
return None
schema = media.get("schema")
if not isinstance(schema, dict):
return None
return merge_allof(deref_schema(spec, schema))
def extract_request_body_schema(
spec: dict[str, Any], operation: dict[str, Any]
) -> dict[str, Any] | None:
"""Pull the primary request-body JSON schema for an operation."""
rb = operation.get("requestBody")
if not isinstance(rb, dict):
return None
if "$ref" in rb:
rb = resolve_ref(spec, rb["$ref"]) or {}
content = rb.get("content") or {}
media: dict[str, Any] | None = None
for ct in _JSON_CONTENT_TYPES:
candidate = content.get(ct)
if isinstance(candidate, dict):
media = candidate
break
if media is None:
for value in content.values():
if isinstance(value, dict) and "schema" in value:
media = value
break
if media is None:
return None
schema = media.get("schema")
if not isinstance(schema, dict):
return None
return merge_allof(deref_schema(spec, schema))
def extract_response_schemas(
spec: dict[str, Any], operation: dict[str, Any]
) -> dict[str, dict[str, dict[str, Any]]]:
"""Return every response schema as ``{status_code: {content_type: schema}}``."""
out: dict[str, dict[str, dict[str, Any]]] = {}
responses = operation.get("responses") or {}
for status, response in responses.items():
if not isinstance(response, dict):
continue
deref_resp = _deref_response(spec, response)
content = deref_resp.get("content") or {}
per_content: dict[str, dict[str, Any]] = {}
for content_type, media in content.items():
if not isinstance(media, dict):
continue
schema = media.get("schema")
if isinstance(schema, dict):
per_content[content_type] = merge_allof(deref_schema(spec, schema))
if per_content:
out[status] = per_content
return out
def _resolve_schema(schema: dict[str, Any]) -> tuple[type, list[Any], bool]:
"""Resolve an OpenAPI parameter schema to ``(py_type, choices, is_list)``."""
if "anyOf" in schema:
non_null = [s for s in schema["anyOf"] if s.get("type") != "null"]
if not non_null:
return (str, [], False)
if len(non_null) == 1:
return _resolve_schema(non_null[0])
choices: list[Any] = []
is_list = False
for s in non_null:
schema_type = s.get("type")
if schema_type == "array" or (
isinstance(schema_type, list) and "array" in schema_type
):
is_list = True
for v in s.get("enum", []):
if v not in choices:
choices.append(v)
return (str, choices, is_list)
schema_type = schema.get("type", "string")
schema_types = schema_type if isinstance(schema_type, list) else [schema_type]
if "array" in schema_types:
items = schema.get("items", {}) or {}
py_type, item_choices, _ = _resolve_schema(items)
return (py_type, item_choices, True)
if "const" in schema:
return (type(schema["const"]), [schema["const"]], False)
scalar_type = next(
(item for item in schema_types if item not in {"array", "null"}), "string"
)
py_type = _TYPE_MAP.get(scalar_type, str)
return (py_type, list(schema.get("enum", [])), False)
def _is_json_arg(schema: dict[str, Any]) -> bool:
"""Whether a request-body field must be supplied as a raw JSON value."""
schema_type = schema.get("type")
schema_types = schema_type if isinstance(schema_type, list) else [schema_type]
if "object" in schema_types or "properties" in schema:
return True
if schema.get("additionalProperties"):
return True
if "array" in schema_types:
items = schema.get("items")
return isinstance(items, dict) and _is_json_arg(items)
return any(
isinstance(member, dict) and _is_json_arg(member)
for member in schema.get("anyOf", []) or []
)
def parse_json_arg(raw: str) -> Any:
"""Decode a CLI argument value that carries a JSON document."""
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
raise argparse.ArgumentTypeError(f"invalid JSON: {exc}") from exc
def request_body_parameters(
body_schema: dict[str, Any] | None,
) -> list[dict[str, Any]]:
"""Flatten a dereferenced request-body schema into OpenAPI parameter objects.
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 and "properties" not in body_schema:
object_members = [
member
for member in body_schema.get("anyOf", [])
if isinstance(member, dict)
and (
"object"
in (
member.get("type")
if isinstance(member.get("type"), list)
else [member.get("type")]
)
or "properties" in member
)
]
if len(object_members) != 1:
return []
body_schema = {**body_schema, **object_members[0]}
required = set(body_schema.get("required") or [])
out: list[dict[str, Any]] = []
for name, prop in (body_schema.get("properties") or {}).items():
if not isinstance(prop, dict):
continue
out.append(
{
"name": name,
"in": "body",
"required": name in required,
"description": prop.get("description") or prop.get("title"),
"schema": prop,
"_json_arg": _is_json_arg(prop),
}
)
return out
def _provider_choices(schema: dict[str, Any]) -> list[Any]:
"""Union of per-provider ``choices`` lists found in OpenBB extension keys."""
out: list[Any] = []
for key, value in schema.items():
if key in _OPENAPI_RESERVED_SCHEMA_KEYS:
continue
if isinstance(value, dict) and isinstance(value.get("choices"), list):
for c in value["choices"]:
if c not in out:
out.append(c)
return out
def _escape_help(text: str | None) -> str | None:
"""Escape lone ``%`` so argparse's %-formatting validator accepts the help text."""
if text is None:
return None
return text.replace("%", "%%")
def parameter_to_kwargs(param: dict[str, Any]) -> tuple[str, dict[str, Any]] | None:
"""Translate one OpenAPI parameter to ``(flag, add_argument kwargs)``."""
name = param.get("name")
if not name or name == "chart":
return None
schema = param.get("schema", {}) or {}
py_type, enum_choices, is_list = _resolve_schema(schema)
provider_choices = _provider_choices(schema)
choices: list[Any] = list(enum_choices)
for c in provider_choices:
if c not in choices:
choices.append(c)
kwargs: dict[str, Any] = {
"dest": name,
"help": _escape_help(param.get("description") or schema.get("description")),
}
if param.get("_json_arg"):
kwargs["type"] = parse_json_arg
if param.get("required"):
kwargs["required"] = True
return (f"--{name}", kwargs)
if py_type is bool:
kwargs.update(action="store_true", default=bool(schema.get("default", False)))
return (f"--{name}", kwargs)
kwargs["type"] = py_type
if "default" in schema:
kwargs["default"] = schema["default"]
if choices:
kwargs["choices"] = choices
if is_list:
kwargs["nargs"] = "+"
if param.get("required") and "default" not in kwargs:
kwargs["required"] = True
return (f"--{name}", kwargs)
def build_parser_from_operation(
op: dict[str, Any],
spec: dict[str, Any] | None = None,
path_item: dict[str, Any] | None = None,
) -> argparse.ArgumentParser:
"""Build an ``ArgumentParser`` from an OpenAPI operation object.
When ``path_item`` is given, parameters shared by every operation under that
path are inherited by the parser (see :func:`operation_parameters`).
"""
parser = argparse.ArgumentParser(
prog=op.get("operationId", "cmd"),
description=(op.get("description") or op.get("summary") or "").strip() or None,
add_help=False,
formatter_class=argparse.RawTextHelpFormatter,
)
body_params = (
request_body_parameters(extract_request_body_schema(spec, op))
if spec is not None
else []
)
params = (
operation_parameters(spec, path_item or {}, op)
if spec is not None
else list(op.get("parameters", []) or [])
)
for param in [*params, *body_params]:
translated = parameter_to_kwargs(param)
if translated is None:
continue
flag, kwargs = translated
try:
parser.add_argument(flag, **kwargs)
except argparse.ArgumentError:
continue
return parser
def _strip_placeholders(segment: str) -> str:
"""Remove ``{placeholder}`` substrings from a URL segment."""
out = segment
while "{" in out and "}" in out:
i = out.find("{")
j = out.find("}", i)
if j < 0:
break
out = out[:i] + out[j + 1 :]
return out.strip(".")
def detect_api_prefix(spec: dict[str, Any]) -> str:
"""Compute the longest slash-segmented prefix shared by every path."""
paths = list(spec.get("paths", {}).keys())
if not paths:
return "/api/v1"
parts_lists = [p.split("/")[:-1] for p in paths]
common: list[str] = parts_lists[0]
for parts in parts_lists[1:]:
new_common: list[str] = []
for a, b in zip(common, parts):
if a == b:
new_common.append(a)
else:
break
common = new_common
if not common:
break
prefix = "/".join(common).rstrip("/")
return prefix or ""
def url_to_command(url: str, api_prefix: str = "/api/v1") -> str:
"""Convert a URL path to a dotted command, dropping ``{path_params}``.
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).replace(".", "_") for p in parts]
return ".".join(p for p in cleaned if p)
def build_command_index(
spec: dict[str, Any], *, api_prefix: str = "/api/v1"
) -> dict[str, argparse.ArgumentParser]:
"""Map each ``paths`` entry in the spec to an ArgumentParser by dotted command."""
index: dict[str, argparse.ArgumentParser] = {}
for url, methods in spec.get("paths", {}).items():
op = methods.get("get") or methods.get("post")
if not op:
continue
index[url_to_command(url, api_prefix=api_prefix)] = build_parser_from_operation(
op, spec, methods
)
return index
def build_router_map(
spec: dict[str, Any], *, api_prefix: str = "/api/v1"
) -> dict[str, str]:
"""Classify every dotted path in ``spec`` as ``"menu"`` or ``"command"``."""
out: dict[str, str] = {}
for url, methods in spec.get("paths", {}).items():
if not (methods.get("get") or methods.get("post")):
continue
cmd = url_to_command(url, api_prefix=api_prefix)
if not cmd:
continue
parts = cmd.split(".")
for i in range(1, len(parts)):
out[".".join(parts[:i])] = "menu"
out.setdefault(cmd, "command")
return out
def build_reference(
spec: dict[str, Any], *, api_prefix: str = "/api/v1"
) -> dict[str, Any]:
"""Mimic ``obb.reference`` — ``paths`` for commands, ``routers`` for menus."""
prefix_parts = [p for p in api_prefix.strip("/").split("/") if p]
tag_descriptions: dict[str, str] = {
t["name"]: t.get("description", "")
for t in spec.get("tags", [])
if isinstance(t, dict) and t.get("name")
}
router_map = build_router_map(spec, api_prefix=api_prefix)
paths_out: dict[str, dict[str, Any]] = {}
routers_out: dict[str, dict[str, Any]] = {}
for url, methods in spec.get("paths", {}).items():
op = methods.get("get") or methods.get("post")
if not op:
continue
parts = [p for p in url.strip("/").split("/") if p]
if parts[: len(prefix_parts)] == prefix_parts:
parts = parts[len(prefix_parts) :]
non_template = [
p.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"):
paths_out[cli_path] = {"description": op_desc}
tags = op.get("tags") or []
tag_desc = tag_descriptions.get(tags[0]) if tags else ""
for i in range(1, len(non_template) + 1):
dotted = ".".join(non_template[:i])
if router_map.get(dotted) != "menu":
continue
menu_path = "/" + "/".join(non_template[:i]) + "/"
if not routers_out.get(menu_path, {}).get("description"):
routers_out[menu_path] = {"description": tag_desc or ""}
return {"paths": paths_out, "routers": routers_out}
def _parse_spec_text(text: str, *, content_type: str = "") -> dict[str, Any]:
"""Parse a fetched spec body, choosing JSON or YAML by content sniff."""
stripped = text.lstrip()
if stripped.startswith(("{", "[")):
return json.loads(text)
if "yaml" in content_type or "yml" in content_type:
return _yaml_load(text)
if stripped[:8] in ("openapi:", "swagger:"):
return _yaml_load(text)
try:
return json.loads(text)
except json.JSONDecodeError:
return _yaml_load(text)
def _yaml_load(text: str) -> dict[str, Any]:
"""Parse a YAML document."""
import yaml
return yaml.safe_load(text)
_EMBEDDED_SPEC_MARKERS: tuple[str, ...] = (
"var spec = ",
"const spec = ",
"let spec = ",
"window.spec = ",
'"spec":',
"spec:",
)
def _find_matching_brace(text: str, start: int) -> int | None:
"""Return the index *after* the ``}`` that matches the ``{`` at ``start``."""
if start >= len(text) or text[start] != "{":
return None
depth = 0
in_string = False
escape = False
for i in range(start, len(text)):
c = text[i]
if escape:
escape = False
continue
if c == "\\":
escape = True
continue
if c == '"':
in_string = not in_string
continue
if in_string:
continue
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return i + 1
return None
def _extract_embedded_spec(html: str) -> dict[str, Any] | None:
"""Scan an HTML page for an embedded OpenAPI / Swagger spec."""
for marker in _EMBEDDED_SPEC_MARKERS:
idx = 0
while True:
i = html.find(marker, idx)
if i < 0:
break
start = i + len(marker)
while start < len(html) and html[start] in " \t\r\n":
start += 1
end = _find_matching_brace(html, start)
if end is None:
idx = i + 1
continue
try:
obj = json.loads(html[start:end])
except json.JSONDecodeError:
idx = i + 1
continue
if isinstance(obj, dict) and ("openapi" in obj or "swagger" in obj):
return obj
idx = end
return None
def _resolve_json_pointer(document: Any, fragment: str) -> Any:
"""Resolve a URI-fragment JSON pointer within one OpenAPI document."""
if not fragment:
return document
pointer = unquote(fragment)
if not pointer.startswith("/"):
raise ValueError(f"Unsupported OpenAPI reference fragment: #{fragment}")
node = document
for raw_part in pointer[1:].split("/"):
part = raw_part.replace("~1", "/").replace("~0", "~")
if isinstance(node, dict):
node = node[part]
elif isinstance(node, list):
node = node[int(part)]
else:
raise ValueError(f"Invalid OpenAPI reference fragment: #{fragment}")
return node
def _url_origin(url: str) -> tuple[str, str, int | None]:
"""Return a normalized HTTP(S) origin for reference security checks."""
parsed = urlsplit(url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ValueError(f"Unsupported OpenAPI reference URL: {url}")
default_port = 443 if parsed.scheme == "https" else 80
return parsed.scheme, parsed.hostname.lower(), parsed.port or default_port
def _bundle_external_refs(
document: dict[str, Any],
source_url: str,
*,
timeout: float,
headers: dict[str, str],
) -> dict[str, Any]:
"""Inline same-origin external refs while preserving root-local refs."""
max_documents = 512
max_document_bytes = 8 * 1024 * 1024
read_chunk_bytes = 64 * 1024
max_depth = 128
root_url, _ = urldefrag(source_url)
root_origin = _url_origin(root_url)
documents: dict[str, dict[str, Any]] = {root_url: document}
def load_document(url: str) -> dict[str, Any]:
cached = documents.get(url)
if cached is not None:
return cached
if _url_origin(url) != root_origin:
raise ValueError(f"External OpenAPI reference must be same-origin: {url}")
if len(documents) >= max_documents:
raise ValueError("OpenAPI reference graph exceeds the document limit")
with httpx.stream(
"GET",
url,
timeout=timeout,
follow_redirects=False,
headers=headers,
) as response:
if response.is_redirect:
raise ValueError(
"Redirects are not allowed for external OpenAPI references"
)
response.raise_for_status()
content_length = response.headers.get("content-length")
if content_length and int(content_length) > max_document_bytes:
raise ValueError("External OpenAPI reference exceeds the size limit")
content = bytearray()
for chunk in response.iter_bytes(chunk_size=read_chunk_bytes):
if len(chunk) > max_document_bytes - len(content):
raise ValueError(
"External OpenAPI reference exceeds the size limit"
)
content.extend(chunk)
text = bytes(content).decode(response.encoding or "utf-8")
content_type = response.headers.get("content-type", "")
parsed = _ensure_openapi_dict(
_parse_spec_text(
text,
content_type=content_type,
),
url,
)
documents[url] = parsed
return parsed
def visit(
node: Any,
current_url: str,
current_document: dict[str, Any],
*,
imported: bool,
seen: frozenset[tuple[str, str]],
depth: int,
) -> Any:
if depth > max_depth:
raise ValueError("OpenAPI reference graph exceeds the depth limit")
if isinstance(node, dict):
if "$id" in node:
raise ValueError("OpenAPI $id resource identifiers are not supported")
ref = node.get("$ref")
if isinstance(ref, str) and (imported or not ref.startswith("#")):
target_url, fragment = urldefrag(urljoin(current_url, ref))
if fragment and not fragment.startswith("/"):
raise ValueError("OpenAPI reference anchors are not supported")
marker = (target_url, fragment)
if marker in seen:
return {"$ref": f"{target_url}#{fragment}"}
target_document = (
current_document
if target_url == current_url
else load_document(target_url)
)
target = _resolve_json_pointer(target_document, fragment)
resolved = visit(
target,
target_url,
target_document,
imported=True,
seen=seen | {marker},
depth=depth + 1,
)
unsupported_siblings = set(node) - {"$ref", "summary", "description"}
if unsupported_siblings:
raise ValueError(
"OpenAPI reference siblings other than summary and description "
"are not supported"
)
siblings = {
key: visit(
value,
current_url,
current_document,
imported=imported,
seen=seen,
depth=depth + 1,
)
for key, value in node.items()
if key != "$ref"
}
if isinstance(resolved, dict):
return {**resolved, **siblings}
return resolved
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(
value,
current_url,
current_document,
imported=imported,
seen=seen,
depth=depth + 1,
)
for value in node
]
return node
return visit(
document,
root_url,
document,
imported=False,
seen=frozenset(),
depth=0,
)
def _raise_fetch_failure(
response: Any, parse_error: ValueError | None, url: str
) -> NoReturn:
"""Raise why the spec at ``url`` could not be loaded.
Parameters
----------
response : Any
The response for the spec URL.
parse_error : ValueError | None
The error from parsing a successful response, if any.
url : str
The spec URL.
Raises
------
httpx.HTTPStatusError
If the response has an error status.
ValueError
If the response body is not a parseable OpenAPI document.
"""
response.raise_for_status()
raise parse_error or ValueError(f"Could not load an OpenAPI document from {url}")
def fetch_openapi(
base_url: str,
*,
timeout: float = 10.0,
path: str | None = None,
headers: dict[str, str] | None = None,
query_params: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Fetch the OpenAPI spec at ``base_url`` and return the parsed dict."""
explicit_path = path is not None
full_url = (
path
if path and (path.startswith("http://") or path.startswith("https://"))
else f"{base_url.rstrip('/')}{path or '/openapi.json'}"
)
merged_headers = {"User-Agent": "openbb-cli/1.0"}
if headers:
merged_headers.update(headers)
response = httpx.get(
full_url,
timeout=timeout,
follow_redirects=True,
headers=merged_headers,
params=query_params or None,
)
parse_error: ValueError | None = None
if response.status_code < 400:
try:
parsed = _ensure_openapi_dict(
_parse_spec_text(
response.text,
content_type=response.headers.get("content-type", ""),
),
full_url,
)
return _bundle_external_refs(
parsed,
str(getattr(response, "url", full_url)),
timeout=timeout,
headers=merged_headers,
)
except ValueError as exc:
parse_error = exc
if explicit_path:
_raise_fetch_failure(response, parse_error, full_url)
landing_url = base_url.rstrip("/") + "/"
landing = httpx.get(
landing_url,
timeout=timeout,
follow_redirects=True,
headers=merged_headers,
params=query_params or None,
)
landing.raise_for_status()
embedded = _extract_embedded_spec(landing.text)
if embedded is not None:
return _bundle_external_refs(
_ensure_openapi_dict(embedded, landing_url),
str(getattr(landing, "url", landing_url)),
timeout=timeout,
headers=merged_headers,
)
_raise_fetch_failure(response, parse_error, full_url)
def _ensure_openapi_dict(parsed: Any, source_url: str) -> dict[str, Any]:
"""Reject parsed bodies that aren't a JSON object (and thus can't be an OpenAPI doc)."""
if not isinstance(parsed, dict):
raise ValueError(
f"{source_url!r} returned a "
f"{type(parsed).__name__}, not an OpenAPI document. "
"Pass --openapi-path to point at the real spec endpoint "
"(e.g. /swagger/v1/swagger.json)."
)
return 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}