improved logging
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user