diff --git a/script/build_language_schema.py b/script/build_language_schema.py index 3ce94b674e..bf83140ec7 100755 --- a/script/build_language_schema.py +++ b/script/build_language_schema.py @@ -641,6 +641,37 @@ def is_platform_schema(schema_name): return component in components and components[component].is_platform_component +def _tighter_bound(own, ref, choose): + """Return the tighter of a field's own range bound and a referenced schema's. + + ``choose`` is ``max`` for a lower bound and ``min`` for an upper bound, so an + ``All(named_type, field_range)`` collapses to the intersection of the two + ranges. Non-numeric bounds (e.g. a stringified TimePeriod) can't be compared, + so the field's own value is kept. + """ + if own is None: + return ref + if ref is None: + return own + if isinstance(own, (int, float)) and isinstance(ref, (int, float)): + return choose(own, ref) + return own + + +def _apply_tighter_bounds(target_s, own_min, own_max, key_s): + """Reconcile a field's own min/max (captured before the spread) with the + referenced schema's bounds, so spreading a named schema never widens + (clobbers) a tighter bound the field defined itself, e.g. + ``All(uint8_t, Range(min=1, max=6))`` must stay 1..6, not uint8_t's 0..255. + """ + for bound, own, choose in (("min", own_min, max), ("max", own_max, min)): + merged = _tighter_bound(own, key_s.get(bound), choose) + if merged is None: + target_s.pop(bound, None) + else: + target_s[bound] = merged + + def shrink(): """Shrink the extending schemas which has just an end type, e.g. at this point ota / port is type schema with extended pointing to core.port, this should instead be @@ -723,7 +754,9 @@ def shrink(): continue assert extends == [x] target_s.pop(S_SCHEMA) + own_min, own_max = target_s.get("min"), target_s.get("max") target_s |= key_s + _apply_tighter_bounds(target_s, own_min, own_max, key_s) if key_s[S_TYPE] in ["integer", "string"]: target_s["data_type"] = x.split(".")[1] # remove this dangling again @@ -739,10 +772,12 @@ def shrink(): target_s[S_EXTENDS].remove(x) continue assert x in target_s[S_SCHEMA][S_EXTENDS] + own_min, own_max = target_s.get("min"), target_s.get("max") target_s.pop(S_SCHEMA) target_s.pop(S_TYPE) # undefined target_s["data_type"] = x.split(".")[1] target_s.update(key_s) # carry min/max bounds, if any + _apply_tighter_bounds(target_s, own_min, own_max, key_s) # remove this dangling again pop_str_path_schema(x) diff --git a/tests/script/test_build_language_schema.py b/tests/script/test_build_language_schema.py index b1f44d0498..5767999438 100644 --- a/tests/script/test_build_language_schema.py +++ b/tests/script/test_build_language_schema.py @@ -546,6 +546,17 @@ def test_cv_types_end_to_end(full_schema_dir: Path) -> None: assert freq["min"] == 45.0 assert freq["max"] == 66.0 + # cv.All(cv.uint8_t, cv.Range(min=1, max=6)): spreading the uint8_t named + # schema (0..255) must not clobber the field's own tighter 1..6 bound. + tm1637 = json.loads((full_schema_dir / "tm1637.json").read_text()) + length = tm1637["tm1637.display"]["schemas"]["CONFIG_SCHEMA"]["schema"][ + "config_vars" + ]["length"] + assert length["type"] == "integer" + assert length["data_type"] == "uint8_t" + assert length["min"] == 1 + assert length["max"] == 6 + # positive_float = All(float_, Range(min=0)): a bounds-only named schema # spreads its data_type name and its min onto the field. light = json.loads((full_schema_dir / "light.json").read_text())["light"]