mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-27 20:42:26 +00:00
1033 lines
35 KiB
Python
1033 lines
35 KiB
Python
"""Tests for openbb_cli.codegen.fetcher_gen — fetcher module emission."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
|
|
from openbb_cli.codegen import fetcher_gen as fg
|
|
|
|
# --- _module_name_from_command ---
|
|
|
|
|
|
def test_module_name_from_command_snake_cases_dotted_path():
|
|
assert fg._module_name_from_command("equity.price.historical") == (
|
|
"equity_price_historical"
|
|
)
|
|
|
|
|
|
def test_module_name_from_command_replaces_special_chars():
|
|
assert fg._module_name_from_command("Foo-Bar/Baz") == "foo_bar_baz"
|
|
|
|
|
|
def test_module_name_from_command_falls_back_when_empty():
|
|
assert fg._module_name_from_command("___") == "command"
|
|
|
|
|
|
def test_module_name_from_command_prefixes_underscore_when_starts_with_digit():
|
|
assert fg._module_name_from_command("3legged.path") == "_3legged_path"
|
|
|
|
|
|
# --- _path_template_keys ---
|
|
|
|
|
|
def test_path_template_keys_extracts_placeholders_in_order():
|
|
assert fg._path_template_keys("/v3/{congress}/bill/{billNumber}") == [
|
|
"congress",
|
|
"billNumber",
|
|
]
|
|
|
|
|
|
def test_path_template_keys_returns_empty_when_no_placeholders():
|
|
assert fg._path_template_keys("/static/path") == []
|
|
|
|
|
|
# --- _resolve_url_path ---
|
|
|
|
|
|
def test_resolve_url_path_appends_prefix_when_missing():
|
|
assert fg._resolve_url_path("/api/v1", "/equity/search") == "/api/v1/equity/search"
|
|
|
|
|
|
def test_resolve_url_path_skips_prefix_when_already_present():
|
|
assert (
|
|
fg._resolve_url_path("/api/v1", "/api/v1/equity/search")
|
|
== "/api/v1/equity/search"
|
|
)
|
|
|
|
|
|
def test_resolve_url_path_handles_blank_prefix():
|
|
assert fg._resolve_url_path("", "/equity/search") == "/equity/search"
|
|
|
|
|
|
def test_resolve_url_path_normalizes_missing_leading_slash():
|
|
assert fg._resolve_url_path("/api", "equity/search") == "/api/equity/search"
|
|
|
|
|
|
def test_resolve_url_path_keeps_path_when_prefix_partially_matches_without_trailing_sep():
|
|
# ``path == prefix`` (no trailing /) — don't double-prefix.
|
|
assert fg._resolve_url_path("/api/v1", "/api/v1") == "/api/v1"
|
|
|
|
|
|
# --- _query_params_schema ---
|
|
|
|
|
|
def test_query_params_schema_drops_provider_and_filtered_creds():
|
|
cmd = {
|
|
"parameters": [
|
|
{"name": "symbol", "type": "string", "required": True},
|
|
{"name": "provider", "type": "string"}, # discriminator, dropped
|
|
{"name": "api_key", "type": "string", "in": "query"}, # credential, dropped
|
|
]
|
|
}
|
|
out = fg._query_params_schema(cmd, provider_name="fmp")
|
|
assert "symbol" in out["properties"]
|
|
assert "provider" not in out["properties"]
|
|
assert "api_key" not in out["properties"]
|
|
assert out["required"] == ["symbol"]
|
|
|
|
|
|
def test_query_params_schema_filters_params_not_in_provider_list():
|
|
cmd = {
|
|
"parameters": [
|
|
{"name": "symbol", "type": "string"},
|
|
{"name": "use_cache", "type": "boolean", "providers": ["cboe"]},
|
|
]
|
|
}
|
|
out = fg._query_params_schema(cmd, provider_name="fmp")
|
|
assert "symbol" in out["properties"]
|
|
# use_cache is cboe-only — must not leak into fmp
|
|
assert "use_cache" not in out["properties"]
|
|
|
|
|
|
def test_query_params_schema_keeps_param_when_provider_matches():
|
|
cmd = {
|
|
"parameters": [
|
|
{"name": "use_cache", "type": "boolean", "providers": ["cboe", "fmp"]},
|
|
]
|
|
}
|
|
out = fg._query_params_schema(cmd, provider_name="cboe")
|
|
assert "use_cache" in out["properties"]
|
|
|
|
|
|
def test_query_params_schema_strips_trailing_provider_tag_from_help():
|
|
cmd = {
|
|
"parameters": [
|
|
{
|
|
"name": "symbol",
|
|
"type": "string",
|
|
"help": "The ticker symbol. (provider: fmp)",
|
|
},
|
|
]
|
|
}
|
|
out = fg._query_params_schema(cmd, provider_name="fmp")
|
|
assert out["properties"]["symbol"]["description"] == "The ticker symbol."
|
|
|
|
|
|
def test_query_params_schema_falls_back_to_description_when_help_missing():
|
|
cmd = {
|
|
"parameters": [
|
|
{"name": "symbol", "type": "string", "description": "Ticker."},
|
|
]
|
|
}
|
|
out = fg._query_params_schema(cmd, provider_name="fmp")
|
|
assert out["properties"]["symbol"]["description"] == "Ticker."
|
|
|
|
|
|
def test_query_params_schema_handles_list_param_with_choices():
|
|
cmd = {
|
|
"parameters": [
|
|
{
|
|
"name": "tickers",
|
|
"type": "string",
|
|
"is_list": True,
|
|
"choices": ["AAPL", "MSFT"],
|
|
"help": "Tickers.",
|
|
},
|
|
]
|
|
}
|
|
out = fg._query_params_schema(cmd, provider_name="fmp")
|
|
tickers = out["properties"]["tickers"]
|
|
assert tickers["type"] == "array"
|
|
assert tickers["items"] == {"type": "string"}
|
|
assert tickers["enum"] == ["AAPL", "MSFT"]
|
|
assert tickers["description"] == "Tickers."
|
|
|
|
|
|
def test_query_params_schema_skips_unnamed_parameters():
|
|
cmd = {"parameters": [{"type": "string"}, {"name": "ok", "type": "string"}]}
|
|
out = fg._query_params_schema(cmd, provider_name="fmp")
|
|
assert list(out["properties"]) == ["ok"]
|
|
|
|
|
|
def test_query_params_schema_merges_request_body_properties():
|
|
cmd = {
|
|
"parameters": [{"name": "symbol", "type": "string"}],
|
|
"request_body_schema": {
|
|
"properties": {
|
|
"data": {"type": "array", "items": {"type": "object"}},
|
|
},
|
|
"required": ["data"],
|
|
},
|
|
}
|
|
out = fg._query_params_schema(cmd, provider_name="tools")
|
|
assert "symbol" in out["properties"]
|
|
assert "data" in out["properties"]
|
|
assert "data" in out["required"]
|
|
|
|
|
|
def test_query_params_schema_does_not_overwrite_existing_property_from_body():
|
|
cmd = {
|
|
"parameters": [{"name": "data", "type": "string"}],
|
|
"request_body_schema": {
|
|
"properties": {"data": {"type": "array"}},
|
|
"required": ["data"],
|
|
},
|
|
}
|
|
out = fg._query_params_schema(cmd, provider_name="tools")
|
|
# Original parameter wins; body version is skipped.
|
|
assert out["properties"]["data"] == {"type": "string"}
|
|
assert out["required"] == []
|
|
|
|
|
|
def test_query_params_schema_marks_required_param():
|
|
cmd = {
|
|
"parameters": [{"name": "symbol", "type": "string", "required": True}],
|
|
}
|
|
out = fg._query_params_schema(cmd, provider_name="fmp")
|
|
assert out["required"] == ["symbol"]
|
|
|
|
|
|
def test_query_params_schema_promotes_path_number_to_integer():
|
|
"""Path-typed ``number`` is integer in practice -- routes match int form only."""
|
|
cmd = {
|
|
"parameters": [
|
|
{"name": "n", "in": "path", "type": "number", "required": True},
|
|
]
|
|
}
|
|
out = fg._query_params_schema(cmd, provider_name="fmp")
|
|
assert out["properties"]["n"]["type"] == "integer"
|
|
|
|
|
|
def test_query_params_schema_keeps_non_path_number_as_number():
|
|
cmd = {
|
|
"parameters": [
|
|
{"name": "ratio", "in": "query", "type": "number", "required": True},
|
|
]
|
|
}
|
|
out = fg._query_params_schema(cmd, provider_name="fmp")
|
|
assert out["properties"]["ratio"]["type"] == "number"
|
|
|
|
|
|
# --- _data_schema ---
|
|
|
|
|
|
def test_data_schema_descends_into_obbject_anyof_array_items():
|
|
response = {
|
|
"properties": {
|
|
"results": {
|
|
"anyOf": [
|
|
{"type": "null"},
|
|
{
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {"symbol": {"type": "string"}},
|
|
},
|
|
},
|
|
]
|
|
}
|
|
}
|
|
}
|
|
out = fg._data_schema({"response_schema": response})
|
|
assert out == {
|
|
"type": "object",
|
|
"properties": {"symbol": {"type": "string"}},
|
|
}
|
|
|
|
|
|
def test_data_schema_descends_into_oneof_inside_results():
|
|
response = {
|
|
"properties": {
|
|
"results": {
|
|
"type": "array",
|
|
"items": {
|
|
"oneOf": [
|
|
{"type": "object", "properties": {"a": {"type": "string"}}},
|
|
{"type": "object", "properties": {"b": {"type": "integer"}}},
|
|
]
|
|
},
|
|
}
|
|
}
|
|
}
|
|
out = fg._data_schema({"response_schema": response})
|
|
assert out["properties"] == {"a": {"type": "string"}}
|
|
|
|
|
|
def test_data_schema_returns_top_level_for_multi_field_non_obbject_response():
|
|
"""Multi-property dict with no clear single data array -> whole dict is the row."""
|
|
response = {
|
|
"type": "object",
|
|
"properties": {"x": {"type": "integer"}, "y": {"type": "string"}},
|
|
}
|
|
out = fg._data_schema({"response_schema": response})
|
|
assert out == response
|
|
|
|
|
|
def test_data_schema_unwraps_single_key_envelope():
|
|
"""``{"refRates": [...rate...]}`` -> rate item schema."""
|
|
response = {
|
|
"type": "object",
|
|
"properties": {
|
|
"refRates": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {"effectiveDate": {"type": "string"}},
|
|
},
|
|
}
|
|
},
|
|
}
|
|
out = fg._data_schema({"response_schema": response})
|
|
assert out == {
|
|
"type": "object",
|
|
"properties": {"effectiveDate": {"type": "string"}},
|
|
}
|
|
|
|
|
|
def test_data_schema_unwraps_top_level_array():
|
|
response = {
|
|
"type": "array",
|
|
"items": {"type": "object", "properties": {"a": {"type": "integer"}}},
|
|
}
|
|
out = fg._data_schema({"response_schema": response})
|
|
assert out == {"type": "object", "properties": {"a": {"type": "integer"}}}
|
|
|
|
|
|
def test_data_schema_recurses_into_nested_single_key_envelope():
|
|
"""Two layers of single-key envelope unwrap fully."""
|
|
response = {
|
|
"type": "object",
|
|
"properties": {
|
|
"outer": {
|
|
"type": "object",
|
|
"properties": {
|
|
"inner": {
|
|
"type": "object",
|
|
"properties": {"v": {"type": "integer"}},
|
|
}
|
|
},
|
|
}
|
|
},
|
|
}
|
|
out = fg._data_schema({"response_schema": response})
|
|
assert out == {"type": "object", "properties": {"v": {"type": "integer"}}}
|
|
|
|
|
|
def test_data_schema_keeps_array_with_non_dict_items():
|
|
"""Single array property whose items aren't a dict -> nothing to unwrap."""
|
|
response = {
|
|
"type": "object",
|
|
"properties": {"refRates": {"type": "array", "items": "scalar"}},
|
|
}
|
|
out = fg._data_schema({"response_schema": response})
|
|
assert out == response
|
|
|
|
|
|
def test_data_schema_keeps_array_without_items():
|
|
"""Single array property with no items schema -> nothing to unwrap."""
|
|
response = {
|
|
"type": "object",
|
|
"properties": {"refRates": {"type": "array"}},
|
|
}
|
|
out = fg._data_schema({"response_schema": response})
|
|
assert out == response
|
|
|
|
|
|
def test_data_schema_keeps_single_key_with_scalar_value():
|
|
"""Single property whose value isn't a dict -> nothing to unwrap."""
|
|
response = {
|
|
"type": "object",
|
|
"properties": {"x": "not a dict"},
|
|
}
|
|
out = fg._data_schema({"response_schema": response})
|
|
assert out == response
|
|
|
|
|
|
def test_data_schema_returns_schema_when_no_properties():
|
|
response = {"type": "object"}
|
|
out = fg._data_schema({"response_schema": response})
|
|
assert out == response
|
|
|
|
|
|
def test_data_schema_top_level_array_with_non_dict_items_returns_empty():
|
|
response = {"type": "array", "items": "scalar"}
|
|
out = fg._data_schema({"response_schema": response})
|
|
assert out == {}
|
|
|
|
|
|
def test_data_schema_unwraps_array_of_scalars_to_value_field():
|
|
"""``{"asOfDates": ["2026-01-01", ...]}`` → Data class with ``value: str`` field."""
|
|
response = {
|
|
"type": "object",
|
|
"properties": {"asOfDates": {"type": "array", "items": {"type": "string"}}},
|
|
}
|
|
out = fg._data_schema({"response_schema": response})
|
|
assert out == {
|
|
"type": "object",
|
|
"properties": {"value": {"type": "string"}},
|
|
"required": ["value"],
|
|
}
|
|
|
|
|
|
def test_data_schema_top_level_array_of_scalars_unwraps_to_value_field():
|
|
response = {"type": "array", "items": {"type": "integer"}}
|
|
out = fg._data_schema({"response_schema": response})
|
|
assert out == {
|
|
"type": "object",
|
|
"properties": {"value": {"type": "integer"}},
|
|
"required": ["value"],
|
|
}
|
|
|
|
|
|
def test_data_schema_descends_into_single_array_among_scalar_siblings():
|
|
"""Multi-property row with exactly one array property → descend into
|
|
the array's items, mirroring runtime ``unpack_response`` behavior
|
|
(single array among scalar siblings = the data, siblings = metadata).
|
|
The auction-shaped wrapper dissolves to the inner ``details`` shape.
|
|
"""
|
|
response = {
|
|
"type": "object",
|
|
"properties": {
|
|
"ambs": {
|
|
"type": "object",
|
|
"properties": {
|
|
"auctions": {
|
|
"type": "array",
|
|
"items": {
|
|
"properties": {
|
|
"auctionStatus": {"type": "string"},
|
|
"operationId": {"type": "string"},
|
|
"operationDate": {"type": "string"},
|
|
"details": {
|
|
"type": "array",
|
|
"items": {
|
|
"properties": {"flag": {"type": "string"}}
|
|
},
|
|
},
|
|
}
|
|
},
|
|
}
|
|
},
|
|
}
|
|
},
|
|
}
|
|
out = fg._data_schema({"response_schema": response})
|
|
# Descended ambs → auctions (single-property envelopes) → auction items
|
|
# (multi-property with one array sibling ``details``) → details items.
|
|
assert "flag" in out.get("properties", {})
|
|
assert "auctionStatus" not in out.get("properties", {})
|
|
|
|
|
|
def test_data_schema_descends_oneOf_to_first_concrete_variant():
|
|
"""Top-level ``oneOf`` (after envelope strip) selects the first dict variant."""
|
|
response = {
|
|
"type": "object",
|
|
"properties": {
|
|
"wrapper": {
|
|
"type": "array",
|
|
"items": {
|
|
"oneOf": [
|
|
{"properties": {"a": {"type": "string"}}},
|
|
{"properties": {"b": {"type": "integer"}}},
|
|
]
|
|
},
|
|
}
|
|
},
|
|
}
|
|
out = fg._data_schema({"response_schema": response})
|
|
assert out.get("properties") == {"a": {"type": "string"}}
|
|
|
|
|
|
def test_unwrap_schema_envelopes_returns_empty_for_non_dict_input():
|
|
assert fg._unwrap_schema_envelopes("not a dict") == {} # type: ignore[arg-type]
|
|
assert fg._unwrap_schema_envelopes(None) == {} # type: ignore[arg-type]
|
|
|
|
|
|
def test_data_schema_returns_empty_when_no_response():
|
|
assert fg._data_schema({}) == {}
|
|
|
|
|
|
def test_data_schema_returns_empty_for_non_dict_response():
|
|
assert fg._data_schema({"response_schema": "not a dict"}) == {}
|
|
|
|
|
|
def test_data_schema_returns_response_when_results_field_is_not_dict():
|
|
response = {"properties": {"results": "not a dict"}}
|
|
out = fg._data_schema({"response_schema": response})
|
|
assert out == response
|
|
|
|
|
|
def test_data_schema_descends_through_anyof_single_property_envelope():
|
|
"""``results.anyOf`` is a recognized envelope shape — even when the only
|
|
variant is ``null``, the envelope is unwrapped (yielding a degenerate
|
|
schema) so codegen doesn't emit ``results: Any`` as a real field."""
|
|
response = {"properties": {"results": {"anyOf": [{"type": "null"}]}}}
|
|
out = fg._data_schema({"response_schema": response})
|
|
assert out == {"anyOf": [{"type": "null"}]}
|
|
|
|
|
|
# --- _credential_lookup_lines ---
|
|
|
|
|
|
def test_credential_lookup_lines_emits_one_assignment_per_credential():
|
|
creds = {
|
|
"api_key": {"name": "apikey", "in": "query"},
|
|
"authorization": {"name": "Authorization", "in": "header"},
|
|
}
|
|
lines = fg._credential_lookup_lines(creds, provider_name="myprov")
|
|
assert lines == [
|
|
' _cred_api_key = _creds.get("myprov_api_key", "")',
|
|
' _cred_authorization = _creds.get("myprov_authorization", "")',
|
|
]
|
|
|
|
|
|
# --- _query_dict_construction ---
|
|
|
|
|
|
def test_query_dict_construction_excludes_path_and_body_fields():
|
|
cmd = {
|
|
"request_body_schema": {"properties": {"data": {"type": "array"}}},
|
|
}
|
|
out = fg._query_dict_construction(
|
|
cmd, creds={}, path_params=["symbol"], provider_name="fmp"
|
|
)
|
|
assert "exclude={'data', 'symbol'}" in out
|
|
|
|
|
|
def test_query_dict_construction_no_excludes_when_no_path_or_body():
|
|
out = fg._query_dict_construction({}, creds={}, path_params=[], provider_name="fmp")
|
|
# ``by_alias=True`` is always emitted so wire names with non-identifier
|
|
# characters (Socrata's ``$select`` / ``$where``) survive serialization;
|
|
# fields without aliases are unaffected since their alias equals the
|
|
# field name.
|
|
assert (
|
|
out
|
|
== " _query_dict = query.model_dump(by_alias=True, exclude_none=True)"
|
|
)
|
|
|
|
|
|
def test_query_dict_construction_emits_by_alias_for_aliased_wire_names():
|
|
"""Excluded-fields branch carries ``by_alias=True`` too."""
|
|
cmd = {"request_body_schema": {"properties": {"data": {"type": "array"}}}}
|
|
out = fg._query_dict_construction(
|
|
cmd, creds={}, path_params=["symbol"], provider_name="fmp"
|
|
)
|
|
assert "by_alias=True" in out
|
|
|
|
|
|
def test_query_dict_construction_pins_provider_when_command_declares_one():
|
|
cmd = {"parameters": [{"name": "provider", "type": "string"}]}
|
|
out = fg._query_dict_construction(
|
|
cmd, creds={}, path_params=[], provider_name="fmp"
|
|
)
|
|
assert "_query_dict[\"provider\"] = 'fmp'" in out
|
|
|
|
|
|
def test_query_dict_construction_merges_query_credentials():
|
|
creds = {"api_key": {"name": "apikey", "in": "query"}}
|
|
out = fg._query_dict_construction(
|
|
{}, creds=creds, path_params=[], provider_name="fmp"
|
|
)
|
|
assert "if _cred_api_key:" in out
|
|
assert "_query_dict['apikey'] = _cred_api_key" in out
|
|
|
|
|
|
def test_query_dict_construction_skips_header_credentials():
|
|
creds = {"authorization": {"name": "Authorization", "in": "header"}}
|
|
out = fg._query_dict_construction(
|
|
{}, creds=creds, path_params=[], provider_name="fmp"
|
|
)
|
|
# Header-credential should not be merged into the query string
|
|
assert "Authorization" not in out
|
|
|
|
|
|
def test_query_dict_construction_emits_wire_name_renames():
|
|
"""Socrata pagination uses clean ``limit`` / ``offset`` user-facing
|
|
names with ``wire_name='$limit'`` / ``'$offset'``; fetcher_gen has to
|
|
emit a runtime rewrite so the URL carries the SoQL-prefixed form."""
|
|
cmd = {
|
|
"parameters": [
|
|
{"name": "limit", "in": "query", "type": "integer", "wire_name": "$limit"},
|
|
{
|
|
"name": "offset",
|
|
"in": "query",
|
|
"type": "integer",
|
|
"wire_name": "$offset",
|
|
},
|
|
]
|
|
}
|
|
out = fg._query_dict_construction(
|
|
cmd, creds={}, path_params=[], provider_name="socrata"
|
|
)
|
|
# Rename loop is emitted and lists both rewrites.
|
|
assert "for _src, _dst in (" in out
|
|
assert "('limit', '$limit')" in out
|
|
assert "('offset', '$offset')" in out
|
|
assert "_query_dict[_dst] = _query_dict.pop(_src)" in out
|
|
|
|
|
|
def test_query_dict_construction_skips_wire_name_rename_when_already_matching():
|
|
"""If ``wire_name`` equals ``name`` (no actual rename needed), the
|
|
rewrite block is omitted entirely."""
|
|
cmd = {
|
|
"parameters": [
|
|
{"name": "limit", "in": "query", "type": "integer", "wire_name": "limit"},
|
|
]
|
|
}
|
|
out = fg._query_dict_construction(
|
|
cmd, creds={}, path_params=[], provider_name="socrata"
|
|
)
|
|
assert "for _src, _dst in (" not in out
|
|
|
|
|
|
def test_query_dict_construction_ignores_non_dict_parameter_entries():
|
|
"""Defensive: a stray non-dict in ``cmd_spec.parameters`` doesn't
|
|
crash the rename loop — it gets skipped silently."""
|
|
cmd = {
|
|
"parameters": [
|
|
"not-a-dict",
|
|
{"name": "limit", "type": "integer", "wire_name": "$limit"},
|
|
]
|
|
}
|
|
out = fg._query_dict_construction(
|
|
cmd, creds={}, path_params=[], provider_name="socrata"
|
|
)
|
|
assert "('limit', '$limit')" in out
|
|
|
|
|
|
# --- _socrata_where_clause_lines ---
|
|
|
|
|
|
def test_socrata_where_clause_emits_runtime_lines_for_date_range_params():
|
|
"""Socrata range params (``start_date`` / ``end_date``) compose into a
|
|
single SoQL ``$where`` clause at runtime — the fetcher emits the
|
|
builder code that runs against the validated query model."""
|
|
range_params = [
|
|
{
|
|
"name": "start_date",
|
|
"_socrata_op": "date_min",
|
|
"_socrata_column": "month_year",
|
|
},
|
|
{
|
|
"name": "end_date",
|
|
"_socrata_op": "date_max",
|
|
"_socrata_column": "month_year",
|
|
},
|
|
]
|
|
out_lines = fg._socrata_where_clause_lines(range_params)
|
|
out = "\n".join(out_lines)
|
|
# Each range param contributes a typed lookup against the query model.
|
|
assert "_range_start_date = getattr(query, 'start_date', None)" in out
|
|
assert "_range_end_date = getattr(query, 'end_date', None)" in out
|
|
# The SoQL operators reflect the date_min / date_max marker.
|
|
assert "month_year >=" in out
|
|
assert "month_year <=" in out
|
|
# Final ``$where`` is composed (with AND-prefixing if a clause already
|
|
# came in via the user) and lands in ``_query_dict['$where']``.
|
|
assert "_query_dict['$where'] = _socrata_where" in out
|
|
assert "_existing_where" in out
|
|
|
|
|
|
def test_column_metadata_from_data_schema_extracts_units_and_format_hints():
|
|
"""Per-column ``description`` (carries units like 'dollars per ton')
|
|
and ``socrata_format`` (currency / decimal-separator rendering hints)
|
|
flow into ``AnnotatedResult.metadata['columns']``."""
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"price": {
|
|
"type": "number",
|
|
"description": "Price, measured in dollars per ton.",
|
|
"socrata_format": {"precisionStyle": "currency"},
|
|
},
|
|
"region": {"type": "string", "description": "Region name."},
|
|
"no_meta": {"type": "string"},
|
|
},
|
|
}
|
|
out = fg._column_metadata_from_data_schema(schema)
|
|
assert out == {
|
|
"price": {
|
|
"description": "Price, measured in dollars per ton.",
|
|
"socrata_format": {"precisionStyle": "currency"},
|
|
},
|
|
"region": {"description": "Region name."},
|
|
}
|
|
# Columns with no metadata are omitted entirely — keeps the
|
|
# constant embedded in the fetcher source compact.
|
|
assert "no_meta" not in out
|
|
|
|
|
|
def test_column_metadata_from_data_schema_skips_non_dict_property_entries():
|
|
"""Defensive: a non-dict property entry (malformed schema) gets skipped
|
|
rather than blowing up the codegen pass. ``format`` alone (no
|
|
description / socrata_format) still survives so format hints don't
|
|
silently disappear.
|
|
"""
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"ok": {"type": "string", "format": "date"},
|
|
"garbage": "not-a-dict", # skipped by the defensive guard
|
|
},
|
|
}
|
|
out = fg._column_metadata_from_data_schema(schema)
|
|
assert out == {"ok": {"format": "date"}}
|
|
|
|
|
|
def test_column_metadata_from_data_schema_handles_none_input():
|
|
"""``None`` input → empty mapping. The codegen path passes
|
|
``data_schema or {}`` for safety, so this should be a no-op."""
|
|
assert fg._column_metadata_from_data_schema(None) == {}
|
|
assert fg._column_metadata_from_data_schema({}) == {}
|
|
|
|
|
|
def test_generate_fetcher_module_embeds_column_metadata_in_annotated_result():
|
|
"""When a spec carries column descriptions / format hints, the
|
|
generated ``transform_data`` always returns ``AnnotatedResult``
|
|
with a ``columns`` key under ``metadata`` (so callers can read
|
|
``obbject.extra['results_metadata']['columns']``)."""
|
|
cmd_spec = {
|
|
"url_path": "/resource/abc.json",
|
|
"method": "get",
|
|
"parameters": [],
|
|
"request_body_schema": None,
|
|
"response_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"results": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"price": {
|
|
"type": "number",
|
|
"description": "Price in dollars per ton.",
|
|
"socrata_format": {"precisionStyle": "currency"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
}
|
|
spec = fg.FetcherCommandSpec(
|
|
name="prices.query",
|
|
cmd_spec=cmd_spec,
|
|
base_url="https://x",
|
|
api_prefix="",
|
|
provider_name="prov",
|
|
)
|
|
out = fg.generate_fetcher_module(spec)
|
|
src = out.source
|
|
# ``AnnotatedResult`` always returned (since ``_metadata`` is now
|
|
# always populated with the columns dict) — ensure the column
|
|
# metadata literal lands verbatim.
|
|
assert "'price'" in src
|
|
assert "'Price in dollars per ton.'" in src
|
|
assert "'precisionStyle': 'currency'" in src
|
|
assert "_metadata.setdefault('columns', _column_metadata)" in src
|
|
|
|
|
|
def test_generate_fetcher_module_skips_column_metadata_when_no_descriptions():
|
|
"""A spec with no per-column descriptions / format hints falls
|
|
back to the original ``return _typed`` path — no empty constant
|
|
embedded, no AnnotatedResult on every response."""
|
|
cmd_spec = {
|
|
"url_path": "/x.json",
|
|
"method": "get",
|
|
"parameters": [],
|
|
"request_body_schema": None,
|
|
"response_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"results": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {"x": {"type": "string"}},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
}
|
|
spec = fg.FetcherCommandSpec(
|
|
name="bare.query",
|
|
cmd_spec=cmd_spec,
|
|
base_url="https://x",
|
|
api_prefix="",
|
|
provider_name="prov",
|
|
)
|
|
out = fg.generate_fetcher_module(spec)
|
|
assert "_column_metadata" not in out.source
|
|
|
|
|
|
def test_socrata_range_params_default_order_to_date_column_descending():
|
|
"""When the spec carries date-range params, the generated fetcher
|
|
defaults ``$order`` to ``<date_col> DESC`` — so ``limit=N`` returns
|
|
the most recent N records instead of arbitrary storage-order rows."""
|
|
cmd = {
|
|
"parameters": [
|
|
{
|
|
"name": "start_date",
|
|
"_socrata_op": "date_min",
|
|
"_socrata_column": "month_year",
|
|
},
|
|
{
|
|
"name": "end_date",
|
|
"_socrata_op": "date_max",
|
|
"_socrata_column": "month_year",
|
|
},
|
|
]
|
|
}
|
|
out = fg._query_dict_construction(
|
|
cmd, creds={}, path_params=[], provider_name="socrata"
|
|
)
|
|
# ``setdefault`` so a user-supplied $order isn't overwritten.
|
|
assert "_query_dict.setdefault('$order', 'month_year' + ' DESC')" in out
|
|
|
|
|
|
def test_query_dict_construction_threads_socrata_range_params_into_where_clause():
|
|
"""End-to-end: ``_query_dict_construction`` invokes
|
|
``_socrata_where_clause_lines`` when range params are present AND
|
|
excludes those params from ``model_dump`` (they don't ride the wire
|
|
as flat ``?<name>=<value>`` pairs)."""
|
|
cmd = {
|
|
"parameters": [
|
|
{
|
|
"name": "start_date",
|
|
"_socrata_op": "date_min",
|
|
"_socrata_column": "ts",
|
|
},
|
|
{
|
|
"name": "end_date",
|
|
"_socrata_op": "date_max",
|
|
"_socrata_column": "ts",
|
|
},
|
|
]
|
|
}
|
|
out = fg._query_dict_construction(
|
|
cmd, creds={}, path_params=[], provider_name="socrata"
|
|
)
|
|
# Range params land in the model_dump exclusion set (so they don't
|
|
# double-up as URL params) AND show up in the $where builder.
|
|
assert "exclude={'end_date', 'start_date'}" in out
|
|
assert "_socrata_where_parts" in out
|
|
assert "ts >=" in out
|
|
assert "ts <=" in out
|
|
|
|
|
|
# --- _header_dict_construction ---
|
|
|
|
|
|
def test_header_dict_construction_emits_header_credentials():
|
|
creds = {"authorization": {"name": "Authorization", "in": "header"}}
|
|
out = fg._header_dict_construction(creds)
|
|
assert "_headers: dict[str, str] = {}" in out
|
|
assert "if _cred_authorization:" in out
|
|
assert "_headers['Authorization'] = _cred_authorization" in out
|
|
|
|
|
|
def test_header_dict_construction_skips_query_credentials():
|
|
creds = {"api_key": {"name": "apikey", "in": "query"}}
|
|
out = fg._header_dict_construction(creds)
|
|
assert "apikey" not in out
|
|
|
|
|
|
# --- generate_fetcher_module (end-to-end) ---
|
|
|
|
|
|
def test_generate_fetcher_module_get_endpoint_emits_valid_python():
|
|
spec = fg.FetcherCommandSpec(
|
|
name="equity.search",
|
|
cmd_spec={
|
|
"parameters": [
|
|
{"name": "query", "type": "string", "required": True, "help": "Search."}
|
|
],
|
|
"url_path": "/equity/search",
|
|
"method": "get",
|
|
"description": "Search equities.",
|
|
"response_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"results": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {"symbol": {"type": "string"}},
|
|
},
|
|
}
|
|
},
|
|
},
|
|
},
|
|
base_url="https://api.example.com/",
|
|
api_prefix="api/v1",
|
|
provider_name="fmp",
|
|
)
|
|
out = fg.generate_fetcher_module(spec)
|
|
|
|
assert isinstance(out, fg.GeneratedFetcher)
|
|
assert out.module_name == "equity_search"
|
|
assert out.model_name == "EquitySearch"
|
|
assert out.query_params_class == "EquitySearchQueryParams"
|
|
assert out.data_class == "EquitySearchData"
|
|
assert out.fetcher_class == "EquitySearchFetcher"
|
|
|
|
src = out.source
|
|
ast.parse(src)
|
|
assert (
|
|
"class EquitySearchFetcher(Fetcher[EquitySearchQueryParams, "
|
|
"list[EquitySearchData]])"
|
|
) in src
|
|
assert "class EquitySearchQueryParams(QueryParams)" in src
|
|
assert "class EquitySearchData(Data)" in src
|
|
assert "https://api.example.com/api/v1/equity/search" in src
|
|
assert "_session.request(_method, _url" in src
|
|
assert '_method = "GET"' in src
|
|
assert "get_async_requests_session" in src
|
|
assert "raise OpenBBError(" in src
|
|
|
|
|
|
def test_generate_fetcher_module_streaming_endpoint_emits_stream_data():
|
|
spec = fg.FetcherCommandSpec(
|
|
name="crypto.feed",
|
|
cmd_spec={
|
|
"parameters": [
|
|
{"name": "limit", "type": "integer", "help": "Number of ticks."}
|
|
],
|
|
"url_path": "/crypto/feed",
|
|
"method": "get",
|
|
"description": "Live crypto feed.",
|
|
"response_schemas": {
|
|
"200": {"text/event-stream": {"schema": {"type": "string"}}}
|
|
},
|
|
"response_schema": {
|
|
"type": "object",
|
|
"properties": {"tick": {"type": "integer"}},
|
|
},
|
|
},
|
|
base_url="https://api.example.com/",
|
|
api_prefix="api/v1",
|
|
provider_name="wargame",
|
|
)
|
|
out = fg.generate_fetcher_module(spec)
|
|
|
|
assert out.is_streaming is True
|
|
src = out.source
|
|
ast.parse(src)
|
|
assert "AsyncIterator" in src
|
|
assert "async def aextract_data" in src
|
|
assert "async def stream_data" in src
|
|
assert "httpx.AsyncClient" in src
|
|
|
|
|
|
def test_generate_fetcher_module_path_params_substituted_into_url():
|
|
spec = fg.FetcherCommandSpec(
|
|
name="us_congress.bill",
|
|
cmd_spec={
|
|
"parameters": [
|
|
{
|
|
"name": "congress",
|
|
"type": "integer",
|
|
"required": True,
|
|
"help": "Congress number.",
|
|
}
|
|
],
|
|
"url_path": "/v3/bill/{congress}",
|
|
"method": "get",
|
|
"description": "Bill data.",
|
|
"response_schema": {"type": "object"},
|
|
},
|
|
base_url="https://api.congress.gov",
|
|
api_prefix="",
|
|
provider_name="us_congress",
|
|
)
|
|
out = fg.generate_fetcher_module(spec)
|
|
assert "'/v3/bill/{congress}'.format(congress=getattr(query, 'congress'))" in (
|
|
out.source
|
|
)
|
|
ast.parse(out.source)
|
|
|
|
|
|
def test_generate_fetcher_module_post_endpoint_emits_json_body_call():
|
|
spec = fg.FetcherCommandSpec(
|
|
name="econometrics.regression",
|
|
cmd_spec={
|
|
"parameters": [],
|
|
"url_path": "/econometrics/regression",
|
|
"method": "post",
|
|
"description": "Linear regression.",
|
|
"request_body_schema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"data": {"type": "array", "items": {"type": "object"}},
|
|
"target": {"type": "string"},
|
|
},
|
|
"required": ["data", "target"],
|
|
},
|
|
"response_schema": {"type": "object"},
|
|
},
|
|
base_url="https://api.example.com",
|
|
api_prefix="",
|
|
provider_name="tools",
|
|
)
|
|
out = fg.generate_fetcher_module(spec)
|
|
src = out.source
|
|
assert '_method = "POST"' in src
|
|
assert '"json": _body' in src
|
|
assert "_body = {" in src
|
|
assert "_session.request(_method, _url" in src
|
|
ast.parse(src)
|
|
|
|
|
|
def test_generate_fetcher_module_uses_default_description_when_missing():
|
|
spec = fg.FetcherCommandSpec(
|
|
name="x.y",
|
|
cmd_spec={
|
|
"parameters": [],
|
|
"url_path": "/x/y",
|
|
"method": "get",
|
|
"response_schema": {"type": "object"},
|
|
},
|
|
base_url="https://api.example.com",
|
|
api_prefix="",
|
|
provider_name="fmp",
|
|
)
|
|
out = fg.generate_fetcher_module(spec)
|
|
# Falls back to ``Fetch <name>.``
|
|
assert "Fetch x.y." in out.source
|
|
|
|
|
|
def test_generate_fetcher_module_credentials_round_trip_to_query_string():
|
|
spec = fg.FetcherCommandSpec(
|
|
name="equity.search",
|
|
cmd_spec={
|
|
"parameters": [
|
|
{"name": "query", "type": "string", "required": True},
|
|
{"name": "apikey", "type": "string", "in": "query"},
|
|
],
|
|
"url_path": "/equity/search",
|
|
"method": "get",
|
|
"description": "Search.",
|
|
"response_schema": {"type": "object"},
|
|
},
|
|
base_url="https://api.example.com",
|
|
api_prefix="",
|
|
provider_name="fmp",
|
|
)
|
|
out = fg.generate_fetcher_module(spec)
|
|
canonical = next(iter(out.credentials_used))
|
|
assert out.credentials_used[canonical]["name"] == "apikey"
|
|
src = out.source
|
|
assert f'_cred_{canonical} = _creds.get("fmp_{canonical}", "")' in src
|
|
assert f"_query_dict['apikey'] = _cred_{canonical}" in src
|