mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-27 12:40:35 +00:00
* 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 <>
1130 lines
38 KiB
Python
1130 lines
38 KiB
Python
"""Generate ``QueryParams`` + ``Data`` + ``Fetcher`` modules for one spec command."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, cast
|
|
|
|
from openbb_cli.codegen.credentials import (
|
|
credentials_from_command,
|
|
filter_user_params,
|
|
)
|
|
from openbb_cli.codegen.pydantic_gen import (
|
|
GeneratedClass,
|
|
class_name_from,
|
|
consolidate_imports,
|
|
generate_class,
|
|
render_function_docstring,
|
|
safe_field_name,
|
|
)
|
|
|
|
_PATH_TEMPLATE_RE = re.compile(r"\{([^}]+)\}")
|
|
|
|
|
|
@dataclass
|
|
class FetcherCommandSpec:
|
|
"""Per-command inputs to the fetcher emitter.
|
|
|
|
Parameters
|
|
----------
|
|
name : str
|
|
Dotted command path as it appears in ``spec_doc["commands"]``.
|
|
cmd_spec : dict
|
|
The spec entry.
|
|
base_url : str
|
|
The upstream API root.
|
|
api_prefix : str
|
|
Path prefix shared across every command in the spec.
|
|
provider_name : str
|
|
Snake-case provider name.
|
|
"""
|
|
|
|
name: str
|
|
cmd_spec: dict[str, Any]
|
|
base_url: str
|
|
api_prefix: str
|
|
provider_name: str
|
|
|
|
|
|
@dataclass
|
|
class GeneratedFetcher:
|
|
"""The output of ``generate_fetcher_module``.
|
|
|
|
Parameters
|
|
----------
|
|
module_name : str
|
|
Snake-case module filename (without ``.py``).
|
|
model_name : str
|
|
The Pydantic model name registered in the provider's ``fetcher_dict``.
|
|
query_params_class : str
|
|
Name of the emitted ``QueryParams`` subclass.
|
|
data_class : str
|
|
Name of the emitted ``Data`` subclass.
|
|
fetcher_class : str
|
|
Name of the emitted ``Fetcher`` subclass.
|
|
source : str
|
|
Full module source ready to write to disk.
|
|
credentials_used : dict
|
|
``{canonical_name: {"name": wire_name, "in": "query"|"header"|"cookie"}}``.
|
|
"""
|
|
|
|
module_name: str
|
|
model_name: str
|
|
query_params_class: str
|
|
data_class: str
|
|
fetcher_class: str
|
|
source: str
|
|
credentials_used: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
is_streaming: bool = False
|
|
|
|
|
|
def _command_is_streaming(cmd_spec: dict[str, Any]) -> bool:
|
|
"""Return whether a command's success response is a stream.
|
|
|
|
Parameters
|
|
----------
|
|
cmd_spec : dict
|
|
The spec command entry.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
True when a 2xx response advertises ``text/event-stream``.
|
|
"""
|
|
for status, content in (cmd_spec.get("response_schemas") or {}).items():
|
|
if (
|
|
str(status).startswith("2")
|
|
and isinstance(content, dict)
|
|
and any("event-stream" in str(ct) for ct in content)
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _module_name_from_command(name: str) -> str:
|
|
"""Convert a dotted command path to a snake_case module identifier.
|
|
|
|
Parameters
|
|
----------
|
|
name : str
|
|
Dotted command path.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
Snake-case identifier.
|
|
"""
|
|
safe = re.sub(r"[^0-9a-zA-Z]+", "_", name).strip("_")
|
|
if not safe:
|
|
safe = "command"
|
|
if safe[0].isdigit():
|
|
safe = f"_{safe}"
|
|
return safe.lower()
|
|
|
|
|
|
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)]
|
|
|
|
|
|
def _query_params_schema(
|
|
cmd_spec: dict[str, Any], provider_name: str
|
|
) -> dict[str, Any]:
|
|
"""Build a JSON-schema dict combining query, path, and body fields.
|
|
|
|
Parameters
|
|
----------
|
|
cmd_spec : dict
|
|
The spec command entry.
|
|
provider_name : str
|
|
Provider this fetcher is being generated for.
|
|
|
|
Returns
|
|
-------
|
|
dict
|
|
A synthetic ``{"type": "object", "properties": {...}, "required": [...]}``.
|
|
"""
|
|
properties: dict[str, Any] = {}
|
|
required: list[str] = []
|
|
for raw in filter_user_params(cmd_spec.get("parameters") or []):
|
|
name = raw.get("name")
|
|
if not name:
|
|
continue
|
|
if name == "provider":
|
|
continue
|
|
param_providers = raw.get("providers") or []
|
|
if param_providers and provider_name not in param_providers:
|
|
continue
|
|
description = raw.get("help") or raw.get("description")
|
|
if description:
|
|
description = re.sub(r"\s*\(provider:[^)]*\)\s*$", "", description).strip()
|
|
prop_schema: dict[str, Any] = {}
|
|
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":
|
|
prop_schema["type"] = "integer"
|
|
if description:
|
|
prop_schema["description"] = description
|
|
if 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"):
|
|
prop_schema["enum"] = list(raw["choices"])
|
|
properties[name] = prop_schema
|
|
if raw.get("required"):
|
|
required.append(name)
|
|
body_schema = cmd_spec.get("request_body_schema") or {}
|
|
body_props = body_schema.get("properties") or {}
|
|
body_required = body_schema.get("required") or []
|
|
for body_name, body_prop in body_props.items():
|
|
if body_name not in properties:
|
|
properties[body_name] = body_prop
|
|
if body_name in body_required:
|
|
required.append(body_name)
|
|
return {
|
|
"type": "object",
|
|
"properties": properties,
|
|
"required": required,
|
|
}
|
|
|
|
|
|
def _column_metadata_from_data_schema(
|
|
data_schema: dict[str, Any],
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""Pull per-column ``description`` / ``socrata_format`` out of the row schema."""
|
|
properties = (data_schema or {}).get("properties") or {}
|
|
out: dict[str, dict[str, Any]] = {}
|
|
for field_name, schema in properties.items():
|
|
if not isinstance(schema, dict):
|
|
continue
|
|
meta: dict[str, Any] = {}
|
|
if schema.get("description"):
|
|
meta["description"] = schema["description"]
|
|
if schema.get("format"):
|
|
meta["format"] = schema["format"]
|
|
if schema.get("socrata_format"):
|
|
meta["socrata_format"] = schema["socrata_format"]
|
|
if meta:
|
|
out[field_name] = meta
|
|
return out
|
|
|
|
|
|
def _data_schema(cmd_spec: dict[str, Any]) -> dict[str, Any]:
|
|
"""Pull the per-row data schema out of a response_schema.
|
|
|
|
Parameters
|
|
----------
|
|
cmd_spec : dict
|
|
The spec command entry.
|
|
|
|
Returns
|
|
-------
|
|
dict
|
|
The schema that should drive the ``Data`` Pydantic class.
|
|
"""
|
|
response = cmd_spec.get("response_schema") or {}
|
|
if not isinstance(response, dict):
|
|
return {}
|
|
properties = response.get("properties") or {}
|
|
results = properties.get("results")
|
|
if isinstance(results, dict):
|
|
non_null = [
|
|
v
|
|
for v in results.get("anyOf") or [results]
|
|
if isinstance(v, dict) and v.get("type") != "null"
|
|
]
|
|
if non_null:
|
|
inner = non_null[0]
|
|
if inner.get("type") == "array" and isinstance(inner.get("items"), dict):
|
|
inner = inner["items"]
|
|
if isinstance(inner.get("oneOf"), list):
|
|
concrete = [c for c in inner["oneOf"] if isinstance(c, dict)]
|
|
if concrete:
|
|
inner = concrete[0]
|
|
return inner
|
|
return _unwrap_schema_envelopes(response)
|
|
|
|
|
|
def _unwrap_schema_envelopes(schema: Any) -> dict[str, Any]:
|
|
"""Strip schema envelopes that ``unpack_response`` strips at runtime."""
|
|
if not isinstance(schema, dict):
|
|
return {}
|
|
typed: dict[str, Any] = schema
|
|
if typed.get("type") == "array":
|
|
return _unwrap_schema_envelopes(typed.get("items"))
|
|
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)
|
|
props = typed.get("properties")
|
|
if not isinstance(props, dict) or not props:
|
|
return typed
|
|
inner = _envelope_inner_schema(typed, props)
|
|
if inner is not None:
|
|
return _unwrap_schema_envelopes(inner)
|
|
return typed
|
|
|
|
|
|
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"):
|
|
variants = schema.get(combinator)
|
|
if not isinstance(variants, list):
|
|
continue
|
|
for variant in variants:
|
|
if isinstance(variant, dict) and variant.get("type") != "null":
|
|
return cast(dict[str, Any], variant)
|
|
return None
|
|
|
|
|
|
def _envelope_inner_schema(
|
|
typed: dict[str, Any], # noqa: ARG001
|
|
props: dict[str, Any],
|
|
) -> dict[str, Any] | None:
|
|
"""Resolve the inner row schema when ``typed`` is a recognized envelope."""
|
|
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 (
|
|
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 _is_scalar_schema(schema: dict[str, Any]) -> bool:
|
|
"""Return True when the schema represents a primitive (string/number/etc.)."""
|
|
return schema.get("type") in {
|
|
"string",
|
|
"integer",
|
|
"number",
|
|
"boolean",
|
|
} and not schema.get("properties")
|
|
|
|
|
|
def _wrap_scalar_as_value(schema: dict[str, Any]) -> dict[str, Any]:
|
|
"""Promote a scalar schema to ``{type: object, properties: {value: scalar}}``."""
|
|
return {
|
|
"type": "object",
|
|
"properties": {"value": schema},
|
|
"required": ["value"],
|
|
}
|
|
|
|
|
|
def _credential_lookup_lines(
|
|
creds: dict[str, dict[str, str]], provider_name: str
|
|
) -> list[str]:
|
|
"""Render ``api_key = creds.get("<provider>_api_key", "")`` lines.
|
|
|
|
Parameters
|
|
----------
|
|
creds : dict
|
|
Mapping returned by ``credentials_from_command``.
|
|
provider_name : str
|
|
Snake-case provider name used as the key prefix.
|
|
|
|
Returns
|
|
-------
|
|
list of str
|
|
One assignment per credential, in canonical-name order.
|
|
"""
|
|
lines: list[str] = []
|
|
for canonical in sorted(creds):
|
|
var = f"_cred_{canonical}"
|
|
full_key = f"{provider_name}_{canonical}"
|
|
lines.append(f' {var} = _creds.get("{full_key}", "")')
|
|
return lines
|
|
|
|
|
|
def _query_dict_construction(
|
|
cmd_spec: dict[str, Any],
|
|
creds: dict[str, dict[str, str]],
|
|
path_params: list[str],
|
|
provider_name: str,
|
|
) -> str:
|
|
"""Compose the ``query_dict = {...}`` block emitted in ``aextract_data``.
|
|
|
|
Parameters
|
|
----------
|
|
cmd_spec : dict
|
|
The spec command entry.
|
|
creds : dict
|
|
Credential entries.
|
|
path_params : list of str
|
|
URL-template placeholders.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
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)
|
|
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)
|
|
if excluded:
|
|
excluded_literal = (
|
|
"{" + ", ".join(repr(name) for name in sorted(excluded)) + "}"
|
|
)
|
|
dump = (
|
|
f" _query_dict = query.model_dump("
|
|
f"by_alias=True, exclude={excluded_literal}, exclude_none=True)"
|
|
)
|
|
else:
|
|
dump = (
|
|
" _query_dict = query.model_dump(by_alias=True, exclude_none=True)"
|
|
)
|
|
lines = [dump]
|
|
declares_provider = any(
|
|
isinstance(p, dict) and p.get("name") == "provider"
|
|
for p in (cmd_spec.get("parameters") or [])
|
|
)
|
|
if declares_provider:
|
|
lines.append(f' _query_dict["provider"] = {provider_name!r}')
|
|
for canonical, info in creds.items():
|
|
if info["in"] != "query":
|
|
continue
|
|
var = f"_cred_{canonical}"
|
|
wire_name = info["name"]
|
|
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))
|
|
date_column = next(
|
|
(p["_socrata_column"] for p in range_params if p.get("_socrata_column")),
|
|
None,
|
|
)
|
|
if date_column:
|
|
lines.append(
|
|
f" _query_dict.setdefault('$order', {date_column!r} + ' DESC')"
|
|
)
|
|
rename_pairs: list[tuple[str, str]] = []
|
|
for param in cmd_spec.get("parameters") or []:
|
|
if not isinstance(param, dict):
|
|
continue
|
|
wire_name = param.get("wire_name")
|
|
if wire_name and wire_name != param.get("name"):
|
|
rename_pairs.append((param["name"], wire_name))
|
|
if rename_pairs:
|
|
lines.append(" for _src, _dst in (")
|
|
for src, dst in rename_pairs:
|
|
lines.append(f" ({src!r}, {dst!r}),")
|
|
lines.append(" ):")
|
|
lines.append(" if _src in _query_dict:")
|
|
lines.append(" _query_dict[_dst] = _query_dict.pop(_src)")
|
|
return "\n".join(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] = []
|
|
for param in range_params:
|
|
wire_name = param["name"]
|
|
column = param["_socrata_column"]
|
|
op = ">=" if param["_socrata_op"] == "date_min" else "<="
|
|
py_field, _ = safe_field_name(wire_name)
|
|
var = f"_range_{py_field}"
|
|
field_lookups.append(
|
|
f" {var} = getattr(query, {py_field!r}, None)\n"
|
|
f" if {var} is not None:\n"
|
|
f" _socrata_where_parts.append("
|
|
f'f"{column} {op} \'" + str({var}) + "\'")'
|
|
)
|
|
out: list[str] = [" _socrata_where_parts: list[str] = []"]
|
|
out.extend(field_lookups)
|
|
out.append(" if _socrata_where_parts:")
|
|
out.append(" _existing_where = _query_dict.pop('$where', None)")
|
|
out.append(" _socrata_where = ' AND '.join(_socrata_where_parts)")
|
|
out.append(" if _existing_where:")
|
|
out.append(
|
|
" _socrata_where = f'({_existing_where}) AND ({_socrata_where})'"
|
|
)
|
|
out.append(" _query_dict['$where'] = _socrata_where")
|
|
return out
|
|
|
|
|
|
def _header_dict_construction(creds: dict[str, dict[str, str]]) -> str:
|
|
"""Compose the ``_headers = {...}`` block for credential headers.
|
|
|
|
Parameters
|
|
----------
|
|
creds : dict
|
|
Credential entries.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
Python source for the headers dict.
|
|
"""
|
|
lines = [" _headers: dict[str, str] = {}"]
|
|
for canonical, info in creds.items():
|
|
if info["in"] != "header":
|
|
continue
|
|
var = f"_cred_{canonical}"
|
|
wire_name = info["name"]
|
|
lines.append(f" if {var}:")
|
|
lines.append(f" _headers[{wire_name!r}] = {var}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _cookie_dict_construction(creds: dict[str, dict[str, str]]) -> str:
|
|
"""Compose the ``_cookies = {...}`` block for credential cookies.
|
|
|
|
Parameters
|
|
----------
|
|
creds : dict
|
|
Credential entries.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
Python source for the cookies dict.
|
|
"""
|
|
lines = [" _cookies: dict[str, str] = {}"]
|
|
for canonical, info in creds.items():
|
|
if info["in"] != "cookie":
|
|
continue
|
|
var = f"_cred_{canonical}"
|
|
wire_name = info["name"]
|
|
lines.append(f" if {var}:")
|
|
lines.append(f" _cookies[{wire_name!r}] = {var}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _resolve_url_path(api_prefix: str, url_path: str) -> str:
|
|
"""Combine api_prefix with url_path; the prefix is dropped if already present."""
|
|
prefix = ("/" + api_prefix.strip("/")) if api_prefix.strip("/") else ""
|
|
path = url_path if url_path.startswith("/") else "/" + url_path
|
|
if prefix and path.startswith(prefix + "/"):
|
|
return path
|
|
if prefix:
|
|
return prefix + path if not path.startswith(prefix) else path
|
|
return path
|
|
|
|
|
|
def generate_fetcher_module(spec: FetcherCommandSpec) -> GeneratedFetcher:
|
|
"""Render the full three-class fetcher module for one spec command.
|
|
|
|
Parameters
|
|
----------
|
|
spec : FetcherCommandSpec
|
|
The command-level inputs (see ``FetcherCommandSpec``).
|
|
|
|
Returns
|
|
-------
|
|
GeneratedFetcher
|
|
The emitted source plus metadata the package builder needs to
|
|
register the fetcher in the provider's ``fetcher_dict``.
|
|
"""
|
|
module_name = _module_name_from_command(spec.name)
|
|
model_name = class_name_from(spec.name)
|
|
qp_class = f"{model_name}QueryParams"
|
|
data_class = f"{model_name}Data"
|
|
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"}
|
|
|
|
qp_generated = generate_class(
|
|
qp_schema,
|
|
class_name=qp_class,
|
|
base_class="QueryParams",
|
|
docstring=f"Query parameters for {spec.name}.",
|
|
)
|
|
data_generated = generate_class(
|
|
data_schema,
|
|
class_name=data_class,
|
|
base_class="Data",
|
|
docstring=f"Response row for {spec.name}.",
|
|
)
|
|
|
|
creds = credentials_from_command(spec.cmd_spec)
|
|
path_params = _path_template_keys(spec.cmd_spec.get("url_path") or "")
|
|
|
|
imports = {
|
|
"from typing import Any",
|
|
"from openbb_core.app.model.abstract.error import OpenBBError",
|
|
"from openbb_core.provider.abstract.annotated_result import AnnotatedResult",
|
|
"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 pydantic import BaseModel, Field",
|
|
"from ....utils import safe_json_loads, unpack_response",
|
|
}
|
|
imports.update(qp_generated.collect_imports())
|
|
imports.update(data_generated.collect_imports())
|
|
|
|
method = (spec.cmd_spec.get("method") or "get").lower()
|
|
full_url_path = _resolve_url_path(
|
|
spec.api_prefix, spec.cmd_spec.get("url_path") or ""
|
|
)
|
|
base_url = spec.base_url.rstrip("/")
|
|
description = (spec.cmd_spec.get("description") or f"Fetch {spec.name}.").strip()
|
|
|
|
cred_lines = _credential_lookup_lines(creds, spec.provider_name)
|
|
query_block = _query_dict_construction(
|
|
spec.cmd_spec, creds, path_params, spec.provider_name
|
|
)
|
|
header_block = _header_dict_construction(creds)
|
|
cookie_block = _cookie_dict_construction(creds)
|
|
|
|
if _command_is_streaming(spec.cmd_spec):
|
|
source = _render_streaming_fetcher_source(
|
|
spec=spec,
|
|
qp_class=qp_class,
|
|
qp_classes=qp_generated.flatten(),
|
|
data_class=data_class,
|
|
fetcher_class=fetcher_class,
|
|
method=method,
|
|
url_path_template=full_url_path,
|
|
base_url=base_url,
|
|
cred_lines=cred_lines,
|
|
query_block=query_block,
|
|
header_block=header_block,
|
|
cookie_block=cookie_block,
|
|
description=description,
|
|
)
|
|
return GeneratedFetcher(
|
|
module_name=module_name,
|
|
model_name=model_name,
|
|
query_params_class=qp_class,
|
|
data_class=data_class,
|
|
fetcher_class=fetcher_class,
|
|
source=source,
|
|
credentials_used=creds,
|
|
is_streaming=True,
|
|
)
|
|
|
|
source = _render_fetcher_source(
|
|
spec=spec,
|
|
qp_class=qp_class,
|
|
qp_classes=qp_generated.flatten(),
|
|
data_class=data_class,
|
|
data_classes=data_generated.flatten(),
|
|
fetcher_class=fetcher_class,
|
|
model_name=model_name,
|
|
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,
|
|
cookie_block=cookie_block,
|
|
creds=creds,
|
|
description=description,
|
|
imports=consolidate_imports(imports),
|
|
column_metadata=_column_metadata_from_data_schema(data_schema),
|
|
)
|
|
|
|
return GeneratedFetcher(
|
|
module_name=module_name,
|
|
model_name=model_name,
|
|
query_params_class=qp_class,
|
|
data_class=data_class,
|
|
fetcher_class=fetcher_class,
|
|
source=source,
|
|
credentials_used=creds,
|
|
)
|
|
|
|
|
|
def _render_streaming_fetcher_source(
|
|
*,
|
|
spec: FetcherCommandSpec,
|
|
qp_class: str,
|
|
qp_classes: list[GeneratedClass],
|
|
data_class: str,
|
|
fetcher_class: str,
|
|
method: str,
|
|
url_path_template: str,
|
|
base_url: str,
|
|
cred_lines: list[str],
|
|
query_block: str,
|
|
header_block: str,
|
|
cookie_block: str,
|
|
description: str,
|
|
) -> str:
|
|
"""Format a streaming fetcher module source.
|
|
|
|
Parameters
|
|
----------
|
|
spec : FetcherCommandSpec
|
|
Original command input.
|
|
qp_class : str
|
|
Query parameters class name.
|
|
qp_classes : list of GeneratedClass
|
|
Pre-flattened query parameter classes.
|
|
data_class : str
|
|
Streamed row class name.
|
|
fetcher_class : str
|
|
Fetcher class name.
|
|
method : str
|
|
HTTP verb.
|
|
url_path_template : str
|
|
URL path with placeholders intact.
|
|
base_url : str
|
|
Upstream API root, no trailing slash.
|
|
cred_lines : list of str
|
|
Credential-extraction source lines.
|
|
query_block : str
|
|
Pre-rendered query-dict source.
|
|
header_block : str
|
|
Pre-rendered headers-dict source.
|
|
cookie_block : str
|
|
Pre-rendered cookies-dict source.
|
|
description : str
|
|
First paragraph of the spec command's description.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
The full streaming fetcher module source.
|
|
"""
|
|
transform_query_doc = render_function_docstring(
|
|
"Validate raw input into typed query parameters.",
|
|
parameters=[("params", "dict[str, Any]", "Raw user input.")],
|
|
returns=(qp_class, "Validated query parameters."),
|
|
indent=" ",
|
|
)
|
|
aextract_doc = render_function_docstring(
|
|
f"Open the stream at {base_url}{url_path_template}.",
|
|
parameters=[
|
|
("query", qp_class, "Validated query parameters."),
|
|
("credentials", "dict | None", "Provider credentials."),
|
|
("**kwargs", "Any", "Forwarded by the provider runtime."),
|
|
],
|
|
returns=("AsyncIterator[str]", "Async iterator of raw stream lines."),
|
|
indent=" ",
|
|
)
|
|
stream_data_doc = render_function_docstring(
|
|
f"Type each stream line as {data_class}.",
|
|
parameters=[
|
|
("query", qp_class, "Validated query parameters."),
|
|
("data", "Any", "Async iterator of raw stream lines."),
|
|
("**kwargs", "Any", "Forwarded by the provider runtime."),
|
|
],
|
|
returns=(f"AsyncIterator[{data_class}]", "Async iterator of typed rows."),
|
|
indent=" ",
|
|
)
|
|
|
|
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")
|
|
parts.append("")
|
|
parts.append("")
|
|
for cls in qp_classes:
|
|
parts.append(cls.source)
|
|
parts.append(f"class {data_class}(Data):")
|
|
parts.append(f' """Streamed row for {spec.name}."""')
|
|
parts.append("")
|
|
parts.append(" data: str")
|
|
parts.append("")
|
|
parts.append("")
|
|
parts.append(
|
|
f"class {fetcher_class}(Fetcher[{qp_class}, AsyncIterator[{data_class}]]):"
|
|
)
|
|
parts.append(f' """Streaming fetcher for {spec.name}."""')
|
|
parts.append("")
|
|
parts.append(" @staticmethod")
|
|
parts.append(f" def transform_query(params: dict) -> {qp_class}:")
|
|
parts.append(transform_query_doc)
|
|
parts.append(f" return {qp_class}(**params)")
|
|
parts.append("")
|
|
parts.append(" @staticmethod")
|
|
parts.append(
|
|
f" async def aextract_data(query: {qp_class}, "
|
|
"credentials: dict | None, **kwargs) -> AsyncIterator[str]:"
|
|
)
|
|
parts.append(aextract_doc)
|
|
parts.extend(cred_lines)
|
|
parts.append(query_block)
|
|
parts.append(" _query_string = get_querystring(_query_dict, [])")
|
|
parts.append(
|
|
f' _url = f"{base_url}{url_path_template}"'
|
|
' + ("?" + _query_string if _query_string else "")'
|
|
)
|
|
parts.append(header_block)
|
|
parts.append(cookie_block)
|
|
parts.append("")
|
|
parts.append(" async def _stream() -> AsyncIterator[str]:")
|
|
parts.append(
|
|
" async with httpx.AsyncClient(timeout=None) as _client: # noqa: S113"
|
|
)
|
|
parts.append(
|
|
f' async with _client.stream("{method.upper()}", '
|
|
"_url, headers=_headers, cookies=_cookies) as _resp:"
|
|
)
|
|
parts.append(" _resp.raise_for_status()")
|
|
parts.append(" async for _line in _resp.aiter_lines():")
|
|
parts.append(" if _line:")
|
|
parts.append(" yield _line")
|
|
parts.append("")
|
|
parts.append(" return _stream()")
|
|
parts.append("")
|
|
parts.append(" @staticmethod")
|
|
parts.append(
|
|
f" async def stream_data(query: {qp_class}, data: Any, **kwargs)"
|
|
f" -> AsyncIterator[{data_class}]:"
|
|
)
|
|
parts.append(stream_data_doc)
|
|
parts.append(" async for _line in data:")
|
|
parts.append(f" yield {data_class}(data=_line)")
|
|
return "\n".join(parts).rstrip() + "\n"
|
|
|
|
|
|
def _render_fetcher_source(
|
|
*,
|
|
spec: FetcherCommandSpec,
|
|
qp_class: str,
|
|
qp_classes: list[GeneratedClass],
|
|
data_class: str,
|
|
data_classes: list[GeneratedClass],
|
|
fetcher_class: str,
|
|
model_name: str,
|
|
method: str,
|
|
url_path_template: str,
|
|
base_url: str,
|
|
path_params: list[str],
|
|
cred_lines: list[str],
|
|
query_block: str,
|
|
header_block: str,
|
|
cookie_block: str,
|
|
creds: dict[str, dict[str, str]],
|
|
description: str,
|
|
imports: list[str],
|
|
column_metadata: dict[str, dict[str, Any]] | None = None,
|
|
) -> str:
|
|
"""Format the final fetcher module source.
|
|
|
|
Parameters
|
|
----------
|
|
spec : FetcherCommandSpec
|
|
Original command input.
|
|
qp_class, data_class, fetcher_class : str
|
|
Class names emitted in the module.
|
|
qp_classes, data_classes : list of GeneratedClass
|
|
Pre-flattened lists of emitted classes.
|
|
model_name : str
|
|
The Pydantic model key referenced by ``@router.command(model=...)``.
|
|
method : str
|
|
HTTP verb.
|
|
url_path_template : str
|
|
URL path with placeholders intact.
|
|
base_url : str
|
|
Upstream API root, no trailing slash.
|
|
path_params : list of str
|
|
Names of placeholders in ``url_path_template``.
|
|
cred_lines : list of str
|
|
Credential-extraction source lines.
|
|
query_block, header_block, cookie_block : str
|
|
Pre-rendered query-dict, headers-dict, and cookies-dict source.
|
|
creds : dict
|
|
Credential entries.
|
|
description : str
|
|
First paragraph of the spec command's description.
|
|
imports : list of str
|
|
All imports the module needs, already sorted.
|
|
|
|
Returns
|
|
-------
|
|
str
|
|
The full module source text.
|
|
"""
|
|
stdlib_imports = [
|
|
i
|
|
for i in imports
|
|
if i.startswith(
|
|
("from typing", "from datetime", "import typing", "import datetime")
|
|
)
|
|
]
|
|
third_party_imports = [i for i in imports if i not in stdlib_imports]
|
|
|
|
parts: list[str] = []
|
|
parts.append(
|
|
f'"""Fetcher for {spec.name} — generated from spec.\n\n'
|
|
f"Hits ``{base_url}{url_path_template}`` via HTTP "
|
|
f'{method.upper()}.\n"""'
|
|
)
|
|
parts.append("")
|
|
if stdlib_imports:
|
|
parts.extend(stdlib_imports)
|
|
parts.append("")
|
|
if third_party_imports:
|
|
parts.extend(third_party_imports)
|
|
parts.append("")
|
|
parts.append("")
|
|
|
|
for cls in qp_classes:
|
|
parts.append(cls.source)
|
|
for cls in data_classes:
|
|
parts.append(cls.source)
|
|
|
|
fetcher_doc = render_function_docstring(
|
|
description.split("\n", 1)[0],
|
|
)
|
|
|
|
transform_query_doc = render_function_docstring(
|
|
"Validate raw input into typed query parameters.",
|
|
parameters=[
|
|
("params", "dict[str, Any]", "Raw user input (CLI flags / API body)."),
|
|
],
|
|
returns=(qp_class, "Validated query parameters."),
|
|
indent=" ",
|
|
)
|
|
|
|
aextract_doc_params: list[tuple[str, str, str | None]] = [
|
|
("query", qp_class, "Validated query parameters."),
|
|
(
|
|
"credentials",
|
|
"dict[str, str] | None",
|
|
"Provider credentials registered on the ``Provider`` instance.",
|
|
),
|
|
("**kwargs", "Any", "Forwarded by the provider runtime; unused."),
|
|
]
|
|
aextract_doc = render_function_docstring(
|
|
f"Fetch from {base_url}{url_path_template} and split rows from metadata.",
|
|
parameters=aextract_doc_params,
|
|
returns=(
|
|
"dict[str, Any]",
|
|
(
|
|
"``{'rows': [...], 'metadata': {...}}`` — single-element list "
|
|
"and single-key envelope wrappers are stripped at the response "
|
|
"boundary, scalar fields beside the data array become metadata."
|
|
),
|
|
),
|
|
indent=" ",
|
|
)
|
|
|
|
transform_data_doc = render_function_docstring(
|
|
f"Type the unpacked rows as {data_class}; surface metadata via AnnotatedResult.",
|
|
parameters=[
|
|
("query", qp_class, "The validated query (unused but provided)."),
|
|
("data", "dict[str, Any]", "Output of ``aextract_data``."),
|
|
("**kwargs", "Any", "Forwarded by the provider runtime; unused."),
|
|
],
|
|
returns=(
|
|
f"list[{data_class}] | AnnotatedResult[list[{data_class}]]",
|
|
(
|
|
"Typed rows; wrapped in ``AnnotatedResult`` when the response "
|
|
"carried metadata alongside the data array."
|
|
),
|
|
),
|
|
indent=" ",
|
|
)
|
|
|
|
has_path_params = bool(path_params)
|
|
has_query_creds = any(info["in"] == "query" for info in creds.values())
|
|
|
|
body_construction = _aextract_body(
|
|
method=method,
|
|
base_url=base_url,
|
|
url_path_template=url_path_template,
|
|
path_params=path_params,
|
|
cred_lines=cred_lines,
|
|
query_block=query_block,
|
|
header_block=header_block,
|
|
cookie_block=cookie_block,
|
|
body_field_names=list(
|
|
(spec.cmd_spec.get("request_body_schema") or {}).get("properties") or {}
|
|
),
|
|
has_path_params=has_path_params,
|
|
has_query_creds=has_query_creds,
|
|
)
|
|
|
|
fetcher_source_lines = [
|
|
f"class {fetcher_class}(Fetcher[{qp_class}, list[{data_class}]]):",
|
|
fetcher_doc,
|
|
"",
|
|
" @staticmethod",
|
|
f" def transform_query(params: dict[str, Any]) -> {qp_class}:",
|
|
transform_query_doc,
|
|
f" return {qp_class}(**params)",
|
|
"",
|
|
" @staticmethod",
|
|
" async def aextract_data(",
|
|
f" query: {qp_class},",
|
|
" credentials: dict[str, str] | None,",
|
|
" **kwargs: Any,",
|
|
" ) -> dict[str, Any]:",
|
|
aextract_doc,
|
|
" _creds = credentials or {}",
|
|
]
|
|
fetcher_source_lines.extend(body_construction)
|
|
column_meta_literal = repr(column_metadata) if column_metadata else None
|
|
transform_data_lines = [
|
|
"",
|
|
" @staticmethod",
|
|
" def transform_data(",
|
|
f" query: {qp_class},",
|
|
" data: dict[str, Any],",
|
|
" **kwargs: Any,",
|
|
f" ) -> list[{data_class}] | AnnotatedResult[list[{data_class}]]:",
|
|
transform_data_doc,
|
|
f" _typed = [{data_class}(**row) for row in data['rows']]",
|
|
" _metadata = dict(data.get('metadata') or {})",
|
|
]
|
|
if column_meta_literal:
|
|
transform_data_lines.append(f" _column_metadata = {column_meta_literal}")
|
|
transform_data_lines.append(
|
|
" _metadata.setdefault('columns', _column_metadata)"
|
|
)
|
|
transform_data_lines.extend(
|
|
[
|
|
" if _metadata:",
|
|
" return AnnotatedResult(result=_typed, metadata=_metadata)",
|
|
" return _typed",
|
|
"",
|
|
]
|
|
)
|
|
fetcher_source_lines.extend(transform_data_lines)
|
|
parts.append("\n".join(fetcher_source_lines))
|
|
|
|
return "\n".join(parts).rstrip() + "\n"
|
|
|
|
|
|
def _aextract_body(
|
|
*,
|
|
method: str,
|
|
base_url: str,
|
|
url_path_template: str,
|
|
path_params: list[str],
|
|
cred_lines: list[str],
|
|
query_block: str,
|
|
header_block: str,
|
|
cookie_block: str,
|
|
body_field_names: list[str],
|
|
has_path_params: bool,
|
|
has_query_creds: bool,
|
|
) -> list[str]:
|
|
"""Render the body of ``aextract_data`` as a list of source lines.
|
|
|
|
Parameters
|
|
----------
|
|
method : str
|
|
``"get"`` or ``"post"``.
|
|
base_url : str
|
|
Upstream API root, no trailing slash.
|
|
url_path_template : str
|
|
URL path with ``{placeholder}`` substitutions intact.
|
|
path_params : list of str
|
|
Placeholder names in ``url_path_template``.
|
|
cred_lines : list of str
|
|
Credential-extraction source lines.
|
|
query_block : str
|
|
Pre-rendered query-dict construction block.
|
|
header_block : str
|
|
Pre-rendered headers-dict construction block.
|
|
cookie_block : str
|
|
Pre-rendered cookies-dict construction block.
|
|
body_field_names : list of str
|
|
Names of fields that should travel in the JSON request body.
|
|
has_path_params : bool
|
|
Whether the URL has placeholders to substitute.
|
|
has_query_creds : bool
|
|
Whether any credential lives in the query string.
|
|
|
|
Returns
|
|
-------
|
|
list of str
|
|
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, {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}")
|
|
lines.append("")
|
|
lines.append(query_block)
|
|
lines.append(" _query_string = get_querystring(_query_dict, [])")
|
|
lines.append(
|
|
f' _url = f"{base_url}{{_path}}" + ("?" + _query_string if _query_string else "")'
|
|
)
|
|
lines.append("")
|
|
lines.append(header_block)
|
|
lines.append("")
|
|
lines.append(cookie_block)
|
|
lines.append("")
|
|
if method == "post":
|
|
body_literal = (
|
|
"{"
|
|
+ ", ".join(
|
|
f"{name!r}: getattr(query, {safe_field_name(name)[0]!r}, None)"
|
|
for name in body_field_names
|
|
)
|
|
+ "}"
|
|
)
|
|
lines.append(f" _body = {body_literal}")
|
|
lines.append(' _method = "POST"')
|
|
lines.append(' _request_kwargs: dict[str, Any] = {"json": _body}')
|
|
else:
|
|
lines.append(' _method = "GET"')
|
|
lines.append(" _request_kwargs: dict[str, Any] = {}")
|
|
lines.append(" async with await get_async_requests_session() as _session:")
|
|
lines.append(
|
|
" async with await _session.request("
|
|
"_method, _url, headers=_headers, cookies=_cookies, **_request_kwargs) as _resp:"
|
|
)
|
|
lines.append(
|
|
" _ct = (_resp.headers.get('Content-Type') or '').lower()"
|
|
)
|
|
lines.append(" _text = await _resp.text()")
|
|
lines.append(" if _resp.status >= 400:")
|
|
lines.append(
|
|
" raise OpenBBError("
|
|
"f'HTTP {_resp.status} from {_url}: {_text[:500]}')"
|
|
)
|
|
lines.append(
|
|
" if 'json' in _ct or _text.lstrip().startswith(('{', '[')):"
|
|
)
|
|
lines.append(" try:")
|
|
lines.append(" _payload = safe_json_loads(_text)")
|
|
lines.append(" except ValueError as _exc:")
|
|
lines.append(
|
|
" raise OpenBBError("
|
|
"f'Upstream {_url} returned malformed JSON: {_exc}') from _exc"
|
|
)
|
|
lines.append(" else:")
|
|
lines.append(
|
|
" return {'rows': [{'content': _text, "
|
|
"'content_type': _ct}], 'metadata': {}}"
|
|
)
|
|
lines.append(" _rows, _metadata = unpack_response(_payload)")
|
|
lines.append(" return {'rows': _rows, 'metadata': _metadata}")
|
|
return lines
|