mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-11-07 18:01:10 +00:00
[BugFix] openbb-cli: Add api-prefix and overlay support to spec generation (#7811)
* feat(cli): add api-prefix and overlay support to spec generation Add --api-prefix and --overlay options to the spec-generation command so commands can be named from a URL path prefix and OpenAPI Overlay 1.0 documents can be applied before building the spec. Normalize router segments so a leading digit gets an underscore and a Python keyword gets a trailing one, and add mounted_command_paths to map spec paths to the paths they mount at under obb. * fix: remove local-only entries from .gitignore Remove .claude/ and github_metadata/ patterns that only exist locally and have no business in the shared repository. --------- Co-authored-by: dlee-bq <325002271+dlee-bq@users.noreply.github.com>
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import keyword
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, cast
|
||||
@@ -16,6 +18,7 @@ from openbb_cli.codegen.pydantic_gen import (
|
||||
consolidate_imports,
|
||||
generate_class,
|
||||
query_field_name,
|
||||
query_params_field_names,
|
||||
render_function_docstring,
|
||||
)
|
||||
|
||||
@@ -121,7 +124,8 @@ def _module_name_from_command(name: str) -> str:
|
||||
safe = "command"
|
||||
if safe[0].isdigit():
|
||||
safe = f"_{safe}"
|
||||
return safe.lower()
|
||||
safe = safe.lower()
|
||||
return f"{safe}_" if keyword.iskeyword(safe) else safe
|
||||
|
||||
|
||||
_COORDINATE_NAME_RE = re.compile(r"(^|[^a-z])(lat|lon|lng|latitude|longitude)", re.I)
|
||||
@@ -142,14 +146,14 @@ def _mark_null_empty(node: Any) -> Any:
|
||||
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()
|
||||
}
|
||||
out: dict[str, Any] = {}
|
||||
for k, v in node.items():
|
||||
if k in ("enum", "const", "default", "example", "examples"):
|
||||
out[k] = v
|
||||
elif k in ("properties", "patternProperties") and isinstance(v, dict):
|
||||
out[k] = {name: _mark_null_empty(schema) for name, schema in v.items()}
|
||||
else:
|
||||
out[k] = _mark_null_empty(v)
|
||||
if out.get("type") == "object" or "properties" in out:
|
||||
out["x-null-empty"] = True
|
||||
return out
|
||||
@@ -209,7 +213,7 @@ def _query_params_schema(
|
||||
prop_schema = {"type": "string", "x-multiple-items": True}
|
||||
if description:
|
||||
prop_schema["description"] = description
|
||||
if raw.get("choices"):
|
||||
if raw.get("choices") and raw.get("list_style") != "json":
|
||||
prop_schema["x-choices"] = list(raw["choices"])
|
||||
if raw.get("default") is not None:
|
||||
prop_schema["default"] = raw["default"]
|
||||
@@ -231,10 +235,16 @@ def _query_params_schema(
|
||||
properties[body_name] = body_prop
|
||||
if body_name in body_required:
|
||||
required.append(body_name)
|
||||
required_groups = [
|
||||
group
|
||||
for group in cmd_spec.get("required_groups") or []
|
||||
if all(name in properties for alt in group["alternatives"] for name in alt)
|
||||
]
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
**({"x-required-groups": required_groups} if required_groups else {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -329,6 +339,11 @@ def _unwrap_schema_envelopes(schema: Any) -> dict[str, Any]:
|
||||
return {}
|
||||
typed: dict[str, Any] = schema
|
||||
if typed.get("type") == "array":
|
||||
kind = _response_shape(typed)[1]
|
||||
if kind == "tuples":
|
||||
return _wrap_scalar_as_value(typed["items"])
|
||||
if kind == "records":
|
||||
return _final_row_schema(typed["items"])
|
||||
return _unwrap_schema_envelopes(typed.get("items"))
|
||||
choice_rows = _wrapper_choice_rows(typed)
|
||||
if choice_rows is not None:
|
||||
@@ -520,11 +535,16 @@ def _response_shape(response: Any) -> tuple[str | None, str | None]:
|
||||
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.
|
||||
itself the row, ``"tuples"`` when the response is an array of arrays (each
|
||||
inner array is one positional row), ``"records"`` when it is an array of
|
||||
objects (each object is one 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":
|
||||
if not isinstance(response, dict):
|
||||
return None, None
|
||||
if response.get("type") == "array":
|
||||
return None, _array_row_kind(response.get("items"))
|
||||
props = response.get("properties")
|
||||
if not isinstance(props, dict) or not props or "results" in props:
|
||||
return None, None
|
||||
@@ -549,6 +569,17 @@ def _response_shape(response: Any) -> tuple[str | None, str | None]:
|
||||
return None, "single"
|
||||
|
||||
|
||||
def _array_row_kind(items: Any) -> str | None:
|
||||
"""``"tuples"`` for array items that are arrays, ``"records"`` for objects."""
|
||||
if not isinstance(items, dict):
|
||||
return None
|
||||
if items.get("type") == "array" or "items" in items:
|
||||
return "tuples"
|
||||
if items.get("type") == "object" or "properties" in items:
|
||||
return "records"
|
||||
return None
|
||||
|
||||
|
||||
def _envelope_inner_schema(
|
||||
typed: dict[str, Any],
|
||||
props: dict[str, Any], # noqa: ARG001
|
||||
@@ -599,13 +630,124 @@ def _single_prop_inner_schema(props: dict[str, Any]) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _merged_data_schema(cmd_spec: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Row schema of a command, covering every URL of a merged command.
|
||||
|
||||
The fields of each URL's row schema are combined. A field declared
|
||||
differently by several URLs takes every distinct declaration
|
||||
(``_merge_prop_schemas``) and keeps the first one's docs; declarations
|
||||
that differ only in docs or nullability count as one (``_shape_key``). A
|
||||
field stays required only when every URL's row schema requires it.
|
||||
"""
|
||||
base = _data_schema(cmd_spec)
|
||||
variants = cmd_spec.get("template_response_schemas") or {}
|
||||
if not variants:
|
||||
return base
|
||||
schemas = [base] + [
|
||||
_data_schema({"response_schema": schema}) for schema in variants.values()
|
||||
]
|
||||
declarations: dict[str, list[Any]] = {}
|
||||
for schema in schemas:
|
||||
for name, prop in (schema.get("properties") or {}).items():
|
||||
seen = declarations.setdefault(name, [])
|
||||
if all(_shape_key(prop) != _shape_key(other) for other in seen):
|
||||
seen.append(prop)
|
||||
properties: dict[str, Any] = {}
|
||||
for name, distinct in declarations.items():
|
||||
first = distinct[0]
|
||||
if len(distinct) == 1 or not all(isinstance(d, dict) for d in distinct):
|
||||
properties[name] = first
|
||||
continue
|
||||
merged = first
|
||||
for other in distinct[1:]:
|
||||
merged = _merge_prop_schemas(merged, other)
|
||||
docs = {k: first[k] for k in ("description", "title", "example") if k in first}
|
||||
properties[name] = {**merged, **docs}
|
||||
if not properties:
|
||||
return base
|
||||
required = set.intersection(*(set(s.get("required") or []) for s in schemas))
|
||||
return {
|
||||
**base,
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": [name for name in properties if name in required],
|
||||
}
|
||||
|
||||
|
||||
_DOC_KEYS = frozenset({"description", "example", "examples", "title"})
|
||||
|
||||
|
||||
def _shape(schema: Any) -> Any:
|
||||
"""Return ``schema`` without docs or nullability, at every level.
|
||||
|
||||
Descriptions, examples, titles and ``nullable`` are dropped, and an
|
||||
``anyOf`` / ``oneOf`` of one schema and ``null`` becomes that schema.
|
||||
"""
|
||||
if isinstance(schema, list):
|
||||
return [_shape(v) for v in schema]
|
||||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
out: dict[str, Any] = {}
|
||||
for key, value in schema.items():
|
||||
if key in ("properties", "patternProperties") and isinstance(value, dict):
|
||||
out[key] = {name: _shape(sub) for name, sub in value.items()}
|
||||
elif key not in _DOC_KEYS and key != "nullable":
|
||||
out[key] = _shape(value)
|
||||
for combinator in ("anyOf", "oneOf"):
|
||||
variants = out.get(combinator)
|
||||
if not isinstance(variants, list):
|
||||
continue
|
||||
non_null = [
|
||||
v for v in variants if not (isinstance(v, dict) and v.get("type") == "null")
|
||||
]
|
||||
if len(non_null) == 1 and len(non_null) < len(variants):
|
||||
return non_null[0]
|
||||
return out
|
||||
|
||||
|
||||
def _shape_key(schema: Any) -> str:
|
||||
"""Compare key for a field schema (see ``_shape``)."""
|
||||
return json.dumps(_shape(schema), sort_keys=True, default=str)
|
||||
|
||||
|
||||
def _unpack_kwargs(response_schema: Any) -> dict[str, Any]:
|
||||
"""``unpack_response`` keyword arguments for one response schema."""
|
||||
key, kind = _response_shape(response_schema)
|
||||
if kind is None:
|
||||
return {}
|
||||
if kind in ("single", "tuples", "records"):
|
||||
return {"kind": kind}
|
||||
return {"rows_key": key, "kind": kind}
|
||||
|
||||
|
||||
def _template_unpack_hints(
|
||||
cmd_spec: dict[str, Any], url_choices: UrlChoices | None
|
||||
) -> dict[tuple[str, ...], dict[str, Any]] | None:
|
||||
"""Per-URL ``unpack_response`` hints, keyed by each URL's sorted placeholders.
|
||||
|
||||
``None`` unless the URLs of a merged command declare different response
|
||||
shapes, in which case the generated code picks the hint for the URL it used.
|
||||
"""
|
||||
variants = cmd_spec.get("template_response_schemas") or {}
|
||||
if not url_choices or not variants:
|
||||
return None
|
||||
base = cmd_spec.get("response_schema")
|
||||
hints: dict[tuple[str, ...], dict[str, Any]] = {}
|
||||
for template in cmd_spec.get("url_templates") or []:
|
||||
names = tuple(sorted(_path_template_keys(template)))
|
||||
hints[names] = _unpack_kwargs(variants.get(template, base))
|
||||
if len({repr(h) for h in hints.values()}) < 2:
|
||||
return None
|
||||
return hints
|
||||
|
||||
|
||||
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"'
|
||||
if kind in ("single", "tuples", "records"):
|
||||
return f', kind="{kind}"'
|
||||
return f", rows_key={key!r}, kind={kind!r}"
|
||||
|
||||
|
||||
@@ -635,7 +777,7 @@ def _is_scalar_schema(schema: dict[str, Any]) -> bool:
|
||||
|
||||
|
||||
def _wrap_scalar_as_value(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Promote a scalar schema to ``{type: object, properties: {value: scalar}}``."""
|
||||
"""Promote a schema to ``{type: object, properties: {value: schema}}``."""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {"value": schema},
|
||||
@@ -673,6 +815,7 @@ def _query_dict_construction(
|
||||
creds: dict[str, dict[str, str]],
|
||||
path_params: list[str],
|
||||
provider_name: str,
|
||||
field_names: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Compose the ``query_dict = {...}`` block emitted in ``aextract_data``.
|
||||
|
||||
@@ -691,13 +834,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 = {query_field_name(n) for n in (*path_params, *body_props)}
|
||||
excluded = {_field_name(field_names, 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(query_field_name(p["name"]) for p in range_params)
|
||||
excluded.update(_field_name(field_names, p["name"]) for p in range_params)
|
||||
if excluded:
|
||||
excluded_literal = (
|
||||
"{" + ", ".join(repr(name) for name in sorted(excluded)) + "}"
|
||||
@@ -725,7 +868,7 @@ def _query_dict_construction(
|
||||
lines.append(f" if {var}:")
|
||||
lines.append(f" _query_dict[{wire_name!r}] = {var}")
|
||||
if range_params:
|
||||
lines.extend(_socrata_where_clause_lines(range_params))
|
||||
lines.extend(_socrata_where_clause_lines(range_params, field_names))
|
||||
date_column = next(
|
||||
(p["_socrata_column"] for p in range_params if p.get("_socrata_column")),
|
||||
None,
|
||||
@@ -741,8 +884,23 @@ def _query_dict_construction(
|
||||
and param.get("in", "query") == "query"
|
||||
):
|
||||
lines.extend(
|
||||
_list_param_lines(param["name"], param.get("list_style") or "multi")
|
||||
_list_param_lines(
|
||||
param["name"],
|
||||
param.get("list_style") or "multi",
|
||||
param.get("type") or "string",
|
||||
)
|
||||
)
|
||||
group_members = list(
|
||||
dict.fromkeys(
|
||||
name
|
||||
for group in cmd_spec.get("required_groups") or []
|
||||
for name in group.get("members") or []
|
||||
)
|
||||
)
|
||||
if group_members:
|
||||
lines.append(f" for _name in {tuple(group_members)!r}:")
|
||||
lines.append(' if _query_dict.get(_name) in ("", []):')
|
||||
lines.append(" _query_dict.pop(_name, None)")
|
||||
rename_pairs: list[tuple[str, str]] = []
|
||||
for param in cmd_spec.get("parameters") or []:
|
||||
if not isinstance(param, dict):
|
||||
@@ -763,13 +921,22 @@ def _query_dict_construction(
|
||||
_LIST_DELIMITERS = {"csv": ",", "space": " ", "pipe": "|"}
|
||||
|
||||
|
||||
def _list_param_lines(wire_name: str, style: str) -> list[str]:
|
||||
def _list_param_lines(
|
||||
wire_name: str, style: str, item_type: str = "string"
|
||||
) -> 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.
|
||||
``multi`` becomes a list (``encode_query`` repeats the key), ``json``
|
||||
becomes a JSON array, and the other styles are re-joined with the
|
||||
delimiter the upstream API expects.
|
||||
"""
|
||||
key = repr(wire_name)
|
||||
if style == "json":
|
||||
type_arg = "" if item_type == "string" else f", {item_type!r}"
|
||||
return [
|
||||
f" if isinstance(_query_dict.get({key}), str):",
|
||||
f" _query_dict[{key}] = json_array(_query_dict[{key}]{type_arg})",
|
||||
]
|
||||
lines = [
|
||||
f" if isinstance(_query_dict.get({key}), str):",
|
||||
f" _items = [i.strip() for i in _query_dict[{key}].split(',') if i.strip()]",
|
||||
@@ -782,14 +949,238 @@ def _list_param_lines(wire_name: str, style: str) -> list[str]:
|
||||
return lines
|
||||
|
||||
|
||||
def _socrata_where_clause_lines(range_params: list[dict[str, Any]]) -> list[str]:
|
||||
@dataclass
|
||||
class UrlChoices:
|
||||
"""The URLs of a merged command, as generated code needs them.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
templates : list of tuple
|
||||
``(template, path_names, query_names)`` per URL, most placeholders
|
||||
first, as ``choose_url_template`` takes them.
|
||||
names : list of str
|
||||
Every placeholder and declared query parameter across the URLs.
|
||||
attributes : dict[str, str]
|
||||
Wire name to the attribute holding its value.
|
||||
defaults : dict[str, Any]
|
||||
Placeholder defaults that must not pick a URL by themselves.
|
||||
"""
|
||||
|
||||
templates: list[tuple[str, tuple[str, ...], tuple[str, ...]]]
|
||||
names: list[str]
|
||||
attributes: dict[str, str]
|
||||
defaults: dict[str, Any]
|
||||
|
||||
@property
|
||||
def placeholders(self) -> list[str]:
|
||||
"""Every placeholder name, in first-seen order."""
|
||||
return list(
|
||||
dict.fromkeys(name for _, names, _ in self.templates for name in names)
|
||||
)
|
||||
|
||||
|
||||
def url_choices_for(
|
||||
cmd_spec: dict[str, Any],
|
||||
api_prefix: str,
|
||||
attributes: dict[str, str],
|
||||
) -> UrlChoices | None:
|
||||
"""Collect the URLs of a merged command whose placeholders all have attributes.
|
||||
|
||||
``attributes`` maps each wire name the generated code can read to its
|
||||
attribute name. URLs with a placeholder outside it (a credential-named or
|
||||
undeclared segment) are left out, since no value can fill them. ``None``
|
||||
when fewer than two URLs remain. A spec written before
|
||||
``template_query_params`` existed lets every URL take every query
|
||||
parameter, as before.
|
||||
"""
|
||||
templates = cmd_spec.get("url_templates") or []
|
||||
if len(templates) < 2:
|
||||
return None
|
||||
params = filter_user_params(cmd_spec.get("parameters") or [])
|
||||
declared = cmd_spec.get("template_query_params")
|
||||
legacy = tuple(p["name"] for p in params if p.get("in", "query") != "path")
|
||||
choices = []
|
||||
for template in templates:
|
||||
path_names = tuple(_path_template_keys(template))
|
||||
if not all(name in attributes for name in path_names):
|
||||
continue
|
||||
query_names = (
|
||||
tuple(n for n in declared.get(template) or () if n in attributes)
|
||||
if isinstance(declared, dict)
|
||||
else tuple(n for n in legacy if n in attributes)
|
||||
)
|
||||
choices.append(
|
||||
(_resolve_url_path(api_prefix, template), path_names, query_names)
|
||||
)
|
||||
if len(choices) < 2:
|
||||
return None
|
||||
names = list(
|
||||
dict.fromkeys(
|
||||
name
|
||||
for _, path_names, query_names in choices
|
||||
for name in (*path_names, *query_names)
|
||||
)
|
||||
)
|
||||
placeholders = {name for _, path_names, _ in choices for name in path_names}
|
||||
defaults = {
|
||||
p["name"]: p["default"]
|
||||
for p in params
|
||||
if p.get("name") in placeholders and p.get("default") is not None
|
||||
}
|
||||
return UrlChoices(
|
||||
templates=choices,
|
||||
names=names,
|
||||
attributes={name: attributes[name] for name in names},
|
||||
defaults=defaults,
|
||||
)
|
||||
|
||||
|
||||
def query_params_fields(
|
||||
cmd_spec: dict[str, Any], provider_name: str, class_name: str
|
||||
) -> dict[str, str]:
|
||||
"""Each parameter's field name in a command's generated ``QueryParams``."""
|
||||
return query_params_field_names(
|
||||
_query_params_schema(cmd_spec, provider_name)["properties"], class_name
|
||||
)
|
||||
|
||||
|
||||
def _query_field_names(spec: FetcherCommandSpec) -> dict[str, str]:
|
||||
"""``query_params_fields`` for the ``QueryParams`` class of ``spec``."""
|
||||
return query_params_fields(
|
||||
spec.cmd_spec, spec.provider_name, f"{class_name_from(spec.name)}QueryParams"
|
||||
)
|
||||
|
||||
|
||||
def _field_name(field_names: dict[str, str] | None, name: str) -> str:
|
||||
"""Return the ``QueryParams`` field that holds parameter ``name``."""
|
||||
return (field_names or {}).get(name) or query_field_name(name)
|
||||
|
||||
|
||||
def boolean_params(cmd_spec: dict[str, Any]) -> set[str]:
|
||||
"""Names of the command's boolean parameters."""
|
||||
return {
|
||||
p["name"]
|
||||
for p in cmd_spec.get("parameters") or []
|
||||
if isinstance(p, dict) and p.get("name") and p.get("type") == "boolean"
|
||||
}
|
||||
|
||||
|
||||
def _url_template_choices(spec: FetcherCommandSpec) -> UrlChoices | None:
|
||||
"""``url_choices_for`` with the generated ``QueryParams`` fields as attributes."""
|
||||
return url_choices_for(spec.cmd_spec, spec.api_prefix, _query_field_names(spec))
|
||||
|
||||
|
||||
def render_choose_url_call(
|
||||
choices: UrlChoices, owner: str | None, indent: str
|
||||
) -> list[str]:
|
||||
"""Render a ``choose_url_template(...)`` call.
|
||||
|
||||
Values are read off ``owner`` (``query``, ``self``), or from local
|
||||
variables named after the attributes when ``owner`` is ``None``.
|
||||
"""
|
||||
values = ", ".join(
|
||||
f"{name!r}: "
|
||||
+ (
|
||||
f"getattr({owner}, {choices.attributes[name]!r})"
|
||||
if owner
|
||||
else choices.attributes[name]
|
||||
)
|
||||
for name in choices.placeholders
|
||||
)
|
||||
labels = {name: choices.attributes[name] for name in choices.placeholders}
|
||||
return [
|
||||
f"{indent}choose_url_template(",
|
||||
f"{indent} {tuple(choices.templates)!r},",
|
||||
f"{indent} {{{values}}},",
|
||||
f"{indent} {labels!r},",
|
||||
f"{indent} {choices.defaults!r},",
|
||||
f"{indent})",
|
||||
]
|
||||
|
||||
|
||||
def _path_lines(
|
||||
url_path_template: str,
|
||||
path_params: list[str],
|
||||
url_choices: UrlChoices | None,
|
||||
field_names: dict[str, str] | None = None,
|
||||
booleans: set[str] | frozenset[str] = frozenset(),
|
||||
) -> list[str]:
|
||||
"""Emit the ``_path = ...`` assignment for ``aextract_data``.
|
||||
|
||||
A command with several URLs picks one at call time with
|
||||
``choose_url_template``; ``_used`` and ``_query_names`` record the names its
|
||||
placeholders used and the query parameters it declares. A boolean path
|
||||
value goes through ``wire_value``, so it is sent as ``true`` / ``false``.
|
||||
"""
|
||||
if url_choices:
|
||||
call = render_choose_url_call(url_choices, "query", " ")
|
||||
call[0] = " _path, _used, _query_names = choose_url_template("
|
||||
return call
|
||||
if path_params:
|
||||
sub_args = ", ".join(
|
||||
(
|
||||
f"{p}=wire_value(getattr(query, {_field_name(field_names, p)!r}))"
|
||||
if p in booleans
|
||||
else f"{p}=getattr(query, {_field_name(field_names, p)!r})"
|
||||
)
|
||||
for p in path_params
|
||||
)
|
||||
return [f" _path = {url_path_template!r}.format({sub_args})"]
|
||||
return [f" _path = {url_path_template!r}"]
|
||||
|
||||
|
||||
def _consumed_path_lines(url_choices: UrlChoices | None) -> list[str]:
|
||||
"""Emit lines dropping what the chosen URL does not send as query parameters.
|
||||
|
||||
That is its own path values, other URLs' parameters it does not declare
|
||||
(a list URL's ``limit`` default on an item URL), and empty values.
|
||||
"""
|
||||
if not url_choices:
|
||||
return []
|
||||
return [
|
||||
f" for _name in {tuple(url_choices.names)!r}:",
|
||||
" if (",
|
||||
" _name in _used",
|
||||
" or _name not in _query_names",
|
||||
' or _query_dict.get(_name) in ("", [])',
|
||||
" ):",
|
||||
" _query_dict.pop(_name, None)",
|
||||
]
|
||||
|
||||
|
||||
def _url_template_validator(url_choices: UrlChoices) -> str:
|
||||
"""Render a ``QueryParams`` validator rejecting path values that form no URL."""
|
||||
call = "\n".join(render_choose_url_call(url_choices, "self", " "))
|
||||
return (
|
||||
'\n @model_validator(mode="after")\n'
|
||||
" def _check_url_template(self) -> Any:\n"
|
||||
' """Allow only path-parameter combinations that form one of the URLs."""\n'
|
||||
f"{call}\n"
|
||||
" return self\n"
|
||||
)
|
||||
|
||||
|
||||
def _has_json_list_param(cmd_spec: dict[str, Any]) -> bool:
|
||||
"""Whether any query parameter is sent as a JSON-encoded array."""
|
||||
return any(
|
||||
isinstance(p, dict)
|
||||
and p.get("is_list")
|
||||
and p.get("list_style") == "json"
|
||||
and p.get("in", "query") == "query"
|
||||
for p in filter_user_params(cmd_spec.get("parameters") or [])
|
||||
)
|
||||
|
||||
|
||||
def _socrata_where_clause_lines(
|
||||
range_params: list[dict[str, Any]], field_names: dict[str, str] | None = None
|
||||
) -> list[str]:
|
||||
"""Compose runtime lines that fold Socrata range params into ``$where``."""
|
||||
field_lookups: list[str] = []
|
||||
for param in range_params:
|
||||
wire_name = param["name"]
|
||||
column = param["_socrata_column"]
|
||||
op = ">=" if param["_socrata_op"] == "date_min" else "<="
|
||||
py_field = query_field_name(wire_name)
|
||||
py_field = _field_name(field_names, wire_name)
|
||||
var = f"_range_{py_field}"
|
||||
field_lookups.append(
|
||||
f" {var} = getattr(query, {py_field!r}, None)\n"
|
||||
@@ -892,7 +1283,9 @@ 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 = _with_body_field(_data_schema(spec.cmd_spec) or {"type": "object"})
|
||||
data_schema = _with_body_field(
|
||||
_merged_data_schema(spec.cmd_spec) or {"type": "object"}
|
||||
)
|
||||
|
||||
qp_generated = generate_class(
|
||||
qp_schema,
|
||||
@@ -909,6 +1302,20 @@ def generate_fetcher_module(spec: FetcherCommandSpec) -> GeneratedFetcher:
|
||||
|
||||
creds = credentials_from_command(spec.cmd_spec)
|
||||
path_params = _path_template_keys(spec.cmd_spec.get("url_path") or "")
|
||||
url_choices = _url_template_choices(spec)
|
||||
if url_choices:
|
||||
qp_generated.source = (
|
||||
qp_generated.source.rstrip("\n")
|
||||
+ "\n"
|
||||
+ _url_template_validator(url_choices)
|
||||
)
|
||||
qp_generated.imports.update(
|
||||
{
|
||||
"from pydantic import model_validator",
|
||||
"from typing import Any",
|
||||
"from ....utils import choose_url_template",
|
||||
}
|
||||
)
|
||||
|
||||
imports = {
|
||||
"import base64",
|
||||
@@ -918,11 +1325,14 @@ def generate_fetcher_module(spec: FetcherCommandSpec) -> GeneratedFetcher:
|
||||
"from openbb_core.provider.abstract.data import Data",
|
||||
"from openbb_core.provider.abstract.fetcher import Fetcher",
|
||||
"from openbb_core.provider.abstract.query_params import QueryParams",
|
||||
"from openbb_core.provider.utils.helpers import "
|
||||
"get_async_requests_session, get_querystring",
|
||||
"from openbb_core.provider.utils.helpers import get_async_requests_session",
|
||||
"from pydantic import BaseModel, Field",
|
||||
"from ....utils import safe_json_loads, unpack_response",
|
||||
"from ....utils import encode_query, safe_json_loads, unpack_response",
|
||||
}
|
||||
if _has_json_list_param(spec.cmd_spec):
|
||||
imports.add("from ....utils import json_array")
|
||||
if not url_choices and boolean_params(spec.cmd_spec) & set(path_params):
|
||||
imports.add("from ....utils import wire_value")
|
||||
imports.update(qp_generated.collect_imports())
|
||||
imports.update(data_generated.collect_imports())
|
||||
|
||||
@@ -934,8 +1344,13 @@ def generate_fetcher_module(spec: FetcherCommandSpec) -> GeneratedFetcher:
|
||||
description = (spec.cmd_spec.get("description") or f"Fetch {spec.name}.").strip()
|
||||
|
||||
cred_lines = _credential_lookup_lines(creds, spec.provider_name)
|
||||
field_names = _query_field_names(spec)
|
||||
query_block = _query_dict_construction(
|
||||
spec.cmd_spec, creds, path_params, spec.provider_name
|
||||
spec.cmd_spec,
|
||||
creds,
|
||||
[] if url_choices else path_params,
|
||||
spec.provider_name,
|
||||
field_names,
|
||||
)
|
||||
header_block = _header_dict_construction(creds, spec.headers)
|
||||
cookie_block = _cookie_dict_construction(creds)
|
||||
@@ -950,6 +1365,7 @@ def generate_fetcher_module(spec: FetcherCommandSpec) -> GeneratedFetcher:
|
||||
method=method,
|
||||
url_path_template=full_url_path,
|
||||
base_url=base_url,
|
||||
path_params=path_params,
|
||||
cred_lines=cred_lines,
|
||||
query_block=query_block,
|
||||
header_block=header_block,
|
||||
@@ -989,7 +1405,13 @@ def generate_fetcher_module(spec: FetcherCommandSpec) -> GeneratedFetcher:
|
||||
imports=consolidate_imports(imports),
|
||||
column_metadata=_column_metadata_from_data_schema(data_schema),
|
||||
)
|
||||
if hint:
|
||||
template_hints = _template_unpack_hints(spec.cmd_spec, url_choices)
|
||||
if template_hints:
|
||||
source = source.replace(
|
||||
"unpack_response(_payload)",
|
||||
f"unpack_response(_payload, **{template_hints!r}.get(tuple(sorted(_used)), {{}}))",
|
||||
)
|
||||
elif hint:
|
||||
source = source.replace(
|
||||
"unpack_response(_payload)", f"unpack_response(_payload{hint})"
|
||||
)
|
||||
@@ -1015,6 +1437,7 @@ def _render_streaming_fetcher_source(
|
||||
method: str,
|
||||
url_path_template: str,
|
||||
base_url: str,
|
||||
path_params: list[str],
|
||||
cred_lines: list[str],
|
||||
query_block: str,
|
||||
header_block: str,
|
||||
@@ -1041,6 +1464,8 @@ def _render_streaming_fetcher_source(
|
||||
URL path with placeholders intact.
|
||||
base_url : str
|
||||
Upstream API root, no trailing slash.
|
||||
path_params : list of str
|
||||
Placeholder names in ``url_path_template``.
|
||||
cred_lines : list of str
|
||||
Credential-extraction source lines.
|
||||
query_block : str
|
||||
@@ -1087,15 +1512,24 @@ def _render_streaming_fetcher_source(
|
||||
parts: list[str] = []
|
||||
parts.append(f'"""Streaming fetcher for {spec.name} — generated from spec."""')
|
||||
parts.append("")
|
||||
parts.append("from collections.abc import AsyncIterator")
|
||||
parts.append("from typing import Any")
|
||||
parts.append("")
|
||||
parts.append("import httpx")
|
||||
parts.append("")
|
||||
parts.append("from openbb_core.provider.abstract.data import Data")
|
||||
parts.append("from openbb_core.provider.abstract.fetcher import Fetcher")
|
||||
parts.append("from openbb_core.provider.abstract.query_params import QueryParams")
|
||||
parts.append("from openbb_core.provider.utils.helpers import get_querystring")
|
||||
imports = {
|
||||
"from collections.abc import AsyncIterator",
|
||||
"from typing import Any",
|
||||
"import httpx",
|
||||
"from openbb_core.provider.abstract.data import Data",
|
||||
"from openbb_core.provider.abstract.fetcher import Fetcher",
|
||||
"from openbb_core.provider.abstract.query_params import QueryParams",
|
||||
"from ....utils import encode_query",
|
||||
}
|
||||
url_choices = _url_template_choices(spec)
|
||||
booleans = boolean_params(spec.cmd_spec)
|
||||
if _has_json_list_param(spec.cmd_spec):
|
||||
imports.add("from ....utils import json_array")
|
||||
if not url_choices and booleans & set(path_params):
|
||||
imports.add("from ....utils import wire_value")
|
||||
for cls in qp_classes:
|
||||
imports.update(cls.imports)
|
||||
parts.extend(consolidate_imports(imports))
|
||||
parts.append("")
|
||||
parts.append("")
|
||||
for cls in qp_classes:
|
||||
@@ -1123,10 +1557,20 @@ def _render_streaming_fetcher_source(
|
||||
)
|
||||
parts.append(aextract_doc)
|
||||
parts.extend(cred_lines)
|
||||
parts.extend(
|
||||
_path_lines(
|
||||
url_path_template,
|
||||
path_params,
|
||||
url_choices,
|
||||
_query_field_names(spec),
|
||||
booleans,
|
||||
)
|
||||
)
|
||||
parts.append(query_block)
|
||||
parts.append(" _query_string = get_querystring(_query_dict, [])")
|
||||
parts.extend(_consumed_path_lines(url_choices))
|
||||
parts.append(" _query_string = encode_query(_query_dict)")
|
||||
parts.append(
|
||||
f' _url = f"{base_url}{url_path_template}"'
|
||||
f' _url = f"{base_url}{{_path}}"'
|
||||
' + ("?" + _query_string if _query_string else "")'
|
||||
)
|
||||
parts.append(header_block)
|
||||
@@ -1315,6 +1759,9 @@ def _render_fetcher_source(
|
||||
),
|
||||
has_path_params=has_path_params,
|
||||
has_query_creds=has_query_creds,
|
||||
url_choices=_url_template_choices(spec),
|
||||
field_names=_query_field_names(spec),
|
||||
booleans=boolean_params(spec.cmd_spec),
|
||||
)
|
||||
|
||||
fetcher_source_lines = [
|
||||
@@ -1381,6 +1828,9 @@ def _aextract_body(
|
||||
body_field_names: list[str],
|
||||
has_path_params: bool,
|
||||
has_query_creds: bool,
|
||||
url_choices: UrlChoices | None = None,
|
||||
field_names: dict[str, str] | None = None,
|
||||
booleans: set[str] | frozenset[str] = frozenset(),
|
||||
) -> list[str]:
|
||||
"""Render the body of ``aextract_data`` as a list of source lines.
|
||||
|
||||
@@ -1408,6 +1858,12 @@ def _aextract_body(
|
||||
Whether the URL has placeholders to substitute.
|
||||
has_query_creds : bool
|
||||
Whether any credential lives in the query string.
|
||||
url_choices : list, optional
|
||||
``_url_template_choices`` output for a command with several URLs.
|
||||
field_names : dict, optional
|
||||
Parameter name to ``QueryParams`` field name (``_query_field_names``).
|
||||
booleans : set of str, optional
|
||||
Names of boolean parameters.
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -1415,16 +1871,19 @@ def _aextract_body(
|
||||
Indented lines forming the body of ``aextract_data``.
|
||||
"""
|
||||
lines: list[str] = list(cred_lines)
|
||||
if has_path_params:
|
||||
sub_args = ", ".join(
|
||||
f"{p}=getattr(query, {query_field_name(p)!r})" for p in path_params
|
||||
lines.extend(
|
||||
_path_lines(
|
||||
url_path_template,
|
||||
path_params if has_path_params else [],
|
||||
url_choices,
|
||||
field_names,
|
||||
booleans,
|
||||
)
|
||||
lines.append(f" _path = {url_path_template!r}.format({sub_args})")
|
||||
else:
|
||||
lines.append(f" _path = {url_path_template!r}")
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(query_block)
|
||||
lines.append(" _query_string = get_querystring(_query_dict, [])")
|
||||
lines.extend(_consumed_path_lines(url_choices))
|
||||
lines.append(" _query_string = encode_query(_query_dict)")
|
||||
lines.append(
|
||||
f' _url = f"{base_url}{{_path}}" + ("?" + _query_string if _query_string else "")'
|
||||
)
|
||||
@@ -1437,7 +1896,7 @@ def _aextract_body(
|
||||
body_literal = (
|
||||
"{"
|
||||
+ ", ".join(
|
||||
f"{name!r}: getattr(query, {query_field_name(name)!r}, None)"
|
||||
f"{name!r}: getattr(query, {_field_name(field_names, name)!r}, None)"
|
||||
for name in body_field_names
|
||||
)
|
||||
+ "}"
|
||||
|
||||
Reference in New Issue
Block a user