Repository navigation
fix(models): build INTERNAL results that carry a column the model does not declare - #159
Conversation
…s not declare The API adds columns without an SDK release, and each one made every INTERNAL call of its resource raise ParseError. `_to_fields` is now the one place that keys an answer by field: it leaves out the keys that name no column or field of the model, logs them once per answer at DEBUG, and every single-request INTERNAL resource builds through it. An answer that is not an object, or that carries none of the model's columns, comes back as it is, so the model still refuses it. The CSV merge of stocks.candles and options.quotes keeps such a column, as every other CSV does, and refuses only a header with none of the resource's columns.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #159 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 62 62
Lines 3208 3297 +89
=========================================
+ Hits 3208 3297 +89
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…rectly stocks.prices builds its rows with the constructor now, so neither from_dict ran in the suite and Codecov reported both lines uncovered. Each test builds one row that carries a column the model does not declare.
✅ Ready — Code reviewThis adds Rating patch 6/6 (exemplary) · proof 6/6 (comprehensive) · overall 6/6 Since last review
Reviewed |
Every module-level name without a leading underscore is part of the SDK's public surface, so `logger` added `marketdata.output_types.columns.logger`. `_logger` is the same `marketdata.logger` logger under a private name.
…d format options.chain, options.expirations, options.lookup and stocks.earnings raised ParseError for a body that is not a JSON object only on INTERNAL: JSON returned the body and DATAFRAME built a frame from it, an empty one for null. Their three decoded formats now decode through one check and raise the same ParseError. The CSV file stays the API's text as it came, since that format decodes nothing.
An INTERNAL answer that carries a column its model does not declare keeps it: marketdata.get_extra(model) returns it as the API sent it, the whole column on a single-object model and each row's own value on a list result, and the DEBUG line says where it went. The from_dict of StockEarnings, StockPrice, StockQuote and their human-readable twins keep such a key the same way. The fan-outs merge only the columns their model declares, so their INTERNAL, JSON and DATAFRAME output still leave it out. The CSV output of options.chain, options.expirations, options.lookup and stocks.earnings refuses a header row with none of the resource's columns, the rule the fan-out merge applies, so a body that is not an answer raises ParseError on every format. BREAKING CHANGE: an INTERNAL call whose answer carries a column the model does not declare returns the model, where it raised TypeError, and the from_dict methods accept such a key, where they raised TypeError. options.chain, options.expirations, options.lookup and stocks.earnings raise ParseError for a body that is not a JSON object on JSON and DATAFRAME, where they returned it, and for a CSV header row with none of their columns, where they wrote the file. The CSV of stocks.candles and options.quotes keeps a column the API adds, where it raised ParseError.
stocks.candles and options.quotes merge every column their answers send,
not only the ones their model declares: a key other than the status flag
that holds a list is a column, and every answer must carry it as a list
as long as its rows, the rule a declared column follows. INTERNAL keeps
such a column for get_extra, each row's value on candles and the whole
column on option quotes, and JSON and DATAFRAME carry it by the API's
name, as the merged CSV already did.
A CSV answer without a header row, on the four single-object resources
and in the fan-out merge, is refused when it reads as JSON or HTML,
since no CSV row of these resources starts with `[`, `{` or `<`.
BREAKING CHANGE: a fan-out answer that lacks a column another answer
sends, declared or not, or sends it with another length, fails the call
with ParseError on every format, where INTERNAL, JSON and DATAFRAME left
an undeclared column out. Under add_headers=False, the CSV of
options.chain, options.expirations, options.lookup, stocks.earnings,
stocks.candles and options.quotes raises ParseError for a body that
reads as JSON or HTML, where it wrote it to the file.
…le-object resources options.chain, options.expirations, options.lookup and stocks.earnings decode their INTERNAL, JSON and DATAFRAME answers through one check that now also requires at least one of the model's columns, the rule the fan-out merge applies, so an object with none of them, like a proxy's JSON error page, raises the same ParseError on those three formats as its header does on CSV. The missing-column test moves from options.lookup to options.expirations: the lookup model has one column, so removing it leaves an object with none of the model's columns. BREAKING CHANGE: on options.chain, options.expirations, options.lookup and stocks.earnings, JSON and DATAFRAME raise ParseError for an object that carries none of the resource's columns, where they returned it.
|
@marketdata-code-review re-review |
Under add_headers=False there is no header row to check, and a lone string, number or boolean reads as a one-cell CSV: the API's own headerless answers include 1790740800, which is valid JSON too. The API labels every CSV answer text/csv and every JSON one application/json, so the CSV output of options.chain, options.expirations, options.lookup, stocks.earnings, stocks.candles and options.quotes now refuses an answer whose Content-Type names JSON or HTML, whatever its body, and keeps the check on the body's shape under any other label or none. The tests label their CSV answers text/csv, as the API does. BREAKING CHANGE: under add_headers=False, the CSV output of options.chain, options.expirations, options.lookup, stocks.earnings, stocks.candles and options.quotes raises ParseError for an answer whose Content-Type names JSON or HTML, where it wrote the body to the file.
#157 conflicted in two places: the imports of resources/base.py keep both csv and Collection, and StockQuote.from_dict keeps this branch's _split_fields and _with_extra, with its docstring.
The fan-outs already refused an answer whose lists differ in length, on every format. Every other resource that answers in columns now holds its answer to the same rule, with the same message, on INTERNAL, JSON and DATAFRAME: funds.candles, markets.status, stocks.news, stocks.prices, stocks.quotes and utilities.status, whose CSV is written from the decoded answer and refuses it too, and options.chain, options.expirations, options.lookup and stocks.earnings. Each format read such an answer its own way: INTERNAL gave every row the whole of an added list, or built rows up to the shortest declared column, DATAFRAME padded the short columns with nulls into extra rows, and JSON returned the body. A single value instead of a list is still kept as sent. json_answer_columns and the single-request resources share _check_column_lengths. BREAKING CHANGE: on funds.candles, markets.status, options.chain, options.expirations, options.lookup, stocks.earnings, stocks.news, stocks.prices, stocks.quotes and utilities.status, an answer whose lists differ in length raises ParseError on INTERNAL, JSON and DATAFRAME, and on the CSV of utilities.status, where they returned a result.
Closes #111
Summary
The API adds columns without an SDK release. Each one made every
OutputFormat.INTERNALcall of its resource raise (ParseErrorsince #50, a bareTypeErrorin 1.x), because a model's constructor refuses a keyword it does not declare. The models are now built from the columns they declare, and the rest of the answer is kept on the model formarketdata.get_extra(model), direction 2 of the issue: on every resource that builds through columns, the fan-outs' merged answer included, nothing the API sent is lost, and the column is named once per answer at DEBUG. Onlyutilities.user, which reads its own keys, keeps nothing. A column the model needs and the body lacks still raisesParseError, and so does an answer whose columns, declared or not, are lists of different lengths, on every format that decodes it, the rule the fan-outs' merge already applied.The same failure hit the CSV output of the two fan-outs,
stocks.candlesandoptions.quotes: their merge refused a header with a column the model does not know, which is how it tells an HTML error page from a CSV. That output now keeps the column, as the CSV of every single-request resource does, and refuses only a header with none of the resource's columns. The CSV ofoptions.chain,options.expirations,options.lookupandstocks.earningsapplies the same rule, so a body that is not an answer of these resources raisesParseErroron every format. Without a header row, underadd_headers=False, the CSV of those four resources and of the fan-outs refuses an answer whoseContent-Typenames JSON or HTML, or whose body reads as either.sdk-go (
json.Unmarshalinto structs, noDisallowUnknownFields) and sdk-csharp (reads the keys it looks for) ignore an unknown key on their typed output, so neither fails on an added column, and neither keeps it for the caller the wayget_extradoes.Version impact
Major (1.3.0 -> 2.0.0): this ships in 2.0.0 with the rest of
[Unreleased], which is not released yet. The CHANGELOG entry is under Changed (BREAKING) with its migration,get_extrais under Added, and the breaking changes are on thefeat(models)!:commitd483055and thefeat(resources)!:commits548338c,7759248,4fb87edanda6cd05e, whoseBREAKING CHANGE:footers name every breaking behavior of this PR. What a caller sees change, and what to do:INTERNAL of every resource that builds through columns (
funds.candles,markets.status,options.chain,options.expirations,options.lookup,options.quotes,stocks.candles,stocks.earnings,stocks.news,stocks.prices,stocks.quotesandclient.utilities.status, both name styles): an answer with a column the model does not declare returns the model, where it raisedParseError.get_extra(model)returns the column as the API sent it: the whole column on a single-object model, each row's own value on a list result. The column is named once per answer at DEBUG onmarketdata.logger:The API sent the columns ['brandNew'], which StockNews does not declare; get_extra() holds themThe line goes to
marketdata.loggerby name, as the response-metadata line inmeta.pydoes, so a client built with its ownlogger=does not get it in that logger. Migration: code that caught thatParseErrorgets the result; read the column withget_extra()until the model declares it.StockEarnings.from_dict,StockPrice.from_dict,StockQuote.from_dictand their human-readable twins: a key that names no field is kept forget_extra(), where it raisedTypeError. Migration: anexcept TypeErrormeant for an added key is no longer reached.options.chain,options.expirations,options.lookupandstocks.earnings, on every format: a body that is not a JSON object raisesParseError. INTERNAL raised a bareAttributeErroron the human-readable models, JSON returned the body, DataFrame built a frame from it (fornullan empty frame, which reads like an answer with no data) and CSV wrote it to the file. An object with none of the resource's columns, like a proxy's JSON error page, raisesParseErroron JSON and DataFrame too, where they returned it, as it did on INTERNAL. On CSV, a header row with none of the resource's columns raisesParseError, and underadd_headers=False, with no header to check, so does an answer whoseContent-Typenames JSON or HTML, a lone string, number or boolean included, or whose body reads as either; any other headerless body is written as it came. Migration: catchParseError(orBaseMarketdataException) where the value used to come back.CSV of
stocks.candlesandoptions.quotes, andutils.merge_csv_responses: a column the API adds is kept, where it raisedParseError: ... (unknown columns [...]); only a header with none of the resource's columns is refused. Migration: none; the file has one more column.stocks.candlesandoptions.quotes, andutils.json_answer_columns: a column the model does not declare is carried through the merge on every format, INTERNAL throughget_extra(), where INTERNAL, JSON and DataFrame left it out. Like a declared column, it must come in every chunk or symbol as a list as long as its rows: an answer without it fails the call withParseErroron every format, as the merged CSV already did for a header that differs. Underadd_headers=False, their CSV refuses an answer labeled or shaped as JSON or HTML, as the four single-object resources do. Migration: none for an API that sends the column in every answer; a call whose answers disagree on a column raises.resources.utilities.base.render: the INTERNAL rows ofclient.utilities.statuskeep an added column forget_extra(), where the call raisedParseError. Migration: as for INTERNAL above.Every other resource that answers in columns (
funds.candles,markets.status,options.chain,options.expirations,options.lookup,stocks.earnings,stocks.news,stocks.prices,stocks.quotesandclient.utilities.status, both name styles): an answer whose lists, declared or not, differ in length raisesParseError(columns of different lengths {...}, the fan-outs' message) on INTERNAL, JSON and DataFrame, and on the CSV ofclient.utilities.status, which is written from the decoded answer. On main, INTERNAL built the rows up to the shortest declared column (and refused an added one, as it refused any added column), DataFrame padded the short columns with nulls into extra rows, and JSON returned the body. A single value instead of a list is kept as sent, on every format. Migration: catchParseError(orBaseMarketdataException); no answer of the live API is uneven (see Review round 8).Unchanged:
ParseError.ParseErroron INTERNAL, with the same message, while JSON, DataFrame and CSV return it, as on main; that stays with Reading a body can raise a non-SDK exception: RecursionError on nested JSON, TypeError/UnicodeError/AssertionError on an exotic charset #110. On the four single-object resources and the fan-outs every format refuses it (see Version impact).utilities.headerskeeps every key as a header;utilities.userreads only its own keys.==and repr:get_extrareads the columns from the model's__dict__, where the response metadata already lives, not from a field.The change
output_types/columns.py:_split_fieldsis the one place that keys an answer by field. It returns the keys that name a column or a field of the model, renamed to the field, and every other key but the status flagsas the API sent it, logging those once at DEBUG. An answer that is not an object, or that carries none of the model's columns, comes back as it is with nothing else, so the model refuses it as before._to_fieldsis gone: every caller goes through_split_fields.get_extra, public and re-exported frommarketdata(in__all__), reads the columns kept on a model._with_extrakeeps them on a model, and_with_row_extraon the rows of a list result: a value per row from a list as long as the rows, which an added list is whenever the answer carries declared lists, since the length check below holds it to their length, and a single value as sent on every row._split_fieldsonce per answer, before a column-oriented answer is split into rows:stocks.candlesandoptions.quotes, on their merged answer; the model each builds from one answer alone, to name the answer a refused value came from, ignores the rest;options.chain,options.expirationsandoptions.lookup, both models;funds.candles,markets.status,stocks.news,stocks.prices,stocks.quotesandclient.utilities.status.stocks.pricesandstocks.quotesbuild each row with the constructor, as the other list resources do, so the log is written once per answer.StockEarnings.from_dict,StockPrice.from_dict,StockQuote.from_dictand their human-readable twins go through_split_fieldsand keep the rest forget_extra, with Args/Returns/Raises docstrings.stocks.earningsbuilds throughfrom_dict.utils.merge_csv_responsesrefuses only a header with none of the resource's columns; its docstring says a column that matches none of them is kept. Without a header row, it refuses an answer whoseContent-Typenames JSON or HTML or, under any other label or none, whose body starts with[,{or<, or isnull(_reads_as_json_or_html).utils.json_answer_columnsreturns, besides the model's columns, every other key butsthat holds a list, and holds it to the rule a declared column follows. Its length check isutils._check_column_lengths, which the single-request resources share: by default it compares every key butsthat holds a list.resources/base.py:_parse_json_objectdecodes a body that must be a JSON object carrying at least one of the model's columns, with every list one length, asparse_jsondoes, and refuses anything else withParseError._parse_json_columnsdecodes and checks the lengths for the INTERNAL, JSON and DataFrame branches offunds.candles,markets.status,stocks.news,stocks.pricesandstocks.quotes, andresources/utilities/base.renderchecks a column-oriented answer before any format, its CSV included._check_csv_headerrefuses a CSV header row with none of the resource's columns, read as the fan-out merge reads it, and, without a header row, an answer labeled or shaped as JSON or HTML, through the same_reads_as_json_or_html. The INTERNAL, JSON and DataFrame branches ofoptions.chain,options.expirations,options.lookupandstocks.earningsdecode through the first, and their CSV branch checks through the second.CHANGELOG.md: the INTERNAL raises a bare TypeError the day the API adds a column #111 entry under Changed (BREAKING), with the length rule and its migration,get_extraunder Added, and the Use Decimal for monetary fields in internal models (drop float for money) #50 line above.README.md: a "Columns a model does not declare" section under Output Formats, next to the one on money: which results carryget_extra, whole or per row, how the values are decoded, how long they last (the object's__dict__, throughcopy,deepcopyandpickle, outside==, the repr andfields()), what the other formats do, and that a list must hold one value per row.docs/funds.md,markets.md,options.md,stocks.mdandutilities.md: a "Columns a model does not declare" section before the methods, saying whatget_extraholds for that resource's results (whole or per row), where a fan-out requires the column in every answer, and which utilities keep nothing, with a link to the README section.Overlap with main
#157 (#102), #154, #158, #160, #161 and #163 landed on main after this branch was cut, #157 on 2026-10-02 at 14:45 UTC.
b442e64merges main into this branch, with the two conflicts this section announced:resources/base.py, the imports: this PR addsimport csv, fix(stocks): build INTERNAL quotes that carry the 52-week range #157 addedCollectionto thecollections.abcimport. Both stay.StockQuote.from_dict: its docstring and its last line. fix(stocks): build INTERNAL quotes that carry the 52-week range #157 built through_to_fields, this PR through_split_fieldsand_with_extra. This PR's side stays.Everything else merged on its own. With #157 in, the 52-week range is a field of
StockQuote, soget_extrano longer holds it: live, at 15:52 UTC,stocks.quotes("AAPL", use_52_week=True, output_format=OutputFormat.INTERNAL)returnsfiftyTwoWeekHigh=Decimal('345.34')andfiftyTwoWeekLow=Decimal('242.7592'),get_extrareturns{}, and no undeclared column is logged. The live run below, ond483055, predates the merge. No other pull request is open.Proof
What main does. A scratch matrix built every INTERNAL resource, plain and human, from nine bodies: the fixture plus a column (
extra), the fixture minus one (missing),[1],"x",5,null,{},{"error": "x"}and{"s": "ok", "error": "x"}. That is 216 cells, run on mainbc0619cand on this branch and compared by exception class and the first 70 characters of the message. What changed:extra, on the 18 single-request cases:ParseErrorbecame a result, which since round 3 holds the column inget_extra. The fan-outs returned one already, without the column; since round 4 they hold it inget_extratoo. The two utilities that do not build through columns returned one already.[1],"x",5andnullon humanoptions.chain,options.expirationsandstocks.earnings: a bareAttributeErrorbecameParseError(12 cells). Since rounds 2 and 3 the non-objects on these resources andoptions.lookup, with either names, raiseParseErroron all four formats; see Review round 3.Every other cell is unchanged, including
{}and the non-objects on the list resources, which stay with #110.The fan-outs, on main. With a column added to the answer of
stocks.candlesandoptions.quotes, INTERNAL, JSON and DataFrame return without it, and CSV raisesParseError: ... (unknown columns ['brandNew']). On this branch every format has it.Suite. main
c4ee8e1: 1979 passed. This branch, ona6cd05e(main merged in): 2164 passed, coverage 100% (3297 statements, 0 missed).test_a_body_whose_keys_the_model_does_not_take_is_a_parse_erroris gone: its twoextracases pinned the old answer. Itsmissingcase stays astest_a_body_missing_a_column_the_model_needs_is_a_parse_error, now onoptions.expirations,markets.statusandutilities.user. 188 tests are new, those 3 included.The new tests fail on main. With main's
src/marketdata(c4ee8e1) checked out on this branch, 147 of the 188 fail (measured ona6cd05e):test_a_foreign_object_is_a_parse_error_on_every_format;test_a_column_no_model_declares_is_kept_for_get_extraand the 22 oftest_a_column_no_model_declares_reaches_every_format;test_a_body_that_is_not_an_object_is_a_parse_error_on_every_format;test_a_headerless_csv_that_reads_as_json_or_html_is_a_parse_errorand the 8 oftest_a_fan_out_headerless_csv_that_reads_as_json_is_a_parse_error;test_a_csv_header_the_csv_module_cannot_read_is_a_parse_error,test_an_undeclared_value_that_is_not_a_list_goes_to_every_row,test_get_extra_is_empty_for_an_object_with_no_dictandtest_get_extra_survives_copy_and_pickle;test_an_undeclared_list_of_another_length_is_a_parse_error_on_every_formatand 15 of the 19 oftest_a_declared_column_of_another_length_is_a_parse_error_on_every_format;test_options_quotes_an_added_column_one_symbol_lacks_is_a_parse_errorand the new case oftest_json_answer_columns_name_the_answer_that_breaks_the_merge;from_dicttests oftest_stocks_prices.pyand the 2 oftest_stocks_quotes.py.The 41 that pass pin what does not change: the missing column (3), the object with none of the model's columns on INTERNAL (22), the CSV without a header row that the API labels CSV (12), and a declared column of another length on the fan-outs (4), which their merge already refused.
Mutations, each caught (on
6a6b3db, before_to_fieldsbecame_split_fields):_to_fieldsalso builds from an object with none of the model's columns: 11 fail, all on the list resources._to_fieldsalso logs the status flag: 5 fail, on the check that the fixtures log nothing.stocks.newsrenames each row instead of the answer: 2 fail, one log per row.Live.
src/tests/integration: 16 passed and 2 failed. Both failures are the API's answers today, and the earnings one fails on main too:test_funds_live.py::test_candles_return_recent_fund_prices: funds candles answers "This API endpoint is currently offline".test_stocks_live.py::test_earnings_return_expected_shape:earnings("AAPL", countback=4)answers one upcoming report withfiscalYear: null.On 2026-09-28 both pass again on this branch. On 2026-10-02, on
b442e64, the 20 live tests pass.Run at DEBUG, no answer in the live suite had a column its model does not declare. A column the API sends today does:
stocks.quotes("AAPL", use_52_week=True, output_format=OutputFormat.INTERNAL), which raises on main (#102). Ond483055, at 2026-09-28 16:25 UTC, it returns the quote, logs the two columns and keeps them. The same run asked the live API for the CSV of the four single-object resources, in both name styles and undercolumns=, and every file passed the header check (headers cut at 100 characters):On
7f929dc, at 2026-09-28 17:35 UTC, both fan-outs answer on every format with the real API: the hourly candles over two yearly chunks, the option quotes over two symbols. No answer carried a column its model does not declare, soget_extraholds nothing and every chunk and symbol passed the merge's rule:Tests
src/tests/test_money_decimal.py:test_a_column_no_model_declares_is_kept_for_get_extra, on every INTERNAL resource that builds through columns, plain and human, the fan-outs included (22). The fixture logs no undeclared column. The fixture plus one column builds the same fields,get_extraholds the column (a row its own value) and it is logged once.test_a_column_no_model_declares_reaches_every_format, on the same 22: the same answer carries the column on INTERNAL throughget_extra, on JSON and DataFrame as a column, and in the CSV file.test_get_extra_survives_copy_and_pickle: a copy, a deep copy and a pickled row keep whatget_extrareturns.test_an_undeclared_value_that_is_not_a_list_goes_to_every_row: a single value goes to every row on INTERNAL, JSON and DataFrame.test_an_undeclared_list_of_another_length_is_a_parse_error_on_every_format, on every case butoptions.lookup, which sends no list (21): a column the model does not declare with one value more than the rows raises one identicalParseError(columns of different lengths {...}) on INTERNAL, JSON and DataFrame, and on the CSV ofutilities.status.test_a_declared_column_of_another_length_is_a_parse_error_on_every_format(19, without the twooptions.expirationscases, which carry one list): the same for a declared column one value short.test_get_extra_is_empty_for_an_object_with_no_dict.test_an_object_with_none_of_the_model_columns_is_a_parse_error, on the same 22:{"error": "upstream timeout"}raisesParseError.test_a_foreign_object_is_a_parse_error_on_every_format, on the single-object models, plain and human (7): the same object raises one identicalParseErroron INTERNAL, JSON and DataFrame, the unknown-columns one on CSV and the JSON-or-HTML one on the headerless CSV.test_a_body_that_is_not_an_object_is_a_parse_error_on_every_format:[1],null,"x",5andtrue, answered as JSON, on the single-object models, plain and human (35). INTERNAL, JSON and DataFrame raise one identicalParseError, CSV the one for a header with none of the resource's columns, the headerless CSV the JSON-or-HTML one, and no file is written.test_a_csv_without_a_header_row_is_written_as_it_came, on the four single-object resources and the two fan-outs, a row and a lone number, both labeledtext/csvas the API labels CSV (12), andtest_a_csv_header_the_csv_module_cannot_read_is_a_parse_error.test_a_headerless_csv_that_reads_as_json_or_html_is_a_parse_error(4: a JSON object with no label, an HTML page labeledtext/plain, text labeledtext/html, a string labeledapplication/problem+json) andtest_a_fan_out_headerless_csv_that_reads_as_json_is_a_parse_error(8:[1]with no label, and"x",5andtruelabeledapplication/json, on each fan-out). The helper that mocks the answer takes itsContent-Type,application/jsonunless a test says otherwise.test_a_body_missing_a_column_the_model_needs_is_a_parse_error(3), above.src/tests/test_stocks_candles.py:test_stocks_candles_csv_keeps_a_column_the_api_adds, over two chunks.src/tests/test_options_quotes.py:test_options_quotes_csv_keeps_a_column_the_api_adds, over two symbols, andtest_options_quotes_an_added_column_one_symbol_lacks_is_a_parse_error: a symbol whose answer lacks the column another one sends fails the call on all four formats, naming that symbol.src/tests/test_utils.py:test_merge_csv_responses_keeps_a_column_the_resource_does_not_have, and a case oftest_json_answer_columns_name_the_answer_that_breaks_the_mergefor an undeclared column one answer lacks.test_json_answer_columns_follow_the_order_the_answers_sendnow expects an undeclared list to be a column, and a key with one value not to be.src/tests/test_stocks_prices.py:test_stock_price_from_dict_keeps_an_undeclared_column_for_get_extraandtest_stock_prices_human_readable_from_dict_keeps_an_undeclared_column.stocks.pricesno longer builds its rows through eitherfrom_dict, so both are pinned directly.src/tests/test_stocks_quotes.py:test_stock_quote_from_dict_keeps_an_undeclared_column_for_get_extraandtest_stock_quotes_human_readable_from_dict_keeps_an_undeclared_column, for the same reason:stocks.quotesbuilds its rows with the constructor. Each builds the quote from a row plusbrandNew, checks it equals the quote built without it, and thatget_extraholdsbrandNew.options.chain,options.expirations,options.lookupandstocks.earningsanswered the placeholderAS RECEIVED FROM API, which names none of their columns; it now answers a CSV with a header and still checks the file as it came.Review round 1
Measured on 2026-09-28, on
b8035b2.2243a397: the logger is private.columns.pynames it_logger, so this PR adds no public name and the version impact is a patch. It is the samemarketdata.loggerlogger, and the DEBUG line does not change. The top-level names that each module this PR changes defines without a leading underscore, read withastonmain, on the reviewed head and on this one (imports left out, as the review rules leave them out of the surface):8c8d6586: no sibling SDK refuses a column when it merges a CSV fan-out. None of them reads a chunk's column names: each keeps the first chunk's header and drops the repeated one from the later chunks. Read on each default branch on 2026-09-28:mergeTextChunks(internal/response/formatted.go) drops a later chunk's first line when it equals the first chunk's, and keeps it as data otherwise.StocksApi.MergeCandleCsvdrops the first line of every chunk after the first.StocksCsvResource.mergeCsvBodiesdoes the same.src/Endpoints/Stocks.php) and the option quotes merge (src/Endpoints/Options.php) keep the first header and strip it from the later chunks.stocks/candles.tsmerges the chunks' JSON and has no CSV merge, per the check of 2026-09-23 for perf(csv): merge the fan-out bodies a row at a time #148; no commit has landed on sdk-js since.Go, C# and Java do not merge option quotes in CSV: they answer a map per symbol, per the same check, and no commit since touches it. So the CSV merge needs no sibling issue.
Checks on
b8035b2.uv run pytest -n 4 --cov=marketdata: 2017 passed, coverage 100% (3207 statements, 0 missed); no test was added or changed, so the numbers above stand../lint.shclean.git merge-treeis clean againstmain, #154 and #158; against #157 it conflicts only in the five docstring lines ofStockQuote.from_dictdescribed above.Review round 2
Measured on 2026-09-28, on
9f65a39.16a66cb0: a body that is not a JSON object is oneParseErroron every format that decodes it.options.chain,options.expirations,options.lookupandstocks.earningsdecode through_parse_json_objecton their INTERNAL, JSON and DataFrame branches. It decodes asparse_jsondoes, withexact=Truewhere INTERNAL had it, and raisesParseErrorfor anything but an object. The check sits in each branch rather than ahead of the dispatch because a CSV request asks the API forformat=csv, and that body is not JSON. The CSV file stays the API's text as it came, as on every resource: that format decodes nothing, the same reasontest_a_csv_file_is_the_api_text_whatever_numbers_it_carriespins for a number the other formats refuse.The seven cases (
options.chain,options.expirationsandstocks.earningswith both names, andoptions.lookup) through the public client, answered with each body on each format.all 7means the seven agree once the model's name is set aside as<Model>.On the reviewed head,
b8035b2:On
9f65a39:On main
bc0619c, JSON, DataFrame and CSV answer as onb8035b2, and INTERNAL raises a bareAttributeError('list' object has no attribute 'items') on the three human-readable models. The DataFrame ofnullwas the worst of them: an empty frame, which reads like an answer with no data.test_a_body_that_is_not_an_object_is_a_parse_error_on_single_object_modelsbecametest_a_body_that_is_not_an_object_is_one_parse_error_on_every_decoded_format. Each of its 14 cases calls the four formats, asserts one identical message on INTERNAL, JSON and DataFrame, and checks that the CSV file holds the body's bytes. With main'ssrc/marketdataall 14 fail, where 6 did. The test count does not change.fe3053d2: INTERNAL keeps leaving out a column its model does not declare, by design. It is direction 1 of #111, which the issue weighed against keeping the column and against raising:columns=is ignored on INTERNAL and every field comes back, as the CHANGELOG says under theoptions.strikesremoval.ParseErrorwhile JSON, DataFrame and CSV returned it: the output format decided whether the call raised, which the house rules make a blocking finding. This PR removes that, and the DEBUG line names the column until the model declares it.extramapping, direction 2) adds public surface to the 14 models and their human-readable twins, and can be an issue of its own. Leaving it out of the other formats takes a column away from their callers, and the CSV would have to be parsed where it is written as it came.stocks.candlesandoptions.quotes, JSON and DataFrame leave it out when they merge, as on main, and only the CSV keeps it.Checks on
9f65a39.uv run pytest -n 4 --cov=marketdata: 2017 passed, coverage 100% (3212 statements, 0 missed). The public names of round 1, over the 16 modules this PR now changes, are the same onbc0619c,b8035b2and9f65a39;_parse_json_objectis private../lint.shclean.git merge-treeis clean againstmain, #154 and #158; against #157 it conflicts only in the five docstring lines ofStockQuote.from_dict, and with #157 merged the suite passes, 2033 tests.Review round 3
Measured on 2026-09-28, on
d483055. This round replaces what rounds 1 and 2 say about the version impact (patch), the public surface, the argument forfe3053d2and the CSV that wrote a body that is not an object: the column is now kept,get_extrais a new public name, CSV refuses that body, and the impact is major.1643b4a2: INTERNAL keeps the undeclared column, forget_extra._split_fieldshands every model built from an answer the columns it does not declare, andget_extra(model)returns them as the API sent them: the whole column on a single-object model, each row's own value on a list result, on every single-request resource and on the sixfrom_dict. They live in the model's__dict__, where the response metadata already does, so a model's fields,==and repr do not change. The fan-outs are left as they are, by decision: their merge keeps only declared columns on INTERNAL, JSON and DataFrame, as on main, while their CSV keeps the column, as it has since this PR's first commit. Carrying the column through their merge on the other three formats is a change of its own.test_a_column_no_model_declares_reaches_every_formatchecks one answer on all four formats for the 18 single-request cases; its CSV is the API's text with the same added column, or onclient.utilities.statusthe file written from the same decoded answer.16a66cb0: CSV refuses the same body. The CSV branch of the four resources checks the answer's header row with_check_csv_headerbefore writing the file: a header with none of the resource's columns raisesParseError, with the rule and the message the fan-out merge already uses, read as it reads it (a BOM off, blank lines skipped, a row thecsvmodule cannot read refused). A header that passes leaves the file as the API's text, byte for byte. The seven cases, through the public client, on each format:The three formats that decode the body give one message. CSV gives the one a header with none of the resource's columns gets, since what is wrong with that text, read as CSV, is its header. Under
add_headers=Falsethere is no header row to check, and the file is written as it came. Live, the CSV of the four resources passes the check in both name styles and undercolumns=(see Live above).c7feefea: major. Version impact says major (1.3.0 -> 2.0.0), the release this ships in, with the migration for each affected call. The #111 entry moved from Fixed to Changed (BREAKING), with its migration, andget_extrais under Added. The breaking change is on thefeat(models)!:commitd483055, whoseBREAKING CHANGE:footer names every breaking behavior of this PR, the earlier commits' included. As in #146 (a1f4764), the history is not rewritten.Surface. The public names of round 1, over the 17 modules this PR now changes. The only new one is
get_extra, whichmarketdata/__init__.pyre-exports and lists in__all__:Checks on
d483055.uv run pytest -n 4 --cov=marketdata: 2043 passed, coverage 100% (3259 statements, 0 missed); 63 of the 92 new tests fail with main'ssrc/marketdata../lint.shclean.git merge-treeis clean againstmain, #154 and #158; against #157 it conflicts as Overlap describes, and with #157 merged and resolved that way the suite passes, 2059 tests. The head,bbeea3c, only adds docstrings (renderand the four CSV tests), and the suite (2043 passed, coverage 100%, 3259 statements, 0 missed),git merge-treeand./lint.shran on it again with the same results.Live, at 2026-09-28 16:35 UTC,
columns=with the API's aliases does not trip the CSV header check: the API answers the canonical name in the header (pricecomes back asmid,dateasupdatedonoptions.chain), and onoptions.expirationswithdateandstocks.earningswithpriceit answers an empty body, which is written as it came.Review round 4
Measured on 2026-09-28, on
7f929dc. This round replaces what round 3 says about the fan-outs and about the CSV without a header row.b4bc19ed: the fan-outs carry the column through their merge.json_answer_columnsnow returns every key but the status flag that holds a list, the model's or not, and holds each to the rule a declared column follows: every chunk or symbol must send it as a list as long as its rows. So the merged answer ofstocks.candlesandoptions.quotescarries an added column on JSON and DataFrame by the API's name, and INTERNAL keeps it forget_extra, each row's value on candles and the whole column on option quotes, as the merged CSV already kept it. An answer that lacks it, or sends another length, fails the call withParseErrornaming that answer on every format, as the CSV merge already did for a header that differs from the first answer's.test_a_column_no_model_declares_reaches_every_formatnow runs on the fan-outs too (22 cases), andtest_options_quotes_an_added_column_one_symbol_lacks_is_a_parse_errorchecks the refusal on the four formats. Live, both fan-outs answer on every format with the real API over two symbols and two yearly chunks (see Live).248e8985: a headerless CSV that reads as JSON or HTML is refused. Without a header row there is nothing to match against the resource's columns, and comparing the width of the rows would refuse an answer the moment the API adds a column. What a CSV row of these resources never does is start with[,{or<, or readnull: its first value is a symbol, a date, a number or a quoted text, undercolumns=too. So_reads_as_json_or_htmlrefuses such a body, withParseError: Response body is not a valid answer of this resource (JSON or HTML, not CSV): '[1]', in the CSV branch of the four single-object resources and in the fan-out merge, whose headerless path had the same gap. The single-object cases oftest_a_body_that_is_not_an_object_is_a_parse_error_on_every_formatask for the headerless CSV as well, and any other headerless body is still written as it came.d848f9bb:get_extrais documented.README.mdhas a "Columns a model does not declare" section under Output Formats, next to the one on money: which results carry it, whole or per row, how its values are decoded, how long they last (on the object itself, throughcopy,deepcopyandpickle, outside==, the repr anddataclasses.fields(), whichtest_get_extra_survives_copy_and_picklepins), and what the other formats do. The review rules leave*.mdout of the diff, so this is where the section is announced.Checks on
7f929dc.uv run pytest -n 4 --cov=marketdata: 2054 passed, coverage 100% (3266 statements, 0 missed); 74 of the 103 new tests fail with main'ssrc/marketdata.get_extrais still the only new public name, over the 19 modules this PR now changes../lint.shclean.git merge-treeis clean againstmain, #154 and #158; against #157 it conflicts in the same two places Overlap describes, and with #157 merged and resolved that way the suite passes, 2070 tests. The breaking changes of this round are on thefeat(resources)!:commit548338c, with aBREAKING CHANGE:footer;7f929dcadds the README section.Review round 5
Measured on 2026-09-28, on
7759248.7ba21780: a foreign object is refused on every format of the single-object resources._parse_json_object, which the INTERNAL, JSON and DataFrame branches ofoptions.chain,options.expirations,options.lookupandstocks.earningsdecode through, now also requires at least one of the model's columns, the rule the fan-out merge applies, with its message:ParseError: Response body is not a valid answer of this resource (none of this resource's fields): '{"error": "upstream timeout"}'. The CSV already refused that body by its header.{"error": "upstream timeout"}through the public client, on each format, on7f929dc:and on
7759248:test_a_foreign_object_is_a_parse_error_on_every_formatchecks the four formats for the seven cases. Theoptions.lookupcase oftest_a_body_missing_a_column_the_model_needs_is_a_parse_errormoved tooptions.expirations: the lookup model has one column, so removing it leaves an object with none of the model's columns, which is now refused for that reason instead.The list resources (
funds.candles,markets.status,stocks.news,stocks.prices,stocks.quotes,client.utilities.status) are left as they are: INTERNAL refuses the object, and JSON, DataFrame and CSV return it, as on main. That is the malformed-body work of #110, as this PR said from the start.Live, at 17:59 UTC, the answers of the four resources pass the new rule on the three formats that decode them, in both name styles and under
columns=, an alias included:Checks on
7759248.uv run pytest -n 4 --cov=marketdata: 2061 passed, coverage 100% (3268 statements, 0 missed); 81 of the 110 new tests fail with main'ssrc/marketdata../lint.shclean.git merge-treeis clean againstmain, #154 and #158; against #157 it conflicts in the same two places Overlap describes, and with #157 merged and resolved that way the suite passes, 2077 tests. The breaking change of this round is on thefeat(resources)!:commit7759248, with aBREAKING CHANGE:footer.Review round 6
Measured on 2026-09-28, on
21efa02.Proof and
4f4bb881: both quotefrom_dict, pinned directly.stocks.quotesbuilds its rows with the constructor, so no endpoint test reachesStockQuote.from_dictorStockQuotesHumanReadable.from_dict.1519681adds a direct test for each, next to the existingfrom_dicttests and away from the tests #157 appends to that file, astest_stocks_prices.pydoes for the price models. The same row plusbrandNew, through both methods, with main'ssrc/marketdata(bc0619c):and on
21efa02:Both tests fail with main's
src/marketdataand pass on this branch; the suite with them is the coverage run below.eb622b26:get_extraon each resource page.21efa02adds a "Columns a model does not declare" section todocs/funds.md,markets.md,options.md,stocks.mdandutilities.md, before their methods: whatget_extraholds for that resource's results, whole on a single-object result and per row on a list, thatcandles()andquotes()need the column in every chunk or symbol, thatutilities.user()andutilities.headers()keep nothing, and a link to the README section for how the values are decoded and how long they last. The review rules leavedocs/**out of the diff, so this is where it is announced.Checks on
21efa02.uv run pytest -n 4 --cov=marketdata: 2063 passed, coverage 100% (3268 statements, 0 missed); 83 of the 112 new tests fail with main'ssrc/marketdata../lint.shclean.git merge-treeis clean againstmain, #154 and #158; against #157 it conflicts in the same two places Overlap describes (docs/stocks.mdandtest_stocks_quotes.pymerge on their own), and with #157 merged and resolved that way the suite passes, 2079 tests.Review round 7
Measured on 2026-10-02, on
b442e64.8f792ec9: a headerless CSV answer labeled JSON or HTML is refused. The body alone cannot tell a JSON scalar from a CSV answer, because several answers the API sends underadd_headers=Falseare valid JSON; theContent-Typecan. What the API sent, live on 2026-10-02:333.435,1790740800and the empty-answer placeholder""parse as JSON, so a check that decodes the body would refuse the API's own answers; a boolean comes asTrue, which does not. Every CSV answer, the error and the empty one included, is labeledtext/csv, and every JSON oneapplication/json, down to a route the API does not have, which answers a CSV request with JSON. So_reads_as_json_or_htmlreads the label first: an answer labeled*/json,*+json,text/htmlorapplication/xhtml+xmlis refused with the sameParseError(JSON or HTML, not CSV), whatever its body, and under any other label or none the check on the body's shape stays. It runs where it ran: the CSV branch ofoptions.chain,options.expirations,options.lookupandstocks.earnings, and the fan-out merge ofstocks.candlesandoptions.quotes. With a header row nothing changes:"x",5andtruealready fail the header check (unknown columns ['x']).Through the public client, each answer mocked with its label, under
add_headers=False, on21efa02:and on
b442e64:The fan-outs did not even write the body: their merge read it as a CSV cell, so
"x"becamex. The single-object resources raiseParseErrorfor the same three answers on INTERNAL, JSON, DataFrame and the CSV with a header, and the fan-outs on JSON, so the headerless CSV now agrees with the other formats.Live, at 15:51 UTC on
b442e64, the headerless CSV of the six resources through the SDK: every answer is labeledtext/csv; charset=utf-8and every file is written, a lone number and a loneTrueincluded (first 40 bytes;stocks.candleswithcolumns=["t"]andcountback=1,options.quoteswithcolumns=["inTheMoney"]):What stays: an answer with no label, or labeled
text/plainortext/csv, whose body is a lone string, number or boolean is still written. Nothing on the wire tells it from a one-cell CSV, andtext/csvis how the API labels its own. The placeholder""is read as the empty answer before any check, on every format, as before.Siblings: none of them checks the body of a CSV answer (see round 1), so each writes a JSON scalar, or a JSON object, as CSV. sdk-go already reads the
Content-Type, to choose the parser of an error envelope (parseErrorBodyininternal/http/client.go), and sdk-php sends anAcceptper format; sdk-js and sdk-java do not mentiontext/csv. No sibling issue is proposed.Tests:
test_a_body_that_is_not_an_object_is_a_parse_error_on_every_formatadds"x",5andtrue(21 cases),test_a_fan_out_headerless_csv_that_reads_as_json_is_a_parse_erroradds them labeled JSON (6),test_a_headerless_csv_that_reads_as_json_or_html_is_a_parse_erroradds text labeled HTML and a string labeledapplication/problem+json(2), andtest_a_csv_without_a_header_row_is_written_as_it_cameadds the fan-outs and a lone number labeledtext/csv(8). With21efa02'ssrc/marketdata, the 29 refusals fail and the 8 written cases pass.Checks on
b442e64.uv run pytest -n 4 --cov=marketdata: 2125 passed, coverage 100% (3284 statements, 0 missed); 112 of the 149 new tests fail with main'ssrc/marketdata.src/tests/integration -m integration: 20 passed. This round adds no public name:_reads_as_json_or_htmlis private../lint.shclean.git merge-treeis clean against main, and no other pull request is open. The breaking change of this round is on thefeat(resources)!:commit4fb87ed, with aBREAKING CHANGE:footer.Review round 8
Measured on 2026-10-02, on
a6cd05e.fbc495c7: an answer whose columns differ in length is oneParseErroron every decoded format. Onb442e64no format raised for it: neither pandas nor polars fails to build the frame, as the finding expected; both build one row more than INTERNAL, with nulls in the declared columns. The formats disagreed on the rows instead.funds.candles(7 rows) through the public client, with an addedbrandNewof 8 values, and withtone value short; DataFrame is the same on pandas and polars:The rule is the one the fan-outs' merge applied, with its message: every key but the status flag
sthat holds a list, declared or not, holds one as long as the others.json_answer_columnsand the single-request resources share it (_check_column_lengths), through_parse_json_objectonoptions.chain,options.expirations,options.lookupandstocks.earnings,_parse_json_columnsonfunds.candles,markets.status,stocks.news,stocks.pricesandstocks.quotes, andrenderonclient.utilities.status, before any branch.options.chainhad the same extra DataFrame row;options.quotes, whose answer has the same shape, already raised. The lines the finding quotes,_with_row_extraincolumns.py, are unchanged: the check runs on the decoded answer before any row is built, so a list of another length than the declared lists no longer reaches them.test_an_undeclared_list_of_another_length_is_a_parse_error_on_every_format(21) andtest_a_declared_column_of_another_length_is_a_parse_error_on_every_format(19) check one identical message on the three formats for every case, in both name styles, the fan-outs included.The fourth format, CSV:
client.utilities.statuswrites its CSV from the decoded answer, so its CSV raises the sameParseError, and both tests check it.t,brandNew\r\nx,1\r\ny,2\r\n3\r\n. On main and on this branch,funds.candles,markets.status,stocks.news,stocks.prices,stocks.quotes,options.chain,options.expirationsandstocks.earningswrite it as it came, and the fan-outs refuse it by its width,row ['3'] does not have 2 values(on main by its header,unknown columns ['brandNew']). A width check on the CSV of the single-request resources is malformed-body work, which stays with Reading a body can raise a non-SDK exception: RecursionError on nested JSON, TypeError/UnicodeError/AssertionError on an exotic charset #110, as the Unchanged list says for the list resources.A single value instead of a list,
"brandNew": "new", gives every row that value on INTERNAL, JSON and DataFrame, on both libraries, before and after this round.test_an_undeclared_value_that_is_not_a_list_goes_to_every_rownow pins it on the three formats; the "a list of another length" case of the test it replaces pinned the INTERNAL reading the finding names.Live, the rule breaks no answer the API sends. At 18:11 UTC, the JSON of 13 endpoints, in both name styles, had every list one length:
At 18:25 UTC, on
a6cd05e, through the SDK, INTERNAL, JSON and DataFrame of the ten resources, in both name styles (utilities.statushas one), returned their result:stocks.quotes6 rows with the 52-week range,stocks.prices4,stocks.news10,stocks.earnings1,markets.status31,funds.candles10,options.chain20 contracts,options.expirations25,options.lookup1 andutilities.status12, none refused.src/tests/integration -m integration: 20 passed.No sibling SDK keeps a column its model does not declare, and none builds a DataFrame, so none has this case, as the review says.
Checks on
a6cd05e.uv run pytest -n 4 --cov=marketdata: 2164 passed, coverage 100% (3297 statements, 0 missed). 147 of the 188 new tests fail with main'ssrc/marketdata. Of this round's 41, 32 fail withb442e64's: the 9 that pass are the eight fan-out cases, which the merge already refused, and the single value. This round adds no public name:_check_column_lengthsand_parse_json_columnsare private../lint.shclean.git merge-treeis clean against main, and no other pull request is open. The breaking change of this round is on thefeat(resources)!:commita6cd05e, with aBREAKING CHANGE:footer.