Files
OpenBB/cli/openbb_cli/dispatchers/spec.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

593 lines
21 KiB
Python

"""Pre-computed spec format for instant one-shot CLI startup."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
from datetime import datetime, timezone
from importlib.metadata import (
PackageNotFoundError,
version as _pkg_version,
)
from pathlib import Path
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from openbb_cli.dispatchers.openapi_schema import (
_escape_help,
_provider_choices,
_resolve_schema,
build_reference,
build_router_map,
detect_api_prefix,
extract_request_body_schema,
extract_response_schema,
extract_response_schemas,
operation_parameters,
param_provider_membership,
parse_json_arg,
path_item_credential_parameters,
request_body_parameters,
url_to_command,
)
SPEC_VERSION = 5
_TYPE_TO_NAME: dict[type, str] = {
str: "string",
int: "integer",
float: "number",
bool: "boolean",
}
_NAME_TO_TYPE: dict[str, type] = {v: k for k, v in _TYPE_TO_NAME.items()}
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_DATETIME_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z?$")
def _coerce_iso_datetime(value: str) -> str:
"""Expand short date forms to RFC-3339 timestamps the upstream API expects."""
if _DATE_RE.match(value):
return f"{value}T00:00:00Z"
if _DATETIME_RE.match(value) and not value.endswith("Z"):
return value + "Z"
return value
def _looks_like_datetime_param(name: str, help_text: str | None) -> bool:
"""Heuristic: parameter is a datetime if name or help points at one."""
n = name.lower()
if n.endswith("datetime") or "_datetime" in n:
return True
return bool(
help_text
and ("timestamp" in help_text.lower() or "yyyy-mm-dd" in help_text.lower())
)
def _operation_providers(op: dict[str, Any]) -> list[str]:
"""Return the OpenBB provider list for an operation, ordered as declared."""
for raw in op.get("parameters", []) or []:
if not isinstance(raw, dict):
continue
if raw.get("name") != "provider":
continue
schema = raw.get("schema") or {}
enum = schema.get("enum")
if isinstance(enum, list):
return [str(e) for e in enum]
const = schema.get("const")
if const is not None:
return [str(const)]
return []
def _normalize_parameter(
param: dict[str, Any], providers_set: set[str] | None = None
) -> dict[str, Any] | None:
"""Translate one OpenAPI parameter into the spec-file parameter form."""
name = param.get("name")
if not name or name == "chart":
return None
location = param.get("in", "query")
schema = param.get("schema", {}) or {}
json_arg = bool(param.get("_json_arg"))
py_type, enum_choices, is_list = _resolve_schema(schema)
if json_arg:
py_type, enum_choices, is_list = str, [], False
choices: list[Any] = list(enum_choices)
for c in _provider_choices(schema):
if c not in choices:
choices.append(c)
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 lower_choices & {"xml", "csv"}:
default = "json"
if default is not None:
required = False
description = param.get("description") or schema.get("description")
providers: list[str] = (
param_provider_membership(schema, description, providers_set)
if providers_set and name != "provider"
else []
)
example: Any = param.get("example")
if example is None:
examples_map = param.get("examples") or {}
if isinstance(examples_map, dict):
for entry in examples_map.values():
if isinstance(entry, dict) and "value" in entry:
example = entry["value"]
break
if example is None:
example = schema.get("example")
type_name = _TYPE_TO_NAME.get(py_type, "string")
if location == "path" and type_name == "number":
type_name = "integer"
return {
"name": name,
"in": location,
"type": type_name,
"is_list": is_list,
"required": required,
"default": default,
"choices": choices,
"example": example,
"help": param.get("description") or schema.get("description"),
"providers": providers,
"json_arg": json_arg,
}
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],
path_item: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build the per-URL command entry — params, providers, schemas."""
providers = _operation_providers(op)
providers_set = set(providers) if providers else None
params: list[dict[str, Any]] = []
for resolved in operation_parameters(spec, path_item or {}, op):
if not resolved:
continue
normalized = _normalize_parameter(resolved, providers_set)
if normalized is not None:
params.append(normalized)
declared = {p["name"] for p in params}
# A required header/cookie the path item declares for every operation (a
# tenant id, a session cookie) isn't a per-call argument — it routes through
# credentials.classify_parameter, same as an apiKey security scheme.
for resolved in path_item_credential_parameters(spec, path_item or {}):
if resolved["name"] in declared:
continue
normalized = _normalize_parameter(resolved, providers_set)
if normalized is not None:
params.append(normalized)
declared.add(normalized["name"])
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,
"description": (op.get("description") or op.get("summary") or "").strip(),
"parameters": params,
"providers": providers,
"request_body_schema": extract_request_body_schema(spec, op),
"response_schema": extract_response_schema(spec, op),
"response_schemas": extract_response_schemas(spec, op),
}
def _merge_overload_entries(entries: list[dict[str, Any]]) -> dict[str, Any]:
"""Merge URL overloads (same dotted name) into a single command entry.
Each variant URL is recorded in ``url_templates`` (sorted by path-placeholder
count, descending). All path params become optional on the merged command —
the dispatcher picks the longest URL template whose placeholders are
satisfied at call time. Query-/body- params are unioned by name; ``required``
holds only when the param is required in every variant where it appears.
"""
entries = sorted(entries, key=lambda e: -_count_path_placeholders(e["url_path"]))
base = entries[-1]
by_name: dict[str, dict[str, Any]] = {}
seen_in_count: dict[str, int] = {}
for entry in entries:
for p in entry["parameters"]:
name = p["name"]
seen_in_count[name] = seen_in_count.get(name, 0) + 1
merged = by_name.setdefault(name, dict(p))
if not merged.get("help") and p.get("help"):
merged["help"] = p["help"]
if not merged.get("choices") and p.get("choices"):
merged["choices"] = list(p["choices"])
if merged.get("default") is None and p.get("default") is not None:
merged["default"] = p["default"]
if merged.get("example") is None and p.get("example") is not None:
merged["example"] = p["example"]
total_entries = len(entries)
merged_params: list[dict[str, Any]] = []
for name, merged in by_name.items():
if seen_in_count[name] < total_entries:
merged["required"] = False
merged_params.append(merged)
description = next(
(entry.get("description") for entry in entries if entry.get("description")),
"",
)
return {
"url_path": base["url_path"],
"url_templates": [entry["url_path"] for entry in entries],
"method": base["method"],
"description": description,
"parameters": merged_params,
"providers": base.get("providers", []),
"request_body_schema": base.get("request_body_schema"),
"response_schema": base.get("response_schema"),
"response_schemas": base.get("response_schemas"),
}
def _count_path_placeholders(url: str) -> int:
"""Count ``{placeholder}`` occurrences in a URL template."""
return url.count("{")
def build_command_spec(
spec: dict[str, Any], *, api_prefix: str = "/api/v1"
) -> dict[str, dict[str, Any]]:
"""Return ``{dotted_command: {url_path, method, description, parameters[]}}``.
URLs that strip to the same dotted command (e.g. ``/x/{a}`` and
``/x/{a}/{b}``) are merged into a single command entry whose
``url_templates`` lists every variant. The dispatcher picks the longest
fully-satisfied template at call time.
"""
groups: dict[str, list[dict[str, Any]]] = {}
for url, methods in spec.get("paths", {}).items():
if "get" in methods:
method, op = "get", methods["get"]
elif "post" in methods:
method, op = "post", methods["post"]
else:
continue
base_cmd = url_to_command(url, api_prefix=api_prefix)
if not base_cmd:
continue
groups.setdefault(base_cmd, []).append(
_build_operation_entry(spec, url, method, op, methods)
)
out: dict[str, dict[str, Any]] = {}
for cmd, entries in groups.items():
out[cmd] = entries[0] if len(entries) == 1 else _merge_overload_entries(entries)
return out
def _resolve_base_url(openapi: dict[str, Any], user_base_url: str) -> str:
"""Honor the OpenAPI ``servers[0].url`` when it adds a path component."""
user_clean = user_base_url.rstrip("/")
servers = openapi.get("servers") or []
if not servers or not isinstance(servers[0], dict):
return user_clean
server_url = (servers[0].get("url") or "").strip()
if not server_url:
return user_clean
if server_url.startswith(("http://", "https://")):
from urllib.parse import urlsplit
if urlsplit(user_clean).path in ("", "/"):
return server_url.rstrip("/")
return user_clean
server_path = "/" + server_url.strip("/")
if user_clean.endswith(server_path):
return user_clean
return user_clean + server_path
def _generator_identifier() -> str:
"""Return ``"openbb-cli==<version>"`` or just the name when unresolved."""
try:
return f"openbb-cli=={_pkg_version('openbb-cli')}"
except PackageNotFoundError:
return "openbb-cli"
def build_spec_document(
openapi: dict[str, Any],
*,
base_url: str,
api_prefix: str | None = None,
source_url: str | None = None,
) -> dict[str, Any]:
"""Build the on-disk spec dict from a fetched OpenAPI document."""
effective_prefix = (
api_prefix if api_prefix is not None else detect_api_prefix(openapi)
)
return {
"version": SPEC_VERSION,
"generated_at": datetime.now(timezone.utc).isoformat(),
"generator": _generator_identifier(),
"source_url": source_url or "",
"api_version": str(openapi.get("openapi") or openapi.get("swagger") or ""),
"base_url": _resolve_base_url(openapi, base_url),
"api_prefix": (("/" + effective_prefix.strip("/")) if effective_prefix else ""),
"commands": build_command_spec(openapi, api_prefix=effective_prefix),
"routers": build_router_map(openapi, api_prefix=effective_prefix),
"reference": build_reference(openapi, api_prefix=effective_prefix),
}
def _content_hash(spec_doc: dict[str, Any]) -> str:
"""Hash the spec doc deterministically, ignoring any existing hash field."""
payload = {k: v for k, v in spec_doc.items() if k != "content_sha256"}
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
return hashlib.sha256(canonical).hexdigest()
def write_spec(path: str | Path, spec_doc: dict[str, Any]) -> None:
"""Write the spec doc to ``path`` as compact JSON, stamped with its SHA-256."""
spec_doc["content_sha256"] = _content_hash(spec_doc)
Path(path).write_text(json.dumps(spec_doc, separators=(",", ":")))
class _CommandParameter(BaseModel):
"""One parameter inside a command's ``parameters`` list."""
model_config = ConfigDict(extra="allow")
name: str
in_: str = Field(default="query", alias="in")
type: str = "string"
is_list: bool = False
required: bool = False
default: Any = None
choices: list[Any] = Field(default_factory=list)
example: Any = None
help: str | None = None
providers: list[str] = Field(default_factory=list)
json_arg: bool = False
class _CommandSpec(BaseModel):
"""One entry in ``spec["commands"]`` keyed by dotted command path."""
model_config = ConfigDict(extra="allow")
url_path: str
url_templates: list[str] | None = None
method: str
description: str | None = None
parameters: list[_CommandParameter] = Field(default_factory=list)
providers: list[str] = Field(default_factory=list)
request_body_schema: dict[str, Any] | None = None
response_schema: dict[str, Any] | None = None
class SpecDocument(BaseModel):
"""The on-disk shape of a ``.spec`` file."""
model_config = ConfigDict(extra="allow")
version: int
base_url: str
api_prefix: str = ""
commands: dict[str, _CommandSpec]
routers: dict[str, Any] = Field(default_factory=dict)
reference: dict[str, Any] = Field(default_factory=dict)
generated_at: str | None = None
generator: str | None = None
source_url: str | None = None
api_version: str | None = None
content_sha256: str | None = None
def load_spec(path: str | Path) -> dict[str, Any]:
"""Load and validate a spec doc; reject incompatible versions early."""
spec_doc = json.loads(Path(path).read_text())
version = spec_doc.get("version")
if version != SPEC_VERSION:
raise ValueError(
f"Spec file at {path} has version {version!r}; expected {SPEC_VERSION}. "
f"Regenerate with: openbb --server <URL> --generate-spec --output {path}"
)
try:
SpecDocument.model_validate(spec_doc)
except ValidationError as exc:
raise ValueError(
f"Spec file at {path} does not conform to the expected schema:\n{exc}"
) from exc
recorded_hash = spec_doc.get("content_sha256")
if recorded_hash is not None:
actual_hash = _content_hash(spec_doc)
if recorded_hash != actual_hash:
raise ValueError(
f"Spec file at {path} failed integrity check: "
f"recorded SHA-256 {recorded_hash!r} does not match recomputed "
f"{actual_hash!r}. The file has been modified since it was generated."
)
return spec_doc
def command_parameters(cmd_spec: dict[str, Any]) -> list[dict[str, Any]]:
"""Return a command's full input surface: query/path params fused with body."""
params = list(cmd_spec.get("parameters") or [])
for body_param in request_body_parameters(cmd_spec.get("request_body_schema")):
normalized = _normalize_parameter(body_param)
if normalized is not None:
params.append(normalized)
return params
def parser_from_command_spec(
cmd_spec: dict[str, Any],
prog: str | None = None,
*,
selected_provider: str | None = None,
) -> argparse.ArgumentParser:
"""Build an ArgumentParser from one entry in ``spec["commands"]``."""
fallback_prog = cmd_spec.get("url_path", "cmd").rsplit("/", 1)[-1] or "cmd"
parser = argparse.ArgumentParser(
prog=prog or fallback_prog,
description=cmd_spec.get("description") or None,
formatter_class=argparse.RawTextHelpFormatter,
add_help=False,
)
required_group = parser.add_argument_group("required arguments")
optional_group = parser.add_argument_group("optional arguments")
for p in command_parameters(cmd_spec):
if not _param_visible_for_provider(p, selected_provider):
continue
target = required_group if p.get("required") else optional_group
try:
_add_normalized_parameter(target, p)
except argparse.ArgumentError:
continue
return parser
def _param_visible_for_provider(
p: dict[str, Any], selected_provider: str | None
) -> bool:
"""Whether a normalized parameter belongs in the parser for ``selected_provider``."""
if selected_provider is None or p.get("name") == "provider":
return True
tags = p.get("providers") or []
return not tags or selected_provider in tags
def _add_normalized_parameter(
parser: argparse._ActionsContainer, p: dict[str, Any]
) -> None:
"""Translate one normalized parameter dict into a parser argument."""
flag = f"--{p['name']}"
py_type = _NAME_TO_TYPE.get(p.get("type", "string"), str)
kwargs: dict[str, Any] = {"dest": p["name"], "help": _escape_help(p.get("help"))}
if p.get("json_arg"):
kwargs["type"] = parse_json_arg
if p.get("required"):
kwargs["required"] = True
parser.add_argument(flag, **kwargs)
return
if py_type is bool:
kwargs.update(
action=argparse.BooleanOptionalAction,
default=bool(p.get("default") or False),
)
parser.add_argument(flag, **kwargs)
return
is_socrata_range_param = p.get("_socrata_op") in {"date_min", "date_max"}
if (
py_type is str
and not is_socrata_range_param
and _looks_like_datetime_param(p["name"], p.get("help"))
):
kwargs["type"] = _coerce_iso_datetime
else:
kwargs["type"] = py_type
if p.get("default") is not None:
kwargs["default"] = p["default"]
if p.get("choices"):
kwargs["choices"] = list(p["choices"])
if p.get("is_list"):
kwargs["nargs"] = "+"
if p.get("required") and "default" not in kwargs:
kwargs["required"] = True
parser.add_argument(flag, **kwargs)
class SpecCommandError(KeyError):
"""Raised when a dotted command path is not present in the spec."""
def parse_command_argv(
spec_doc: dict[str, Any], argv: list[str]
) -> tuple[str, dict[str, Any]]:
"""Resolve ``argv`` against ``spec_doc['commands']`` → (command, params)."""
if not argv:
raise SpecCommandError(
"missing command — usage: openbb --spec PATH <command.path> [--key value]"
)
command, rest = argv[0], list(argv[1:])
cmd_spec = spec_doc.get("commands", {}).get(command)
if cmd_spec is None:
raise SpecCommandError(f"command not in spec: {command!r}")
selected_provider = _peek_provider(cmd_spec, rest)
parser = parser_from_command_spec(cmd_spec, selected_provider=selected_provider)
ns = parser.parse_args(rest)
params = {k: v for k, v in vars(ns).items() if v is not None}
return command, params
def _peek_provider(cmd_spec: dict[str, Any], argv: list[str]) -> str | None:
"""Run a minimal first-pass parser to extract ``--provider`` from ``argv``."""
providers = cmd_spec.get("providers") or []
if not providers:
return None
peek = argparse.ArgumentParser(add_help=False)
peek.add_argument("--provider", choices=list(providers))
try:
ns, _ = peek.parse_known_args(argv)
except SystemExit:
return None
return getattr(ns, "provider", None)