improved logging

This commit is contained in:
Ayzen
2026-06-06 00:52:52 +03:00
parent af6005d68f
commit aea49f6128
65 changed files with 1206 additions and 240 deletions
+50 -26
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
from typing import Any
from python_app.models.run_config_schema import (
@@ -13,6 +14,8 @@ from python_app.models.run_config_schema import (
SwitchModel,
)
logger = logging.getLogger(__name__)
# Wire-format bounds shared with the C++ pipeline. The ring header stores the
# slot size as a uint32, and capacity * slot_size must address into a single
# shared-memory mapping, so reject values the C++ side cannot represent.
@@ -26,9 +29,10 @@ _MAX_COMBOS = 4096
def _require_int(payload: dict[str, Any], key: str, default: int) -> int:
"""Read a strict JSON integer, treating an explicit ``null`` as 'use default'.
Accept only a genuine JSON integer (not bool, not float, not numeric string):
silently truncating ``5.7`` or parsing ``"5"`` would hide a malformed config.
Mirrors ``run_config_codec._read_int`` so every config integer reads identically.
Accepts only a genuine JSON integer (not bool, not float, not numeric string),
because silently truncating ``5.7`` or parsing ``"5"`` would hide a malformed
config. Mirrors ``run_config_codec._read_int`` so every config integer reads
identically.
"""
value = payload.get(key, default)
if value is None: # explicit JSON null -> use the default, never coerce
@@ -63,7 +67,7 @@ def load_switch_payload(
target: SwitchModel,
) -> None:
"""Populate switch model from payload preserving defaults for missing values."""
# #53: scalar reads reject array/object JSON types as ValueError (not TypeError).
# Scalar reads reject array/object JSON types as ValueError (not TypeError).
target.name = _require_str(payload, "name", target.name)
target.driver_mode = _require_str(payload, "driver_mode", target.driver_mode)
target.driver = _require_str(payload, "driver", target.driver)
@@ -81,7 +85,7 @@ def load_control_button_payload(
target: ControlButtonModel,
) -> None:
"""Populate control-button model from payload preserving defaults."""
# #53: scalar reads reject array/object JSON types as ValueError (not TypeError).
# Scalar reads reject array/object JSON types as ValueError (not TypeError).
target.enabled = _require_bool(payload, "enabled", target.enabled)
target.gpio_chip = _require_str(payload, "gpio_chip", target.gpio_chip)
target.pin = _require_int(payload, "pin", target.pin)
@@ -92,38 +96,50 @@ def load_control_button_payload(
def load_ring_payload(payload: dict[str, Any], target: RingEndpointModel) -> None:
"""Populate ring endpoint model from payload preserving defaults."""
# #53: scalar reads reject array/object JSON types as ValueError (not TypeError).
"""Populate ring endpoint model from payload preserving defaults.
Validates the resulting ring sizing before returning, so a bad config fails
here (on GUI save and at config load) instead of crashing the C++ ring
allocator at boot.
"""
# Scalar reads reject array/object JSON types as ValueError (not TypeError).
target.name = _require_str(payload, "name", target.name)
target.capacity = _require_int(payload, "capacity", target.capacity)
target.slot_size_bytes = _require_int(payload, "slot_size_bytes", target.slot_size_bytes)
# #36: enforce ring sizing in Python so a bad config fails here (in GUI/save and
# at config load) instead of crashing the C++ ring allocator at boot.
validate_ring_endpoint(target)
def validate_ring_endpoint(ring: RingEndpointModel) -> None:
"""Validate ring sizing against the constraints the C++ allocator requires."""
field = ring.name or "ring"
"""Validate ring sizing against the constraints the C++ allocator requires.
Raises ValueError naming the offending ring when capacity or slot size is
non-positive, the slot size overflows the uint32 wire field, or the segment
would exceed the maximum single mapping.
"""
ring_name = ring.name or "ring"
if ring.capacity <= 0:
raise ValueError(f"rings.{field}.capacity must be > 0")
raise ValueError(f"rings.{ring_name}.capacity must be > 0")
if ring.slot_size_bytes <= 0:
raise ValueError(f"rings.{field}.slot_size_bytes must be > 0")
raise ValueError(f"rings.{ring_name}.slot_size_bytes must be > 0")
if ring.slot_size_bytes > _UINT32_MAX:
raise ValueError(f"rings.{field}.slot_size_bytes exceeds the uint32 wire limit")
raise ValueError(f"rings.{ring_name}.slot_size_bytes exceeds the uint32 wire limit")
# Overflow-safe: compare against the ceiling without ever forming the full
# product, so an attacker-sized capacity cannot wrap a fixed-width index.
# product, so an oversized capacity cannot wrap a fixed-width index.
if ring.capacity > _RING_SEGMENT_MAX_BYTES // ring.slot_size_bytes:
raise ValueError(
f"rings.{field} capacity * slot_size_bytes exceeds the maximum ring segment size"
f"rings.{ring_name} capacity * slot_size_bytes exceeds the maximum ring segment size"
)
def validate_sweep_model(sweep: RadarSweepModel) -> None:
"""Validate radar sweep bounds in Python so a bad sweep fails in the GUI/save
and at config load rather than aborting the C++ acquisition process at boot.
"""Validate radar sweep bounds (point count and frequency span).
Runs in Python so a bad sweep fails on GUI save and at config load rather
than aborting the C++ acquisition process at boot. Raises ValueError when
``points`` is non-integral or non-positive, or when ``stop_hz`` does not
exceed ``start_hz``.
"""
# #36: points must be a positive, integral count of frequency samples.
# points must be a positive, integral count of frequency samples.
points = sweep.points
if isinstance(points, bool) or not isinstance(points, int):
raise ValueError("radar.sweep.points must be an integer")
@@ -142,9 +158,11 @@ def validate_gpr_model(
) -> None:
"""Validate stable GPR config against current switch dimensions.
When ``sweep`` is supplied (load and GUI/save paths share this chokepoint),
its bounds are validated here too so #36 sweep failures surface alongside the
GPR checks instead of as a C++ boot crash.
Checks that the relative permittivity is positive and that every tx/rx
geometry entry indexes a valid, non-duplicate switch position. When ``sweep``
is supplied (the load and GUI/save paths share this chokepoint), its bounds
are validated here too so sweep failures surface alongside the GPR checks
instead of as a C++ boot crash.
"""
if sweep is not None:
validate_sweep_model(sweep)
@@ -200,7 +218,12 @@ def validate_combos(
def parse_combos_from_text(text: str) -> list[ComboModel]:
"""Parse UI combos string in `input:output,input:output` format."""
"""Parse a UI combos string in ``input:output,input:output`` format.
Returns an empty list for blank input. Raises ValueError (naming the
offending pair) on malformed syntax, empty sides, non-integer values, more
than ``_MAX_COMBOS`` entries, or a non-blank string that yields no combos.
"""
cleaned = text.strip()
if not cleaned:
return []
@@ -213,13 +236,13 @@ def parse_combos_from_text(text: str) -> list[ComboModel]:
if ":" not in pair:
raise ValueError(f"Invalid combo syntax: {pair!r}. Expected input:output")
# #57: cap the combo count so a pathological string cannot expand into a
# list large enough to stall the GUI or the acquisition loop.
# Cap the combo count so a pathological string cannot expand into a list
# large enough to stall the GUI or the acquisition loop.
if len(combos) >= _MAX_COMBOS:
raise ValueError(f"Too many combos: limit is {_MAX_COMBOS}")
input_text, output_text = (side.strip() for side in pair.split(":", 1))
# #57: reject empty sides and re-raise non-integer values naming the pair/side.
# Reject empty sides and re-raise non-integer values naming the pair/side.
if not input_text:
raise ValueError(f"Invalid combo {pair!r}: input side is empty")
if not output_text:
@@ -236,4 +259,5 @@ def parse_combos_from_text(text: str) -> list[ComboModel]:
if not combos:
raise ValueError("No valid combos were provided")
logger.debug("Parsed %d combo(s) from UI text", len(combos))
return combos