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
+6
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
from typing import Any
from python_app.models.gui_profile_schema import (
@@ -18,6 +19,8 @@ from python_app.models.gui_profile_schema import (
)
from python_app.models.run_config_model import RunConfigModel
logger = logging.getLogger(__name__)
def _as_dict(value: Any, context: str) -> dict[str, Any]:
"""Validate payload node is object-like, treating missing values as empty object."""
@@ -74,6 +77,7 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
profile = GuiProfileModel(run_config=RunConfigModel.from_dict(payload), gui=None)
gui_payload = payload.get("gui")
if gui_payload is None:
logger.debug("Decoded GUI profile without a 'gui' section; UI state left unset")
return profile
gui_object = _as_dict(gui_payload, "gui")
@@ -124,6 +128,7 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
and root_gpr_object.get("mode") in {"point", "extended"}
)
if selected_mode == "gpr" and (legacy_algorithm_mode is not None or has_legacy_root_gpr_mode):
logger.debug("Migrating legacy GPR profile: rewriting selected_mode 'gpr' -> 'legacy_gpr'")
selected_mode = "legacy_gpr"
gpr_context = "gui.processing.gpr"
@@ -495,6 +500,7 @@ def gui_profile_from_dict(payload: dict[str, Any]) -> GuiProfileModel:
)
profile.gui = gui
logger.debug("Decoded GUI profile: selected_mode=%s", gui.processing.selected_mode)
return profile
+8 -1
View File
@@ -5,11 +5,14 @@ from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
import json
import logging
from pathlib import Path
from typing import Any
from python_app.models.run_config_model import RunConfigModel
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class GuiSwitchStateModel:
@@ -159,7 +162,11 @@ class GuiProfileModel:
@classmethod
def load_from_path(cls, path: Path) -> GuiProfileModel:
"""Load JSON file from disk and decode into profile model."""
"""Load a JSON file from disk and decode it into a profile model.
Raises ValueError when the file's JSON root is not an object.
"""
logger.debug("Loading GUI profile from %s", path)
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"Config profile root must be JSON object: {path}")
+41 -23
View File
@@ -2,9 +2,12 @@
from __future__ import annotations
import logging
import math
from typing import Any
from python_app.logging_setup import LOG_LEVELS
from python_app.models.run_config_schema import (
ComboModel,
GprRxGeometryModel,
@@ -21,6 +24,8 @@ from python_app.models.run_config_validation import (
validate_gpr_model,
)
logger = logging.getLogger(__name__)
def _as_dict(value: Any, context: str) -> dict[str, Any]:
"""Validate payload node is object-like, treating missing values as empty object."""
@@ -45,51 +50,47 @@ def _as_list(value: Any, context: str) -> list[Any]:
def _read_str(payload: dict[str, Any], key: str, default: str) -> str:
"""Return payload string, treating an explicit JSON `null` as missing.
"""Return a payload string, treating an explicit JSON ``null`` as 'use default'.
`payload.get(key, default)` returns `None` when the key exists with value
`null`, which is then coerced into the literal string `"None"` by `str()`.
Keeping the default on ``null`` avoids coercing it to the literal string
``"None"``. JSON arrays/objects reaching a scalar field are rejected as
ValueError to keep the config-error contract uniform.
"""
value = payload.get(key, default)
if value is None:
return default
# A JSON array/object reaching a scalar field is a config error, not a
# str() fallback; surface it as ValueError to keep the error contract uniform.
if isinstance(value, (dict, list)):
raise ValueError(f"{key} must be a JSON string")
return str(value)
def _read_int(payload: dict[str, Any], key: str, default: int) -> int:
"""Return payload integer, treating an explicit JSON `null` as 'use default'.
"""Return a payload integer, treating an explicit JSON ``null`` as 'use default'.
Without this, `int(payload.get(key, default))` raises TypeError on an
explicit `null`. JSON arrays/objects (and other non-numeric scalars) are
rejected as ValueError so malformed types share the config-error contract.
Accepts 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 ``gui_profile_codec._optional_int`` so the two
codecs agree.
"""
value = payload.get(key, default)
if value is None:
return 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
# gui_profile_codec._optional_int so the two codecs agree.
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"{key} must be a JSON integer")
return value
def _read_float(payload: dict[str, Any], key: str, default: float) -> float:
"""Return payload float, treating an explicit JSON `null` as 'use default'.
"""Return a payload float, treating an explicit JSON ``null`` as 'use default'.
Rejects JSON arrays/objects (and other non-numeric scalars) as ValueError,
and rejects non-finite values (NaN/Infinity) at decode time so the C++
pipeline never receives a value it cannot honor.
Accepts only a genuine JSON number (int/float, not bool, not numeric
string); parsing ``"1e9"`` would hide a malformed config. Non-finite values
(NaN/Infinity) are rejected at decode time so the C++ pipeline never receives
a value it cannot honor. Mirrors ``gui_profile_codec._optional_float``.
"""
value = payload.get(key, default)
if value is None:
return default
# Accept only a genuine JSON number (int/float, not bool, not numeric string):
# parsing "1e9" would hide a malformed config. Mirrors gui_profile_codec.
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ValueError(f"{key} must be a JSON number")
result = float(value)
@@ -99,11 +100,11 @@ def _read_float(payload: dict[str, Any], key: str, default: float) -> float:
def _read_bool(payload: dict[str, Any], key: str, default: bool) -> bool:
"""Return payload boolean, treating an explicit JSON `null` as 'use default'.
"""Return a payload boolean, treating an explicit JSON ``null`` as 'use default'.
Plain `bool(payload.get(key, default))` would silently flip the default to
`False` on an explicit `null`; here `null` keeps the default instead.
Non-boolean JSON types are rejected as ValueError.
Keeping the default on ``null`` avoids the silent flip to ``False`` that a
plain ``bool(...)`` coercion would produce. Non-boolean JSON types are
rejected as ValueError.
"""
value = payload.get(key, default)
if value is None:
@@ -321,6 +322,14 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
load_switch_payload(port1_payload, model.output_switch)
load_switch_payload(port2_payload, model.input_switch)
load_control_button_payload(control_button_payload, model.control_button)
logging_payload = _as_dict(payload.get("logging"), "logging")
level = _read_str(logging_payload, "level", model.logging.level).strip().lower()
if level.upper() not in LOG_LEVELS:
valid = ", ".join(name.lower() for name in LOG_LEVELS)
raise ValueError(f"logging.level must be one of: {valid}")
model.logging.level = level
model.apply_device_model_constraints()
runtime = model.runtime
@@ -406,7 +415,7 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
)
)
model.apply_device_model_constraints()
# Pass sweep= so #36 sweep bounds (points > 0, stop_hz >= start_hz) are validated
# Pass sweep= so the sweep bounds (points > 0, stop_hz > start_hz) are validated
# on the config-load path instead of crashing the C++ acquisition process at boot.
validate_gpr_model(
model.gpr,
@@ -437,6 +446,12 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
input_positions=model.input_switch.positions,
output_positions=model.output_switch.positions,
)
logger.debug(
"Decoded run config: radar.model=%s driver_mode=%s combos=%d",
model.radar.model,
model.radar.driver_mode,
len(model.combos),
)
return model
@@ -540,6 +555,9 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
"debounce_ms": model.control_button.debounce_ms,
"action": model.control_button.action,
},
"logging": {
"level": model.logging.level,
},
"run": {
"settling_ms": model.runtime.settling_ms,
"idle_sleep_ms": model.runtime.idle_sleep_ms,
+21 -1
View File
@@ -5,9 +5,12 @@ from __future__ import annotations
from dataclasses import dataclass, field
import hashlib
import json
import logging
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class ComboModel:
@@ -276,6 +279,18 @@ class GprModel:
rx_geometry: list[GprRxGeometryModel] = field(default_factory=list)
@dataclass(slots=True)
class LoggingModel:
"""Application logging settings shared by the GUI and headless daemon.
``level`` is the verbosity floor (one of DEBUG/INFO/WARNING/ERROR, case-insensitive);
it is chosen from the UI log-level selector, applied to the ``python_app`` logger at
startup, and persisted here so the same verbosity is restored on the next run.
"""
level: str = "info"
@dataclass(slots=True)
class RunConfigModel:
"""Top-level runtime config model consumed by C++ processes and GUI."""
@@ -289,6 +304,7 @@ class RunConfigModel:
gpr: GprModel = field(default_factory=GprModel)
combos: list[ComboModel] = field(default_factory=list)
control_button: ControlButtonModel = field(default_factory=ControlButtonModel)
logging: LoggingModel = field(default_factory=LoggingModel)
LIBREVNA_MODEL = "librevna"
LIBREVNA_MULTI_MODEL = "librevna_multi"
@@ -427,7 +443,11 @@ class RunConfigModel:
@classmethod
def load_from_path(cls, path: Path) -> RunConfigModel:
"""Load JSON file from disk and decode into model."""
"""Load a JSON file from disk and decode it into a model.
Raises ValueError when the file's JSON root is not an object.
"""
logger.debug("Loading run config from %s", path)
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"Config root must be JSON object: {path}")
+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