mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-29 05:21:48 +00:00
[BugFix] openbb-cli: Fix Detection of Sub-Routes of a Path (#7794)
* fix detection of sub-routes of a path * ruff fixes * more lint --------- Co-authored-by: dlee-bq <325002271+dlee-bq@users.noreply.github.com>
This commit is contained in:
@@ -15,8 +15,8 @@ from openbb_cli.codegen.pydantic_gen import (
|
||||
class_name_from,
|
||||
consolidate_imports,
|
||||
generate_class,
|
||||
query_field_name,
|
||||
render_function_docstring,
|
||||
safe_field_name,
|
||||
)
|
||||
|
||||
_PATH_TEMPLATE_RE = re.compile(r"\{([^}]+)\}")
|
||||
@@ -45,6 +45,7 @@ class FetcherCommandSpec:
|
||||
base_url: str
|
||||
api_prefix: str
|
||||
provider_name: str
|
||||
headers: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -123,6 +124,37 @@ def _module_name_from_command(name: str) -> str:
|
||||
return safe.lower()
|
||||
|
||||
|
||||
_COORDINATE_NAME_RE = re.compile(r"(^|[^a-z])(lat|lon|lng|latitude|longitude)", re.I)
|
||||
|
||||
|
||||
def _is_coordinate_name(name: str) -> bool:
|
||||
"""Whether a path parameter is a decimal coordinate (never an integer id)."""
|
||||
return bool(_COORDINATE_NAME_RE.search(name))
|
||||
|
||||
|
||||
def _mark_null_empty(node: Any) -> Any:
|
||||
"""Flag every object schema so its generated class treats ``''`` as null.
|
||||
|
||||
The marker is a vendor extension read by ``generate_class``; nested object
|
||||
schemas are visited too, so nested classes get the same behavior.
|
||||
"""
|
||||
if isinstance(node, list):
|
||||
return [_mark_null_empty(n) for n in node]
|
||||
if not isinstance(node, dict):
|
||||
return node
|
||||
out = {
|
||||
k: (
|
||||
v
|
||||
if k in ("enum", "const", "default", "example", "examples")
|
||||
else _mark_null_empty(v)
|
||||
)
|
||||
for k, v in node.items()
|
||||
}
|
||||
if out.get("type") == "object" or "properties" in out:
|
||||
out["x-null-empty"] = True
|
||||
return out
|
||||
|
||||
|
||||
def _path_template_keys(url_path: str) -> list[str]:
|
||||
"""Extract ``{placeholder}`` names from a URL template, in declaration order."""
|
||||
return [m.group(1) for m in _PATH_TEMPLATE_RE.finditer(url_path)]
|
||||
@@ -163,16 +195,30 @@ def _query_params_schema(
|
||||
for key in ("type", "default", "choices"):
|
||||
if key in raw and raw[key] is not None:
|
||||
prop_schema[key] = raw[key]
|
||||
if raw.get("in") == "path" and prop_schema.get("type") == "number":
|
||||
if (
|
||||
raw.get("in") == "path"
|
||||
and prop_schema.get("type") == "number"
|
||||
and not _is_coordinate_name(name)
|
||||
):
|
||||
prop_schema["type"] = "integer"
|
||||
if description:
|
||||
prop_schema["description"] = description
|
||||
if raw.get("is_list"):
|
||||
if raw.get("is_list") and raw.get("in", "query") == "query":
|
||||
# Platform convention: a ``str`` field flagged ``multiple_items_allowed``
|
||||
# (comma-separated over REST, list-or-str in Python).
|
||||
prop_schema = {"type": "string", "x-multiple-items": True}
|
||||
if description:
|
||||
prop_schema["description"] = description
|
||||
if raw.get("choices"):
|
||||
prop_schema["x-choices"] = list(raw["choices"])
|
||||
if raw.get("default") is not None:
|
||||
prop_schema["default"] = raw["default"]
|
||||
elif raw.get("is_list"):
|
||||
inner = {"type": prop_schema.get("type", "string")}
|
||||
prop_schema = {"type": "array", "items": inner}
|
||||
if description:
|
||||
prop_schema["description"] = description
|
||||
if raw.get("choices"):
|
||||
if raw.get("choices") and "x-multiple-items" not in prop_schema:
|
||||
prop_schema["enum"] = list(raw["choices"])
|
||||
properties[name] = prop_schema
|
||||
if raw.get("required"):
|
||||
@@ -242,13 +288,41 @@ def _data_schema(cmd_spec: dict[str, Any]) -> dict[str, Any]:
|
||||
if inner.get("type") == "array" and isinstance(inner.get("items"), dict):
|
||||
inner = inner["items"]
|
||||
if isinstance(inner.get("oneOf"), list):
|
||||
merged = _merge_object_variants(inner)
|
||||
concrete = [c for c in inner["oneOf"] if isinstance(c, dict)]
|
||||
if concrete:
|
||||
if merged is not None:
|
||||
inner = merged
|
||||
elif concrete:
|
||||
inner = concrete[0]
|
||||
return inner
|
||||
return _unwrap_schema_envelopes(response)
|
||||
|
||||
|
||||
def _with_body_field(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Give a schema-less row a ``value`` field for the raw body.
|
||||
|
||||
A text or PDF response has no row schema; the fetcher puts the body in
|
||||
``value``. Declaring it keeps the row from being a field-less model, which
|
||||
the platform's ``to_df`` cannot tabulate.
|
||||
"""
|
||||
structured = (
|
||||
schema.get("properties")
|
||||
or schema.get("additionalProperties")
|
||||
or any(schema.get(k) for k in ("anyOf", "oneOf", "allOf", "items"))
|
||||
or schema.get("type") not in (None, "object")
|
||||
)
|
||||
if structured:
|
||||
return schema
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {
|
||||
"description": "Response body; the API defines no response schema."
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _unwrap_schema_envelopes(schema: Any) -> dict[str, Any]:
|
||||
"""Strip schema envelopes that ``unpack_response`` strips at runtime."""
|
||||
if not isinstance(schema, dict):
|
||||
@@ -256,20 +330,134 @@ def _unwrap_schema_envelopes(schema: Any) -> dict[str, Any]:
|
||||
typed: dict[str, Any] = schema
|
||||
if typed.get("type") == "array":
|
||||
return _unwrap_schema_envelopes(typed.get("items"))
|
||||
choice_rows = _wrapper_choice_rows(typed)
|
||||
if choice_rows is not None:
|
||||
return _final_row_schema(choice_rows)
|
||||
merged = _merge_object_variants(typed)
|
||||
if merged is not None:
|
||||
return _unwrap_schema_envelopes(merged)
|
||||
combinator_target = _first_non_null_combinator_variant(typed)
|
||||
if combinator_target is not None:
|
||||
return _unwrap_schema_envelopes(combinator_target)
|
||||
if _is_scalar_schema(typed):
|
||||
return _wrap_scalar_as_value(typed)
|
||||
return _unwrap_object_schema(typed)
|
||||
|
||||
|
||||
def _unwrap_object_schema(typed: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Resolve the row schema for an object schema that has ``properties``."""
|
||||
props = typed.get("properties")
|
||||
if not isinstance(props, dict) or not props:
|
||||
return typed
|
||||
if _response_shape(typed)[1] in ("list", "map"):
|
||||
# The chosen array's items (or a map's entries) *are* the rows: never
|
||||
# look for another envelope inside a row (an alert with one array of
|
||||
# records among its fields is still an alert, not that array).
|
||||
row = _envelope_inner_schema(typed, props)
|
||||
return typed if row is None else _final_row_schema(row)
|
||||
inner = _envelope_inner_schema(typed, props)
|
||||
if inner is not None:
|
||||
return _unwrap_schema_envelopes(inner)
|
||||
return typed
|
||||
|
||||
|
||||
def _merge_prop_schemas(a: dict[str, Any], b: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Combine one property's schema from two variants of a union."""
|
||||
if a == b:
|
||||
return a
|
||||
if a.get("type") == "array" and b.get("type") == "array":
|
||||
ia, ib = a.get("items"), b.get("items")
|
||||
if isinstance(ia, dict) and isinstance(ib, dict):
|
||||
return {**a, "items": _merge_prop_schemas(ia, ib)}
|
||||
if (
|
||||
isinstance(a.get("enum"), list)
|
||||
and isinstance(b.get("enum"), list)
|
||||
and a.get("type") == b.get("type")
|
||||
):
|
||||
merged = {
|
||||
**a,
|
||||
"enum": [*a["enum"], *[e for e in b["enum"] if e not in a["enum"]]],
|
||||
}
|
||||
return merged
|
||||
|
||||
# An enum in one variant and the plain type in another: the plain type admits both.
|
||||
def strip(s):
|
||||
return {
|
||||
k: v for k, v in s.items() if k not in ("enum", "example", "description")
|
||||
}
|
||||
|
||||
if strip(a) == strip(b):
|
||||
return {k: v for k, v in a.items() if k != "enum"}
|
||||
variants: list[dict[str, Any]] = []
|
||||
for s in (a, b):
|
||||
for v in s.get("anyOf") or [s]:
|
||||
if v not in variants:
|
||||
variants.append(v)
|
||||
return {"anyOf": variants}
|
||||
|
||||
|
||||
def _wrapper_choice_rows(schema: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Row schema of a ``oneOf`` whose variants each wrap one container.
|
||||
|
||||
``{si: {...}}`` | ``{wi: {...}}`` | ``{fs: {...}}`` means "the response is
|
||||
whichever of these keys is present", so each variant's single property is
|
||||
unwrapped like any envelope and the rows are the union of what they yield.
|
||||
Only containers (arrays or objects) count; a one-field *row* is not a wrapper.
|
||||
"""
|
||||
for comb in ("oneOf", "anyOf"):
|
||||
variants = schema.get(comb)
|
||||
if not isinstance(variants, list) or len(variants) < 2:
|
||||
continue
|
||||
rows: list[dict[str, Any]] = []
|
||||
for v in variants:
|
||||
props = v.get("properties") if isinstance(v, dict) else None
|
||||
if not isinstance(props, dict) or len(props) != 1:
|
||||
break
|
||||
only = next(iter(props.values()))
|
||||
if not (
|
||||
isinstance(only, dict)
|
||||
and (_is_array_schema(only) or only.get("properties"))
|
||||
):
|
||||
break
|
||||
rows.append(_unwrap_schema_envelopes(only))
|
||||
else:
|
||||
return {"anyOf": rows}
|
||||
return None
|
||||
|
||||
|
||||
def _merge_object_variants(schema: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Merge a ``oneOf`` / ``anyOf`` of object shapes into one object schema.
|
||||
|
||||
The union means "any of these shapes", so the row type carries every
|
||||
variant's fields; a field is required only if all variants require it, and
|
||||
a property declared differently across variants takes the union of them.
|
||||
Returns ``None`` unless the union is made only of two or more objects.
|
||||
"""
|
||||
for comb in ("oneOf", "anyOf"):
|
||||
variants = schema.get(comb)
|
||||
if not isinstance(variants, list):
|
||||
continue
|
||||
objs = [v for v in variants if isinstance(v, dict) and v.get("type") != "null"]
|
||||
if len(objs) < 2 or not all(v.get("properties") for v in objs):
|
||||
continue
|
||||
props: dict[str, Any] = {}
|
||||
for v in objs:
|
||||
for name, prop in v["properties"].items():
|
||||
props[name] = (
|
||||
_merge_prop_schemas(props[name], prop)
|
||||
if name in props
|
||||
and isinstance(prop, dict)
|
||||
and isinstance(props[name], dict)
|
||||
else props.get(name, prop)
|
||||
)
|
||||
required = set.intersection(*(set(v.get("required") or []) for v in objs))
|
||||
out: dict[str, Any] = {"type": "object", "properties": props}
|
||||
if required:
|
||||
out["required"] = sorted(required)
|
||||
return out
|
||||
return None
|
||||
|
||||
|
||||
def _first_non_null_combinator_variant(schema: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Return the first non-null variant of a top-level ``oneOf`` / ``anyOf``."""
|
||||
for combinator in ("oneOf", "anyOf"):
|
||||
@@ -282,36 +470,160 @@ def _first_non_null_combinator_variant(schema: dict[str, Any]) -> dict[str, Any]
|
||||
return None
|
||||
|
||||
|
||||
_LD_KEYS = frozenset({"@context", "@id", "@type", "@vocab", "@version"})
|
||||
|
||||
|
||||
def _is_array_schema(schema: Any) -> bool:
|
||||
"""Whether a property schema describes an array (directly or via a union)."""
|
||||
if not isinstance(schema, dict):
|
||||
return False
|
||||
if schema.get("type") == "array" or "items" in schema:
|
||||
return True
|
||||
return any(
|
||||
_is_array_schema(v)
|
||||
for comb in ("anyOf", "oneOf")
|
||||
for v in schema.get(comb) or []
|
||||
)
|
||||
|
||||
|
||||
def _array_items(schema: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Return the ``items`` schema of an array property (unwrapping unions)."""
|
||||
if isinstance(schema.get("items"), dict):
|
||||
return cast(dict[str, Any], schema["items"])
|
||||
for comb in ("anyOf", "oneOf"):
|
||||
for variant in schema.get(comb) or []:
|
||||
if _is_array_schema(variant):
|
||||
return _array_items(variant)
|
||||
return None
|
||||
|
||||
|
||||
def _is_map_schema(schema: Any) -> bool:
|
||||
"""Whether a property is a free-form ``{key: value}`` map."""
|
||||
return (
|
||||
isinstance(schema, dict)
|
||||
and schema.get("type") == "object"
|
||||
and not schema.get("properties")
|
||||
and bool(schema.get("additionalProperties"))
|
||||
)
|
||||
|
||||
|
||||
def _has_record_items(schema: dict[str, Any]) -> bool:
|
||||
"""Whether an array property's items are objects (records), per the schema."""
|
||||
items = _array_items(schema)
|
||||
return isinstance(items, dict) and not _is_scalar_schema(items)
|
||||
|
||||
|
||||
def _response_shape(response: Any) -> tuple[str | None, str | None]:
|
||||
"""Decide how a response object maps to rows, from the response schema alone.
|
||||
|
||||
Returns ``(key, kind)``: ``"list"`` when one property is the data array (the
|
||||
only array, or the only one whose items are objects; every other property
|
||||
is metadata), ``"map"`` when the only property is a free-form
|
||||
``{key: value}`` map (one row per entry), ``"single"`` when the object is
|
||||
itself the row, else ``(None, None)``. The data model and the generated
|
||||
``unpack_response`` call both use this, so they cannot disagree.
|
||||
"""
|
||||
if not isinstance(response, dict) or response.get("type") == "array":
|
||||
return None, None
|
||||
props = response.get("properties")
|
||||
if not isinstance(props, dict) or not props or "results" in props:
|
||||
return None, None
|
||||
if _first_non_null_combinator_variant(response) is not None:
|
||||
return None, None
|
||||
eff = {k: v for k, v in props.items() if k not in _LD_KEYS}
|
||||
arrays = [k for k, v in eff.items() if _is_array_schema(v)]
|
||||
if len(eff) > 1:
|
||||
# Beside other fields an array of scalars is a field of the row
|
||||
# (``securityTypes``), not the rows; it only is the rows when it stands alone.
|
||||
arrays = [k for k in arrays if _has_record_items(eff[k])]
|
||||
if len(arrays) > 1:
|
||||
# e.g. ``@graph`` (station objects) beside ``observationStations`` (the
|
||||
# same stations as URL strings): the schema's item types say which.
|
||||
arrays = [k for k in arrays if _has_record_items(eff[k])]
|
||||
if len(arrays) == 1:
|
||||
return arrays[0], "list"
|
||||
if len(eff) == 1 and _is_map_schema(next(iter(eff.values()))):
|
||||
return next(iter(eff)), "map"
|
||||
if _single_prop_inner_schema(props) is not None:
|
||||
return None, None # a one-property wrapper object: unwrapped by the model too
|
||||
return None, "single"
|
||||
|
||||
|
||||
def _envelope_inner_schema(
|
||||
typed: dict[str, Any], # noqa: ARG001
|
||||
props: dict[str, Any],
|
||||
typed: dict[str, Any],
|
||||
props: dict[str, Any], # noqa: ARG001
|
||||
) -> dict[str, Any] | None:
|
||||
"""Resolve the inner row schema when ``typed`` is a recognized envelope."""
|
||||
key, kind = _response_shape(typed)
|
||||
if key is None or kind is None:
|
||||
return _single_prop_inner_schema(props)
|
||||
value = props[key]
|
||||
if kind == "map":
|
||||
extra = value.get("additionalProperties")
|
||||
extra = extra if isinstance(extra, dict) else {}
|
||||
variants = [
|
||||
v
|
||||
for v in extra.get("anyOf") or [extra]
|
||||
if isinstance(v, dict) and v.get("type") != "null"
|
||||
]
|
||||
inner = variants[0] if variants else {}
|
||||
row_props: dict[str, Any] = {"key": {"type": "string"}}
|
||||
if inner.get("properties"):
|
||||
row_props.update(inner["properties"])
|
||||
else:
|
||||
row_props["value"] = inner
|
||||
return {"type": "object", "properties": row_props}
|
||||
items = _array_items(value)
|
||||
if isinstance(items, dict) and not _is_scalar_schema(items):
|
||||
return items
|
||||
if isinstance(items, dict):
|
||||
return _wrap_scalar_as_value(items)
|
||||
return None
|
||||
|
||||
|
||||
def _single_prop_inner_schema(props: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Inner schema of a one-property envelope that is not an array or map."""
|
||||
if len(props) == 1:
|
||||
only_value = next(iter(props.values()))
|
||||
if isinstance(only_value, dict):
|
||||
only_dict = cast(dict[str, Any], only_value)
|
||||
if only_dict.get("type") == "array":
|
||||
items = only_dict.get("items")
|
||||
return cast(dict[str, Any], items) if isinstance(items, dict) else None
|
||||
if _is_array_schema(only_dict):
|
||||
items = _array_items(only_dict)
|
||||
return items if isinstance(items, dict) else None
|
||||
if (
|
||||
only_dict.get("properties")
|
||||
or only_dict.get("oneOf")
|
||||
or only_dict.get("anyOf")
|
||||
):
|
||||
return only_dict
|
||||
array_keys = [
|
||||
k for k, v in props.items() if isinstance(v, dict) and v.get("type") == "array"
|
||||
]
|
||||
# Only resolve as an envelope if there is exactly one list of all dicts,
|
||||
# or if the list is the only element present.
|
||||
if len(array_keys) == 1:
|
||||
items = props[array_keys[0]].get("items")
|
||||
if isinstance(items, dict) and not _is_scalar_schema(items):
|
||||
return cast(dict[str, Any], items)
|
||||
return None
|
||||
|
||||
|
||||
def _unpack_hint(cmd_spec: dict[str, Any]) -> str:
|
||||
"""Extra ``unpack_response`` arguments derived from the response schema."""
|
||||
key, kind = _response_shape(cmd_spec.get("response_schema"))
|
||||
if kind is None:
|
||||
return ""
|
||||
if kind == "single":
|
||||
return ', kind="single"'
|
||||
return f", rows_key={key!r}, kind={kind!r}"
|
||||
|
||||
|
||||
def _final_row_schema(row: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Resolve the row schema without unwrapping it any further."""
|
||||
if not isinstance(row, dict):
|
||||
return {}
|
||||
if _is_scalar_schema(row):
|
||||
return _wrap_scalar_as_value(row)
|
||||
merged = _merge_object_variants(row)
|
||||
if merged is not None:
|
||||
return merged
|
||||
variant = _first_non_null_combinator_variant(row)
|
||||
if variant is not None:
|
||||
return _final_row_schema(variant)
|
||||
return row
|
||||
|
||||
|
||||
def _is_scalar_schema(schema: dict[str, Any]) -> bool:
|
||||
"""Return True when the schema represents a primitive (string/number/etc.)."""
|
||||
return schema.get("type") in {
|
||||
@@ -379,13 +691,13 @@ def _query_dict_construction(
|
||||
The Python source for the ``query_dict`` assignment.
|
||||
"""
|
||||
body_props = (cmd_spec.get("request_body_schema") or {}).get("properties") or {}
|
||||
excluded = set(path_params) | set(body_props)
|
||||
excluded = {query_field_name(n) for n in (*path_params, *body_props)}
|
||||
range_params = [
|
||||
p
|
||||
for p in (cmd_spec.get("parameters") or [])
|
||||
if isinstance(p, dict) and p.get("_socrata_op") in {"date_min", "date_max"}
|
||||
]
|
||||
excluded.update(p["name"] for p in range_params)
|
||||
excluded.update(query_field_name(p["name"]) for p in range_params)
|
||||
if excluded:
|
||||
excluded_literal = (
|
||||
"{" + ", ".join(repr(name) for name in sorted(excluded)) + "}"
|
||||
@@ -422,6 +734,15 @@ def _query_dict_construction(
|
||||
lines.append(
|
||||
f" _query_dict.setdefault('$order', {date_column!r} + ' DESC')"
|
||||
)
|
||||
for param in filter_user_params(cmd_spec.get("parameters") or []):
|
||||
if (
|
||||
isinstance(param, dict)
|
||||
and param.get("is_list")
|
||||
and param.get("in", "query") == "query"
|
||||
):
|
||||
lines.extend(
|
||||
_list_param_lines(param["name"], param.get("list_style") or "multi")
|
||||
)
|
||||
rename_pairs: list[tuple[str, str]] = []
|
||||
for param in cmd_spec.get("parameters") or []:
|
||||
if not isinstance(param, dict):
|
||||
@@ -439,6 +760,28 @@ def _query_dict_construction(
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
_LIST_DELIMITERS = {"csv": ",", "space": " ", "pipe": "|"}
|
||||
|
||||
|
||||
def _list_param_lines(wire_name: str, style: str) -> list[str]:
|
||||
"""Emit lines turning the model's comma-joined string into the wire format.
|
||||
|
||||
``multi`` becomes a list (``get_querystring`` repeats the key); the other
|
||||
styles are re-joined with the delimiter the upstream API expects.
|
||||
"""
|
||||
key = repr(wire_name)
|
||||
lines = [
|
||||
f" if isinstance(_query_dict.get({key}), str):",
|
||||
f" _items = [i.strip() for i in _query_dict[{key}].split(',') if i.strip()]",
|
||||
]
|
||||
if style == "multi":
|
||||
lines.append(f" _query_dict[{key}] = _items")
|
||||
else:
|
||||
delimiter = _LIST_DELIMITERS.get(style, ",")
|
||||
lines.append(f" _query_dict[{key}] = {delimiter!r}.join(_items)")
|
||||
return lines
|
||||
|
||||
|
||||
def _socrata_where_clause_lines(range_params: list[dict[str, Any]]) -> list[str]:
|
||||
"""Compose runtime lines that fold Socrata range params into ``$where``."""
|
||||
field_lookups: list[str] = []
|
||||
@@ -446,7 +789,7 @@ def _socrata_where_clause_lines(range_params: list[dict[str, Any]]) -> list[str]
|
||||
wire_name = param["name"]
|
||||
column = param["_socrata_column"]
|
||||
op = ">=" if param["_socrata_op"] == "date_min" else "<="
|
||||
py_field, _ = safe_field_name(wire_name)
|
||||
py_field = query_field_name(wire_name)
|
||||
var = f"_range_{py_field}"
|
||||
field_lookups.append(
|
||||
f" {var} = getattr(query, {py_field!r}, None)\n"
|
||||
@@ -467,7 +810,9 @@ def _socrata_where_clause_lines(range_params: list[dict[str, Any]]) -> list[str]
|
||||
return out
|
||||
|
||||
|
||||
def _header_dict_construction(creds: dict[str, dict[str, str]]) -> str:
|
||||
def _header_dict_construction(
|
||||
creds: dict[str, dict[str, str]], fixed: dict[str, str] | None = None
|
||||
) -> str:
|
||||
"""Compose the ``_headers = {...}`` block for credential headers.
|
||||
|
||||
Parameters
|
||||
@@ -480,7 +825,7 @@ def _header_dict_construction(creds: dict[str, dict[str, str]]) -> str:
|
||||
str
|
||||
Python source for the headers dict.
|
||||
"""
|
||||
lines = [" _headers: dict[str, str] = {}"]
|
||||
lines = [f" _headers: dict[str, str] = {dict(fixed or {})!r}"]
|
||||
for canonical, info in creds.items():
|
||||
if info["in"] != "header":
|
||||
continue
|
||||
@@ -547,7 +892,7 @@ def generate_fetcher_module(spec: FetcherCommandSpec) -> GeneratedFetcher:
|
||||
fetcher_class = f"{model_name}Fetcher"
|
||||
|
||||
qp_schema = _query_params_schema(spec.cmd_spec, spec.provider_name)
|
||||
data_schema = _data_schema(spec.cmd_spec) or {"type": "object"}
|
||||
data_schema = _with_body_field(_data_schema(spec.cmd_spec) or {"type": "object"})
|
||||
|
||||
qp_generated = generate_class(
|
||||
qp_schema,
|
||||
@@ -556,7 +901,7 @@ def generate_fetcher_module(spec: FetcherCommandSpec) -> GeneratedFetcher:
|
||||
docstring=f"Query parameters for {spec.name}.",
|
||||
)
|
||||
data_generated = generate_class(
|
||||
data_schema,
|
||||
_mark_null_empty(data_schema),
|
||||
class_name=data_class,
|
||||
base_class="Data",
|
||||
docstring=f"Response row for {spec.name}.",
|
||||
@@ -566,6 +911,7 @@ def generate_fetcher_module(spec: FetcherCommandSpec) -> GeneratedFetcher:
|
||||
path_params = _path_template_keys(spec.cmd_spec.get("url_path") or "")
|
||||
|
||||
imports = {
|
||||
"import base64",
|
||||
"from typing import Any",
|
||||
"from openbb_core.app.model.abstract.error import OpenBBError",
|
||||
"from openbb_core.provider.abstract.annotated_result import AnnotatedResult",
|
||||
@@ -591,7 +937,7 @@ def generate_fetcher_module(spec: FetcherCommandSpec) -> GeneratedFetcher:
|
||||
query_block = _query_dict_construction(
|
||||
spec.cmd_spec, creds, path_params, spec.provider_name
|
||||
)
|
||||
header_block = _header_dict_construction(creds)
|
||||
header_block = _header_dict_construction(creds, spec.headers)
|
||||
cookie_block = _cookie_dict_construction(creds)
|
||||
|
||||
if _command_is_streaming(spec.cmd_spec):
|
||||
@@ -621,6 +967,7 @@ def generate_fetcher_module(spec: FetcherCommandSpec) -> GeneratedFetcher:
|
||||
is_streaming=True,
|
||||
)
|
||||
|
||||
hint = _unpack_hint(spec.cmd_spec)
|
||||
source = _render_fetcher_source(
|
||||
spec=spec,
|
||||
qp_class=qp_class,
|
||||
@@ -642,6 +989,10 @@ def generate_fetcher_module(spec: FetcherCommandSpec) -> GeneratedFetcher:
|
||||
imports=consolidate_imports(imports),
|
||||
column_metadata=_column_metadata_from_data_schema(data_schema),
|
||||
)
|
||||
if hint:
|
||||
source = source.replace(
|
||||
"unpack_response(_payload)", f"unpack_response(_payload{hint})"
|
||||
)
|
||||
|
||||
return GeneratedFetcher(
|
||||
module_name=module_name,
|
||||
@@ -1065,7 +1416,9 @@ def _aextract_body(
|
||||
"""
|
||||
lines: list[str] = list(cred_lines)
|
||||
if has_path_params:
|
||||
sub_args = ", ".join(f"{p}=getattr(query, {p!r})" for p in path_params)
|
||||
sub_args = ", ".join(
|
||||
f"{p}=getattr(query, {query_field_name(p)!r})" for p in path_params
|
||||
)
|
||||
lines.append(f" _path = {url_path_template!r}.format({sub_args})")
|
||||
else:
|
||||
lines.append(f" _path = {url_path_template!r}")
|
||||
@@ -1084,7 +1437,7 @@ def _aextract_body(
|
||||
body_literal = (
|
||||
"{"
|
||||
+ ", ".join(
|
||||
f"{name!r}: getattr(query, {safe_field_name(name)[0]!r}, None)"
|
||||
f"{name!r}: getattr(query, {query_field_name(name)!r}, None)"
|
||||
for name in body_field_names
|
||||
)
|
||||
+ "}"
|
||||
@@ -1103,7 +1456,20 @@ def _aextract_body(
|
||||
lines.append(
|
||||
" _ct = (_resp.headers.get('Content-Type') or '').lower()"
|
||||
)
|
||||
lines.append(" _text = await _resp.text()")
|
||||
lines.append(" _raw = await _resp.read()")
|
||||
lines.append(
|
||||
" _textual = not _ct or any("
|
||||
"_t in _ct for _t in ('json', 'text', 'xml', 'csv', 'javascript', 'yaml'))"
|
||||
)
|
||||
lines.append(" if _resp.status < 400 and not _textual:")
|
||||
lines.append(
|
||||
" return {'rows': [{'value': "
|
||||
"base64.b64encode(_raw).decode('ascii')}], "
|
||||
"'metadata': {'content_type': _ct, 'encoding': 'base64'}}"
|
||||
)
|
||||
lines.append(
|
||||
" _text = _raw.decode(_resp.get_encoding() or 'utf-8', errors='replace')"
|
||||
)
|
||||
lines.append(" if _resp.status >= 400:")
|
||||
lines.append(
|
||||
" raise OpenBBError("
|
||||
@@ -1121,8 +1487,8 @@ def _aextract_body(
|
||||
)
|
||||
lines.append(" else:")
|
||||
lines.append(
|
||||
" return {'rows': [{'content': _text, "
|
||||
"'content_type': _ct}], 'metadata': {}}"
|
||||
" return {'rows': [{'value': _text}], "
|
||||
"'metadata': {'content_type': _ct}}"
|
||||
)
|
||||
lines.append(" _rows, _metadata = unpack_response(_payload)")
|
||||
lines.append(" return {'rows': _rows, 'metadata': _metadata}")
|
||||
|
||||
@@ -349,13 +349,66 @@ def _build_root_router(
|
||||
if commands:
|
||||
parts.append("")
|
||||
parts.extend(commands)
|
||||
# The root router's file must not share a name with a namespace router: a
|
||||
# spec with a top-level ``congress`` namespace under a provider named
|
||||
# ``congress`` would otherwise overwrite it and the root would import itself.
|
||||
taken = {r.module_name.lower() for r in sub_routers}
|
||||
module_name = root_namespace
|
||||
while module_name.lower() in taken:
|
||||
module_name = f"{module_name}_root"
|
||||
return GeneratedRouter(
|
||||
module_name=root_namespace,
|
||||
module_name=module_name,
|
||||
entry_point_name=root_namespace,
|
||||
source="\n".join(parts).rstrip() + "\n",
|
||||
)
|
||||
|
||||
|
||||
def _assert_unique_router_modules(routers: list[GeneratedRouter]) -> None:
|
||||
"""Refuse to emit routers that would overwrite each other's files.
|
||||
|
||||
Every router is one module; two sharing a name (case-insensitively, for
|
||||
case-folding filesystems) means one silently replaces the other and the
|
||||
survivor can import itself. Router modules only ever import their own
|
||||
children, so unique names are what keeps the import graph acyclic.
|
||||
"""
|
||||
seen: dict[str, str] = {}
|
||||
for r in routers:
|
||||
key = r.module_name.lower()
|
||||
if key in seen:
|
||||
raise ValueError(
|
||||
f"Router module name collision: {r.module_name!r} and "
|
||||
f"{seen[key]!r} would both be written to routers/{key}.py. "
|
||||
"Rename one namespace in the spec, or use --router-name."
|
||||
)
|
||||
seen[key] = r.module_name
|
||||
_assert_acyclic(routers)
|
||||
|
||||
|
||||
def _assert_acyclic(routers: list[GeneratedRouter]) -> None:
|
||||
"""Raise if the routers' ``from ...routers.<x> import`` graph has a cycle."""
|
||||
import re
|
||||
|
||||
edges = {
|
||||
r.module_name: set(re.findall(r"\.routers\.(\w+) import", r.source))
|
||||
for r in routers
|
||||
}
|
||||
state: dict[str, int] = {}
|
||||
|
||||
def visit(node: str, path: list[str]) -> None:
|
||||
if state.get(node) == 2:
|
||||
return
|
||||
if state.get(node) == 1:
|
||||
cycle = " -> ".join([*path[path.index(node) :], node])
|
||||
raise ValueError(f"Circular router import in generated package: {cycle}")
|
||||
state[node] = 1
|
||||
for nxt in sorted(edges.get(node, ())):
|
||||
visit(nxt, [*path, node])
|
||||
state[node] = 2
|
||||
|
||||
for name in edges:
|
||||
visit(name, [])
|
||||
|
||||
|
||||
_SKIP_TOP_NAMESPACES: frozenset[str] = frozenset({"coverage"})
|
||||
|
||||
|
||||
@@ -412,6 +465,7 @@ def generate_packages(
|
||||
"""
|
||||
base_url = (spec_doc.get("base_url") or "").rstrip("/")
|
||||
api_prefix = spec_doc.get("api_prefix") or ""
|
||||
fixed_headers = {str(k): str(v) for k, v in (spec_doc.get("headers") or {}).items()}
|
||||
filtered_commands = {
|
||||
name: cmd
|
||||
for name, cmd in (spec_doc.get("commands") or {}).items()
|
||||
@@ -458,6 +512,7 @@ def generate_packages(
|
||||
base_url=base_url,
|
||||
api_prefix=api_prefix,
|
||||
provider_name="tools",
|
||||
headers=fixed_headers,
|
||||
)
|
||||
)
|
||||
post_commands.append(post)
|
||||
@@ -480,6 +535,7 @@ def generate_packages(
|
||||
base_url=base_url,
|
||||
api_prefix=api_prefix,
|
||||
provider_name=prov,
|
||||
headers=fixed_headers,
|
||||
)
|
||||
)
|
||||
fetchers_by_provider.setdefault(prov, []).append(fetcher)
|
||||
@@ -522,6 +578,7 @@ def generate_packages(
|
||||
root_has_stream=routers.root_has_stream,
|
||||
)
|
||||
routers.routers.append(root_router)
|
||||
_assert_unique_router_modules(routers.routers)
|
||||
|
||||
providers: list[GeneratedProvider] = []
|
||||
for prov in target_providers:
|
||||
|
||||
@@ -42,6 +42,7 @@ class PostCommandSpec:
|
||||
base_url: str
|
||||
api_prefix: str
|
||||
provider_name: str
|
||||
headers: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -539,6 +540,7 @@ def generate_post_command_module(spec: PostCommandSpec) -> GeneratedPostCommand:
|
||||
cred_lines=cred_lines,
|
||||
data_class=data_class,
|
||||
params=params,
|
||||
fixed_headers=spec.headers,
|
||||
)
|
||||
|
||||
imports = {
|
||||
@@ -628,6 +630,7 @@ def _render_body_block(
|
||||
cred_lines: list[str],
|
||||
data_class: str,
|
||||
params: list[tuple[str, str, str | None, bool, Any]],
|
||||
fixed_headers: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Render the function body for a POST command.
|
||||
|
||||
@@ -651,6 +654,8 @@ def _render_body_block(
|
||||
Name of the response Data class.
|
||||
params : list of tuple
|
||||
Function parameters.
|
||||
fixed_headers : dict, optional
|
||||
Headers sent with every request (from ``--header`` at spec generation).
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -703,7 +708,7 @@ def _render_body_block(
|
||||
f' _url = f"{base_url}{{_path}}" + ("?" + _query_string if _query_string else "")'
|
||||
)
|
||||
|
||||
lines.append(" _headers: dict[str, str] = {}")
|
||||
lines.append(f" _headers: dict[str, str] = {dict(fixed_headers or {})!r}")
|
||||
for canonical, info in creds.items():
|
||||
if info["in"] != "header":
|
||||
continue
|
||||
|
||||
@@ -69,6 +69,20 @@ def safe_field_name(name: str) -> tuple[str, bool]:
|
||||
return (cleaned, cleaned != name)
|
||||
|
||||
|
||||
def query_field_name(name: str) -> str:
|
||||
"""Snake-case attribute name for a query parameter.
|
||||
|
||||
The platform exposes every query parameter in snake_case (``zoneId`` is
|
||||
passed as ``zone_id``); the original name is kept as the field alias so
|
||||
the upstream wire name is unchanged.
|
||||
"""
|
||||
safe, _ = safe_field_name(name)
|
||||
snake = re.sub(r"(?<=[a-z0-9])([A-Z])", r"_\1", safe)
|
||||
snake = re.sub(r"(?<=[A-Z])([A-Z][a-z])", r"_\1", snake)
|
||||
snake = re.sub(r"_+", "_", snake).lower().strip("_") or "field"
|
||||
return f"{snake}_" if keyword.iskeyword(snake) else snake
|
||||
|
||||
|
||||
def class_name_from(*parts: str) -> str:
|
||||
"""Join + camel-case a set of name parts into a Python class identifier."""
|
||||
words: list[str] = []
|
||||
@@ -185,12 +199,20 @@ def _resolve_union(
|
||||
return "Any"
|
||||
|
||||
rendered: list[str] = []
|
||||
for variant in non_null:
|
||||
for index, variant in enumerate(non_null, start=1):
|
||||
# Untitled object variants would all get the same class name and
|
||||
# collapse into one; number them so every shape stays distinct.
|
||||
distinct = (
|
||||
isinstance(variant, dict)
|
||||
and len(non_null) > 1
|
||||
and (variant.get("properties") or variant.get("type") == "object")
|
||||
and not variant.get("title")
|
||||
)
|
||||
rendered.append(
|
||||
schema_to_type(
|
||||
variant,
|
||||
parent_class_name=parent_class_name,
|
||||
field_name=field_name,
|
||||
field_name=f"{field_name}_v{index}" if distinct else field_name,
|
||||
nested_classes=nested_classes,
|
||||
imports=imports,
|
||||
)
|
||||
@@ -314,6 +336,7 @@ def _build_field_call(
|
||||
def _resolve_field_description(
|
||||
prop: dict[str, Any],
|
||||
field_name: str | None = None, # noqa: ARG001
|
||||
include_choices: bool = True,
|
||||
) -> str:
|
||||
"""Pick the description text for a Pydantic Field."""
|
||||
text = prop.get("description") or prop.get("title")
|
||||
@@ -327,7 +350,7 @@ def _resolve_field_description(
|
||||
if isinstance(prop.get("const"), (str, int, float, bool))
|
||||
else None
|
||||
)
|
||||
if enum:
|
||||
if enum and include_choices:
|
||||
rendered = ", ".join(str(v) for v in enum)
|
||||
parts.append(f"Choices: {rendered}.")
|
||||
return " ".join(parts).rstrip()
|
||||
@@ -557,9 +580,13 @@ def generate_class(
|
||||
body_lines: list[str] = []
|
||||
doc_fields: list[tuple[str, str, str | None, bool, Any, bool]] = []
|
||||
optional_object_fields: list[str] = []
|
||||
multiple_items: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for raw_name, prop_schema in properties.items():
|
||||
safe_name, needs_alias = safe_field_name(raw_name)
|
||||
if base_class == "QueryParams":
|
||||
safe_name = query_field_name(raw_name)
|
||||
needs_alias = safe_name != raw_name
|
||||
annotation = schema_to_type(
|
||||
prop_schema if isinstance(prop_schema, dict) else {},
|
||||
parent_class_name=class_name,
|
||||
@@ -569,12 +596,22 @@ def generate_class(
|
||||
)
|
||||
is_required = raw_name in required_set
|
||||
prop = prop_schema if isinstance(prop_schema, dict) else {}
|
||||
if prop.get("x-multiple-items"):
|
||||
extra: dict[str, Any] = {"multiple_items_allowed": True}
|
||||
if prop.get("x-choices"):
|
||||
extra["choices"] = list(prop["x-choices"])
|
||||
multiple_items[safe_name] = extra
|
||||
if not is_required and (prop.get("type") == "object" or "properties" in prop):
|
||||
optional_object_fields.append(safe_name)
|
||||
has_default = "default" in prop
|
||||
if not is_required and "None" not in annotation:
|
||||
nullable = bool(schema.get("x-null-empty")) # response rows: null == empty
|
||||
if (not is_required or nullable) and "None" not in annotation:
|
||||
annotation = f"{annotation} | None"
|
||||
description = _resolve_field_description(prop, field_name=raw_name)
|
||||
# The platform lists a query parameter's choices itself ("Choices for
|
||||
# <provider>: ..."); repeating them in the description duplicates them.
|
||||
description = _resolve_field_description(
|
||||
prop, field_name=raw_name, include_choices=base_class != "QueryParams"
|
||||
)
|
||||
breakout = _nested_object_breakout(prop)
|
||||
if breakout:
|
||||
description = (
|
||||
@@ -622,6 +659,34 @@ def generate_class(
|
||||
" return None if v == [] else v"
|
||||
)
|
||||
|
||||
if multiple_items:
|
||||
imports.add("from pydantic import field_validator")
|
||||
imports.add("from typing import Any")
|
||||
names = ", ".join(repr(n) for n in multiple_items)
|
||||
body_lines.append(f"\n __json_schema_extra__ = {multiple_items!r}")
|
||||
body_lines.append(
|
||||
f'\n @field_validator({names}, mode="before")\n'
|
||||
" @classmethod\n"
|
||||
" def _join_multiple_items(cls, v: Any) -> Any:\n"
|
||||
' """Accept a list and join it into a comma-separated string."""\n'
|
||||
" if isinstance(v, (list, tuple, set)):\n"
|
||||
' return ",".join(str(i) for i in v)\n'
|
||||
" return v"
|
||||
)
|
||||
|
||||
if schema.get("x-null-empty"):
|
||||
imports.add("from pydantic import model_validator")
|
||||
imports.add("from typing import Any")
|
||||
body_lines.append(
|
||||
'\n @model_validator(mode="before")\n'
|
||||
" @classmethod\n"
|
||||
" def _empty_string_is_null(cls, values: Any) -> Any:\n"
|
||||
' """Treat empty strings as null."""\n'
|
||||
" if isinstance(values, dict):\n"
|
||||
' return {k: (None if v == "" else v) for k, v in values.items()}\n'
|
||||
" return values"
|
||||
)
|
||||
|
||||
summary = docstring or class_name
|
||||
doc_block = _render_class_docstring(summary, doc_fields)
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ def generate_routers(
|
||||
collected=out,
|
||||
is_top_level=True,
|
||||
)
|
||||
if top_node.cmd_spec is None:
|
||||
if top_node.cmd_spec is None or top_node.is_namespace:
|
||||
continue
|
||||
function_name = _safe_segment(top_name)
|
||||
description = (top_node.cmd_spec.get("description") or "").strip()
|
||||
@@ -156,6 +156,23 @@ def generate_routers(
|
||||
return out
|
||||
|
||||
|
||||
def _own_commands(node: NamespaceNode) -> list[tuple[str, NamespaceNode]]:
|
||||
"""Return ``(function_name, node)`` for every command a router module hosts.
|
||||
|
||||
Leaf children mount on their parent's router. A node that is both a
|
||||
command and a namespace (e.g. ``/alerts`` and ``/alerts/{id}`` alongside
|
||||
``/alerts/active``) mounts its command on its *own* router, named after
|
||||
itself; mounting it on the parent would shadow the namespace.
|
||||
"""
|
||||
out: list[tuple[str, NamespaceNode]] = []
|
||||
if node.is_command and node.is_namespace:
|
||||
out.append((_safe_segment(node.name), node))
|
||||
for child_name, child in sorted(node.children.items()):
|
||||
if child.is_command and not child.is_namespace:
|
||||
out.append((_safe_segment(child_name), child))
|
||||
return out
|
||||
|
||||
|
||||
def _emit_router(
|
||||
node: NamespaceNode,
|
||||
*,
|
||||
@@ -206,8 +223,7 @@ def _emit_router(
|
||||
has_stream = any(
|
||||
(fetcher := fetchers_by_command.get(child.full_path)) is not None
|
||||
and fetcher.is_streaming
|
||||
for child in node.children.values()
|
||||
if child.is_command and child.cmd_spec is not None
|
||||
for _, child in _own_commands(node)
|
||||
)
|
||||
if has_stream:
|
||||
parts.append("from openbb_core.app.model.stream import OBBStream")
|
||||
@@ -228,15 +244,13 @@ def _emit_router(
|
||||
sub_imports.append((sub_module, _safe_segment(sub_name), sub_node.full_path))
|
||||
|
||||
post_imports: list[tuple[str, str]] = []
|
||||
for child in node.children.values():
|
||||
if child.is_command and child.cmd_spec is not None:
|
||||
post = post_commands_by_command.get(child.full_path)
|
||||
if post is not None:
|
||||
post_module = (
|
||||
f"{package_name}.providers.{provider_name}.models."
|
||||
f"{post.module_name}"
|
||||
)
|
||||
post_imports.append((post_module, post.function_name))
|
||||
for _, child in _own_commands(node):
|
||||
post = post_commands_by_command.get(child.full_path)
|
||||
if post is not None:
|
||||
post_module = (
|
||||
f"{package_name}.providers.{provider_name}.models.{post.module_name}"
|
||||
)
|
||||
post_imports.append((post_module, post.function_name))
|
||||
|
||||
if post_imports:
|
||||
parts.append("")
|
||||
@@ -265,10 +279,9 @@ def _emit_router(
|
||||
if sub_imports:
|
||||
parts.append("")
|
||||
|
||||
for child_name, child in sorted(node.children.items()):
|
||||
if not child.is_command or child.cmd_spec is None:
|
||||
for function_name, child in _own_commands(node):
|
||||
if child.cmd_spec is None:
|
||||
continue
|
||||
function_name = _safe_segment(child_name)
|
||||
description = (child.cmd_spec.get("description") or "").strip()
|
||||
fetcher = fetchers_by_command.get(child.full_path)
|
||||
if fetcher is not None:
|
||||
|
||||
Reference in New Issue
Block a user