Repository navigation
Description
Summary
pm-stack/grist/update-choice-options.py guards the inner stored
widgetOptions shape (#6588, check_widget_options), but the Grist API
envelope around it in get_widget_options — and the JSON decode in
api() — are still shape-trusted. A wrong-shape 200 response dies as a
raw KeyError / TypeError / AttributeError / JSONDecodeError
traceback, even though the module's own contract (docstring, exit
codes) says an unexpected shape is an exit-1 failure reported on
stderr, and run() converts exactly the two named error classes
(TimeoutError, WidgetOptionsError, lines 221–226) to that exit.
Evidence (verified this run, laptop @ 65beb42, probe ~/git/fleet/probe-grist-envelope, real file verbatim, stubbed api transport)
get_widget_options (update-choice-options.py:136–144):
for col in data["columns"]: # :138
if col["id"] == column: # :139
raw = col["fields"].get("widgetOptions") or "" # :140
parsed = json.loads(raw) # :144 — only JSONDecodeError is wrappedControls pass (empty widgetOptions → {}; valid JSON string →
{'choices': ['id']}). Every wrong shape below is a raw error:
| payload | result |
|---|---|
{} (no columns) |
KeyError: 'columns' |
[] (top-level list) |
TypeError: list indices must be integers |
{"error": "not found"} |
KeyError: 'columns' |
{"columns": "abc"} |
TypeError: string indices must be integers |
{"columns": ["Area"]} |
TypeError: string indices must be integers |
{"columns": [{"id": "Area"}]} (no fields) |
KeyError: 'fields' |
fields: null |
AttributeError: 'NoneType' object has no attribute 'get' |
fields: [] |
AttributeError: 'list' object has no attribute 'get' |
widgetOptions already a dict (not a JSON string) |
TypeError: the JSON object must be str, bytes or bytearray, not dict |
widgetOptions: 123 |
TypeError: ... not int |
{"columns": null} |
TypeError: 'NoneType' object is not iterable |
End-to-end: run([...]) with a {} response propagates
KeyError: 'columns' instead of returning 1 — the CLI traceback names
no endpoint, column, or field.
Second door, same class: api() (:133) returns
json.loads(raw) if raw and raw != "null" else None with no decode
guard — a non-JSON 200 body (e.g. an HTML proxy/error page) raises a
raw JSONDecodeError: Expecting value: line 1 column 1 (char 0)
(probe2 in the same probe dir), also uncaught by run().
Why these shapes are realistic: the columns endpoint is a server the
tool does not control — other Grist versions, an error object returned
with 200 by a proxy, or a column whose fields/widgetOptions were
written by another tool (the exact premise #6588 already accepted for
the inner value). Both the initial read and the post-PATCH read-back
verification go through get_widget_options, so a wrong-shape
read-back also crashes after a successful PATCH, reporting a
traceback for a change that actually landed.
Fix shape
Validate the envelope at the same boundary #6588 used for the inner
value: in get_widget_options, require data to be an object with a
columns list of objects each carrying an id and an object
fields; require a present widgetOptions to be a string before
json.loads. Raise WidgetOptionsError (or a sibling named error
that run() already converts) with messages naming the endpoint
payload part that was wrong. In api(), wrap the json.loads decode
failure the same way, naming method + path. No coercion, no PATCH on a
shape failure (the existing pre-PATCH ordering already guarantees
this for the first read).
Acceptance criteria
- Each wrong-shape payload in the table above produces a named,
human-readable error on stderr and exit 1 viarun()— no
traceback, no rawKeyError/TypeError/AttributeError. - A non-JSON 200 body from
api()is reported as a named API
failure (method + path), exit 1, not a rawJSONDecodeError. - Controls unchanged: empty widgetOptions →
{}; valid stored
options merge and read-back verification behave exactly as today. - A column genuinely absent from a well-shaped
columnslist
still exits via the existing "column not found" path. - New cases land in
pm-stack/grist/test_update_choice_options.py
(stubbed transport, no live Grist), covering at least: missing
columns, non-listcolumns, entry withoutfields,
non-stringwidgetOptions, non-JSON response body.
Activity
developing-today commentedon Nov 7, 2026
CODE-2135
dezren39 commentedon Nov 7, 2026
🤖 Agent note · filed