fix(config): normalize nullable arrays and objects instead of refusing them (#642)

* fix(config): normalize nullable arrays and objects instead of refusing them

`element_style._nullable` widens every `customization.modes.<mode>` override
with 'null' so a blank means "inherit the base", which turns a colour declared
"array" into ["array", "null"]. `normalize_config_values` only knew how to
convert null/integer/number/boolean out of a union, so a valid [0, 249, 0]
matched nothing and logged

    Could not normalize field customization.modes.upcoming.odds_text.text_color:
    value=[0, 249, 0], type=<class 'list'>, schema_type=['array', 'null']

The warning was the harmless half. It `continue`d past the single-type handling
below, where `prop_type == 'array'` coerces items, so a nullable array never had
its items normalized while a plain one did. A form posts numbers as strings, so
["0", "249", "0"] survived to the validator and was rejected with "Expected type
integer, got str" -- setting a per-mode colour in the web UI failed outright.
Every per-mode override of a structural or string type was exposed, across all
eight scoreboard plugins, not only colours.

Re-enter the single-type handling with the matched member rather than bailing,
accept a string that matches, and warn only on a genuine mismatch so the
diagnostic still reaches the validator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ET8e5weDrb5Ju5QTKLU7zh

* fix(config): convert only integral numbers for integer array items

Review catch on the previous commit. Routing nullable arrays into the shared
item handling made its integer coercion reachable for them, and that coercion
called int(v) on any number: a client sending [2.5, 249, 0] for an RGB array
got 2 stored and a 200 back, so a wrong value was silently corrected into a
valid-looking one rather than refused.

Convert only genuinely integral values, at both the union-item and the plain
'array' item branch so the two cannot drift. A whole float -- 2.0, which is all
JSON can express for an integer -- still converts. This also settles an
inconsistency that predates the change: int('2.5') raises, so the string form
was always preserved and rejected while the numeric form was truncated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ET8e5weDrb5Ju5QTKLU7zh

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-26 16:33:23 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 9964dd2183
commit c4927e82a3
2 changed files with 202 additions and 6 deletions
+38 -6
View File
@@ -2080,10 +2080,29 @@ def _prepare_plugin_config_for_save(plugin_id, plugin_config, schema, schema_mgr
normalized[key] = value.strip().lower() in ('true', '1', 'on', 'yes')
continue
# Nothing converted: keep the value for validation to report.
logger.warning(f"Could not normalize field {field_path}: value={repr(value)}, type={type(value)}, schema_type={prop_type}")
normalized[key] = value
continue
# The scalar conversions above are the only ones this branch
# knows, so a union naming a structural or string type fell
# through here even when the value already matched it --
# customization.modes.<mode> makes every override nullable
# (see element_style._nullable), so every per-mode colour is
# ['array', 'null'] and warned on a perfectly valid [r, g, b].
# Worse than the noise: `continue` skipped the single-type
# handling below, so a nullable array never had its items
# normalized and form-posted ["0", "249", "0"] stayed strings
# where a plain 'array' field would have become ints. Re-enter
# that handling with the matched member instead.
if isinstance(value, list) and 'array' in prop_type:
prop_type = 'array'
elif isinstance(value, dict) and 'object' in prop_type:
prop_type = 'object'
elif isinstance(value, str) and 'string' in prop_type:
normalized[key] = value
continue
else:
# Nothing converted: keep the value for validation to report.
logger.warning(f"Could not normalize field {field_path}: value={repr(value)}, type={type(value)}, schema_type={prop_type}")
normalized[key] = value
continue
if isinstance(value, dict) and prop_type == 'object' and 'properties' in prop_schema:
# Recursively normalize nested objects
@@ -2112,7 +2131,16 @@ def _prepare_plugin_config_for_save(plugin_id, plugin_config, schema, schema_mgr
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(v, (int, float)):
normalized_array.append(int(v))
# Only a genuinely integral value converts.
# int(2.5) == 2 would store a silently
# corrected number where the client sent a
# wrong one; leaving it lets the validator
# reject it. A whole float (2.0 out of JSON)
# is integral and still converts.
if isinstance(v, int) or float(v).is_integer():
normalized_array.append(int(v))
else:
normalized_array.append(v)
continue
elif 'number' in item_type:
if isinstance(v, str):
@@ -2138,7 +2166,11 @@ def _prepare_plugin_config_for_save(plugin_id, plugin_config, schema, schema_mgr
except (ValueError, TypeError, OverflowError):
normalized_array.append(v)
elif isinstance(v, (int, float)):
normalized_array.append(int(v))
# Integral only -- see the union branch above.
if isinstance(v, int) or float(v).is_integer():
normalized_array.append(int(v))
else:
normalized_array.append(v)
else:
normalized_array.append(v)
normalized[key] = normalized_array