mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-28 13:01:38 +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 <>
1099 lines
37 KiB
Python
1099 lines
37 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
|
|
|
|
|
|
# --- _cookie_dict_construction ---
|
|
|
|
|
|
def test_cookie_dict_construction_emits_cookie_credentials():
|
|
creds = {"xero_session": {"name": "xero-session", "in": "cookie"}}
|
|
out = fg._cookie_dict_construction(creds)
|
|
assert "_cookies: dict[str, str] = {}" in out
|
|
assert "if _cred_xero_session:" in out
|
|
assert "_cookies['xero-session'] = _cred_xero_session" in out
|
|
|
|
|
|
def test_cookie_dict_construction_skips_header_and_query_credentials():
|
|
creds = {
|
|
"authorization": {"name": "Authorization", "in": "header"},
|
|
"api_key": {"name": "apikey", "in": "query"},
|
|
}
|
|
out = fg._cookie_dict_construction(creds)
|
|
assert "Authorization" not in out
|
|
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
|
|
|
|
|
|
def test_generate_fetcher_module_required_cookie_reaches_the_request():
|
|
"""A required path-item cookie (Xero's tenant-scoped session, e.g.) is sent.
|
|
|
|
Regression guard for the case ``operation_parameters`` intentionally never
|
|
fixes: a required header/cookie the path item declares for every operation
|
|
isn't a CLI/Python-interface argument, it's a stored credential — but it
|
|
still has to actually reach the outgoing request.
|
|
"""
|
|
spec = fg.FetcherCommandSpec(
|
|
name="accounting.accounts",
|
|
cmd_spec={
|
|
"parameters": [
|
|
{
|
|
"name": "xero-tenant-id",
|
|
"type": "string",
|
|
"in": "header",
|
|
"required": True,
|
|
},
|
|
{
|
|
"name": "xero-session",
|
|
"type": "string",
|
|
"in": "cookie",
|
|
"required": True,
|
|
},
|
|
],
|
|
"url_path": "/Accounts",
|
|
"method": "get",
|
|
"description": "List accounts.",
|
|
"response_schema": {"type": "object"},
|
|
},
|
|
base_url="https://api.xero.com/api.xro/2.0",
|
|
api_prefix="",
|
|
provider_name="xero",
|
|
)
|
|
out = fg.generate_fetcher_module(spec)
|
|
assert set(out.credentials_used) == {"xero_tenant_id", "xero_session"}
|
|
src = out.source
|
|
assert "_cred_xero_tenant_id" in src
|
|
assert "_cred_xero_session" in src
|
|
assert "_headers['xero-tenant-id'] = _cred_xero_tenant_id" in src
|
|
assert "_cookies['xero-session'] = _cred_xero_session" in src
|
|
assert "cookies=_cookies" in src
|
|
ast.parse(src)
|