mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-20 03:28:37 +00:00
Compare commits
3 Commits
d22d0a3754
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d297dd6217 | ||
|
|
974d7ea57a | ||
|
|
ab0cfd2362 |
@@ -75,12 +75,20 @@ def install_via_pip(package_name):
|
|||||||
Debian/Ubuntu-based systems without a virtual environment.
|
Debian/Ubuntu-based systems without a virtual environment.
|
||||||
--prefer-binary prefers pre-built wheels over source distributions to avoid
|
--prefer-binary prefers pre-built wheels over source distributions to avoid
|
||||||
exhausting /tmp space during compilation.
|
exhausting /tmp space during compilation.
|
||||||
|
--ignore-installed stops pip from trying to *uninstall* packages that were
|
||||||
|
installed by apt (e.g. python3-requests). Those Debian packages ship no
|
||||||
|
pip RECORD file, so an uninstall attempt fails with "uninstall-no-record-file"
|
||||||
|
and aborts the whole install. With --ignore-installed, pip lays the new
|
||||||
|
version down in /usr/local where it shadows the apt copy instead of removing
|
||||||
|
it. This matters when a pip dependency (google-api-python-client pulls a
|
||||||
|
newer requests) needs to upgrade an apt-managed package.
|
||||||
|
|
||||||
Returns (success, output).
|
Returns (success, output).
|
||||||
"""
|
"""
|
||||||
print(f"Installing {package_name} via pip...")
|
print(f"Installing {package_name} via pip...")
|
||||||
success, output = _run([
|
success, output = _run([
|
||||||
sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--prefer-binary', package_name
|
sys.executable, '-m', 'pip', 'install',
|
||||||
|
'--break-system-packages', '--prefer-binary', '--ignore-installed', package_name
|
||||||
])
|
])
|
||||||
if success:
|
if success:
|
||||||
print(f"Successfully installed {package_name} via pip")
|
print(f"Successfully installed {package_name} via pip")
|
||||||
@@ -200,7 +208,7 @@ def main():
|
|||||||
if setup_py.exists():
|
if setup_py.exists():
|
||||||
# Try installing - use regular install, not editable mode
|
# Try installing - use regular install, not editable mode
|
||||||
# This is optional for web interface and should already be installed in Step 6
|
# This is optional for web interface and should already be installed in Step 6
|
||||||
ok, output = _run([sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path)])
|
ok, output = _run([sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--ignore-installed', str(rgbmatrix_path)])
|
||||||
if ok:
|
if ok:
|
||||||
print("rgbmatrix module installed successfully")
|
print("rgbmatrix module installed successfully")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1505,30 +1505,68 @@ class DisplayController:
|
|||||||
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
|
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
|
||||||
self._live_resume_index = None
|
self._live_resume_index = None
|
||||||
|
|
||||||
def _check_live_priority(self):
|
def _collect_live_modes(self):
|
||||||
"""
|
"""Return every currently live-priority mode, in registration order.
|
||||||
Check all plugins for live priority content.
|
|
||||||
Returns the mode that should be displayed if live content is found, None otherwise.
|
Scans all registered plugin modes; for each plugin that has live
|
||||||
|
priority *and* live content, collects the specific live mode(s) it
|
||||||
|
reports via get_live_modes() (only those actually registered), falling
|
||||||
|
back to the scanned mode name when it ends in '_live'. Deduplicated,
|
||||||
|
preserving order. A plugin registered under several mode keys (the
|
||||||
|
sports plugins register one per league) contributes each live mode once.
|
||||||
"""
|
"""
|
||||||
|
live = []
|
||||||
|
seen = set()
|
||||||
for mode_name, plugin_instance in self.plugin_modes.items():
|
for mode_name, plugin_instance in self.plugin_modes.items():
|
||||||
if hasattr(plugin_instance, 'has_live_priority') and hasattr(plugin_instance, 'has_live_content'):
|
if not (hasattr(plugin_instance, 'has_live_priority')
|
||||||
try:
|
and hasattr(plugin_instance, 'has_live_content')):
|
||||||
if plugin_instance.has_live_priority() and plugin_instance.has_live_content():
|
continue
|
||||||
# Get the specific live mode from the plugin if available
|
try:
|
||||||
if hasattr(plugin_instance, 'get_live_modes'):
|
if not (plugin_instance.has_live_priority()
|
||||||
live_modes = plugin_instance.get_live_modes()
|
and plugin_instance.has_live_content()):
|
||||||
if live_modes and len(live_modes) > 0:
|
continue
|
||||||
# Verify the mode actually exists before returning it
|
resolved = []
|
||||||
for suggested_mode in live_modes:
|
if hasattr(plugin_instance, 'get_live_modes'):
|
||||||
if suggested_mode in self.plugin_modes:
|
for suggested_mode in (plugin_instance.get_live_modes() or []):
|
||||||
return suggested_mode
|
if suggested_mode in self.plugin_modes:
|
||||||
# If suggested modes don't exist, fall through to check current mode
|
resolved.append(suggested_mode)
|
||||||
# Fallback: if this mode ends with _live, return it
|
if not resolved and mode_name.endswith('_live'):
|
||||||
if mode_name.endswith('_live'):
|
resolved.append(mode_name)
|
||||||
return mode_name
|
for m in resolved:
|
||||||
except Exception as e:
|
if m not in seen:
|
||||||
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
seen.add(m)
|
||||||
return None
|
live.append(m)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
||||||
|
return live
|
||||||
|
|
||||||
|
def _check_live_priority(self, advance=False):
|
||||||
|
"""Return the live-priority mode to display, or None if nothing is live.
|
||||||
|
|
||||||
|
When several plugins report live content at once (e.g. a baseball game
|
||||||
|
and a soccer match), this round-robins between them so the display
|
||||||
|
alternates each dwell instead of pinning to whichever plugin is first in
|
||||||
|
registration order.
|
||||||
|
|
||||||
|
advance=False (default): a non-advancing peek — returns the live mode
|
||||||
|
already on screen if it is still live, otherwise the first live mode.
|
||||||
|
Used by the Vegas coordinator and the vegas-active check, which only
|
||||||
|
need to know whether *any* game is live (and must not spin the cursor).
|
||||||
|
|
||||||
|
advance=True: the rotation pick — returns the live mode *after* the one
|
||||||
|
currently shown, so each dwell advances to the next live game. The
|
||||||
|
currently-displayed mode is the cursor, so this stays correct as games
|
||||||
|
start and end (no separate index to keep in sync).
|
||||||
|
"""
|
||||||
|
live_modes = self._collect_live_modes()
|
||||||
|
if not live_modes:
|
||||||
|
return None
|
||||||
|
if self.current_display_mode in live_modes:
|
||||||
|
if advance:
|
||||||
|
idx = live_modes.index(self.current_display_mode)
|
||||||
|
return live_modes[(idx + 1) % len(live_modes)]
|
||||||
|
return self.current_display_mode
|
||||||
|
return live_modes[0]
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Run the display controller, switching between displays."""
|
"""Run the display controller, switching between displays."""
|
||||||
@@ -1689,9 +1727,11 @@ class DisplayController:
|
|||||||
# Display failed, clear the status and continue normally
|
# Display failed, clear the status and continue normally
|
||||||
wifi_status_data = None
|
wifi_status_data = None
|
||||||
|
|
||||||
# Check for live priority content and switch to it immediately
|
# Check for live priority content and switch to it immediately.
|
||||||
|
# advance=True so multiple simultaneously-live games take turns
|
||||||
|
# (round-robin) instead of pinning to the first plugin.
|
||||||
if not self.on_demand_active and not wifi_status_data:
|
if not self.on_demand_active and not wifi_status_data:
|
||||||
live_priority_mode = self._check_live_priority()
|
live_priority_mode = self._check_live_priority(advance=True)
|
||||||
self._apply_live_priority(live_priority_mode)
|
self._apply_live_priority(live_priority_mode)
|
||||||
|
|
||||||
# Vegas scroll mode - continuous ticker across all plugins
|
# Vegas scroll mode - continuous ticker across all plugins
|
||||||
|
|||||||
@@ -214,6 +214,104 @@ class TestDisplayControllerLivePriority:
|
|||||||
assert controller.current_mode_index == 1
|
assert controller.current_mode_index == 1
|
||||||
assert controller.current_display_mode == "b"
|
assert controller.current_display_mode == "b"
|
||||||
|
|
||||||
|
# --- Round-robin between multiple simultaneous live games --------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _live_plugin(live_modes):
|
||||||
|
"""A mock plugin that is live and reports the given live mode names."""
|
||||||
|
p = MagicMock()
|
||||||
|
p.has_live_priority = MagicMock(return_value=True)
|
||||||
|
p.has_live_content = MagicMock(return_value=True)
|
||||||
|
p.get_live_modes = MagicMock(return_value=list(live_modes))
|
||||||
|
return p
|
||||||
|
|
||||||
|
def test_collect_live_modes_dedupes_multi_mode_plugin(self, test_display_controller):
|
||||||
|
"""A sports plugin registered under several mode keys (one per league)
|
||||||
|
contributes each live mode once, in registration order; plugins with no
|
||||||
|
live content are skipped."""
|
||||||
|
controller = test_display_controller
|
||||||
|
baseball = self._live_plugin(["baseball_live"])
|
||||||
|
soccer = self._live_plugin(["soccer_fifa.world_live"])
|
||||||
|
idle = MagicMock()
|
||||||
|
idle.has_live_priority = MagicMock(return_value=True)
|
||||||
|
idle.has_live_content = MagicMock(return_value=False)
|
||||||
|
controller.plugin_modes = {
|
||||||
|
"baseball_live": baseball,
|
||||||
|
"baseball_recent": baseball,
|
||||||
|
"soccer_fifa.world_live": soccer,
|
||||||
|
"soccer_usa.1_live": soccer,
|
||||||
|
"soccer_recent": soccer,
|
||||||
|
"clock": idle,
|
||||||
|
}
|
||||||
|
assert controller._collect_live_modes() == [
|
||||||
|
"baseball_live", "soccer_fifa.world_live"
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_round_robin_alternates_between_simultaneous_live_games(self, test_display_controller):
|
||||||
|
"""Regression: with two games live at once, the live-priority pick
|
||||||
|
round-robins each dwell instead of pinning to the first plugin in
|
||||||
|
registration order (the bug where a baseball game hid a live World Cup
|
||||||
|
match)."""
|
||||||
|
controller = test_display_controller
|
||||||
|
baseball = self._live_plugin(["baseball_live"])
|
||||||
|
soccer = self._live_plugin(["soccer_fifa.world_live"])
|
||||||
|
controller.plugin_modes = {
|
||||||
|
"baseball_live": baseball,
|
||||||
|
"soccer_fifa.world_live": soccer,
|
||||||
|
}
|
||||||
|
# First entry into live priority from an ambient mode -> first live game.
|
||||||
|
controller.current_display_mode = "clock"
|
||||||
|
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||||
|
# The controller switches to it; the next dwell advances to the other.
|
||||||
|
controller.current_display_mode = "baseball_live"
|
||||||
|
assert controller._check_live_priority(advance=True) == "soccer_fifa.world_live"
|
||||||
|
# And wraps back again.
|
||||||
|
controller.current_display_mode = "soccer_fifa.world_live"
|
||||||
|
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||||
|
|
||||||
|
def test_single_live_game_holds_without_flipping(self, test_display_controller):
|
||||||
|
"""One live game: advancing returns the same mode, so the hold is stable."""
|
||||||
|
controller = test_display_controller
|
||||||
|
controller.plugin_modes = {"baseball_live": self._live_plugin(["baseball_live"])}
|
||||||
|
controller.current_display_mode = "baseball_live"
|
||||||
|
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||||
|
|
||||||
|
def test_non_advancing_peek_does_not_rotate(self, test_display_controller):
|
||||||
|
"""The default (advance=False) peek used by the Vegas coordinator must
|
||||||
|
not spin the cursor: it returns the live mode already on screen."""
|
||||||
|
controller = test_display_controller
|
||||||
|
controller.plugin_modes = {
|
||||||
|
"baseball_live": self._live_plugin(["baseball_live"]),
|
||||||
|
"soccer_fifa.world_live": self._live_plugin(["soccer_fifa.world_live"]),
|
||||||
|
}
|
||||||
|
controller.current_display_mode = "soccer_fifa.world_live"
|
||||||
|
assert controller._check_live_priority() == "soccer_fifa.world_live"
|
||||||
|
assert controller._check_live_priority() == "soccer_fifa.world_live"
|
||||||
|
# From an ambient mode the peek reports the first live game (truthy).
|
||||||
|
controller.current_display_mode = "clock"
|
||||||
|
assert controller._check_live_priority() == "baseball_live"
|
||||||
|
|
||||||
|
def test_no_live_content_returns_none(self, test_display_controller):
|
||||||
|
controller = test_display_controller
|
||||||
|
idle = MagicMock()
|
||||||
|
idle.has_live_priority = MagicMock(return_value=True)
|
||||||
|
idle.has_live_content = MagicMock(return_value=False)
|
||||||
|
controller.plugin_modes = {"clock": idle}
|
||||||
|
controller.current_display_mode = "clock"
|
||||||
|
assert controller._check_live_priority(advance=True) is None
|
||||||
|
|
||||||
|
def test_fallback_to_mode_name_when_get_live_modes_unhelpful(self, test_display_controller):
|
||||||
|
"""A live plugin whose get_live_modes returns nothing registered falls
|
||||||
|
back to its own '_live' mode name (legacy behavior preserved)."""
|
||||||
|
controller = test_display_controller
|
||||||
|
legacy = MagicMock()
|
||||||
|
legacy.has_live_priority = MagicMock(return_value=True)
|
||||||
|
legacy.has_live_content = MagicMock(return_value=True)
|
||||||
|
legacy.get_live_modes = MagicMock(return_value=["unregistered_mode"])
|
||||||
|
controller.plugin_modes = {"hockey_live": legacy}
|
||||||
|
controller.current_display_mode = "clock"
|
||||||
|
assert controller._check_live_priority(advance=True) == "hockey_live"
|
||||||
|
|
||||||
|
|
||||||
class TestDisplayControllerDynamicDuration:
|
class TestDisplayControllerDynamicDuration:
|
||||||
"""Test dynamic duration handling."""
|
"""Test dynamic duration handling."""
|
||||||
|
|||||||
93
test/web_interface/test_dotted_league_keys.py
Normal file
93
test/web_interface/test_dotted_league_keys.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
Regression test for saving plugin config fields whose schema keys contain dots
|
||||||
|
(e.g. soccer league keys like "fifa.world", "eng.1", "usa.1").
|
||||||
|
|
||||||
|
Bug: the web config form posts form-data with dotted paths such as
|
||||||
|
"leagues.fifa.world.enabled". The helpers that resolve those paths split on every
|
||||||
|
dot, so the dotted league key "fifa.world" was mistaken for nested "fifa" ->
|
||||||
|
"world" objects. Per-league edits (enable, favorite_teams, nested booleans) were
|
||||||
|
written to a fabricated "leagues.fifa.world" branch while the real league object
|
||||||
|
was never updated, so the save silently dropped the change and the saved config
|
||||||
|
came out byte-identical.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from web_interface.blueprints.api_v3 import (
|
||||||
|
_get_schema_property,
|
||||||
|
_set_nested_value,
|
||||||
|
_parse_form_value_with_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"leagues": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"fifa.world": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {"type": "boolean"},
|
||||||
|
"favorite_teams": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
},
|
||||||
|
"display_modes": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"live": {"type": "boolean"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestDottedLeagueKeys(unittest.TestCase):
|
||||||
|
def test_schema_lookup_resolves_dotted_league_key(self):
|
||||||
|
prop = _get_schema_property(SCHEMA, "leagues.fifa.world.favorite_teams")
|
||||||
|
self.assertIsNotNone(prop, "dotted league key path should resolve")
|
||||||
|
self.assertEqual(prop.get("type"), "array")
|
||||||
|
|
||||||
|
def test_schema_lookup_resolves_nested_object_beneath_dotted_key(self):
|
||||||
|
live = _get_schema_property(SCHEMA, "leagues.fifa.world.display_modes.live")
|
||||||
|
self.assertIsNotNone(live)
|
||||||
|
self.assertEqual(live.get("type"), "boolean")
|
||||||
|
|
||||||
|
def test_parse_typed_value_for_dotted_key(self):
|
||||||
|
# Comma-separated text input "USA" must become an array, not the raw string.
|
||||||
|
parsed = _parse_form_value_with_schema(
|
||||||
|
"USA", "leagues.fifa.world.favorite_teams", SCHEMA
|
||||||
|
)
|
||||||
|
self.assertEqual(parsed, ["USA"])
|
||||||
|
|
||||||
|
def test_set_value_updates_real_league_not_fabricated_branch(self):
|
||||||
|
config = {"leagues": {"fifa.world": {"enabled": False, "favorite_teams": []}}}
|
||||||
|
_set_nested_value(config, "leagues.fifa.world.enabled", True)
|
||||||
|
_set_nested_value(config, "leagues.fifa.world.favorite_teams", ["USA"])
|
||||||
|
|
||||||
|
self.assertTrue(config["leagues"]["fifa.world"]["enabled"])
|
||||||
|
self.assertEqual(config["leagues"]["fifa.world"]["favorite_teams"], ["USA"])
|
||||||
|
# The real league must be updated and no fabricated "fifa" branch created.
|
||||||
|
self.assertNotIn("fifa", config["leagues"])
|
||||||
|
|
||||||
|
def test_set_value_into_missing_leaf_lands_in_real_league(self):
|
||||||
|
# A leaf that does not exist yet still resolves into the real dotted league.
|
||||||
|
config = {"leagues": {"fifa.world": {"enabled": False}}}
|
||||||
|
_set_nested_value(config, "leagues.fifa.world.display_modes.live", True)
|
||||||
|
self.assertTrue(
|
||||||
|
config["leagues"]["fifa.world"]["display_modes"]["live"]
|
||||||
|
)
|
||||||
|
self.assertNotIn("fifa", config["leagues"])
|
||||||
|
|
||||||
|
def test_plain_nested_paths_still_work(self):
|
||||||
|
config = {}
|
||||||
|
_set_nested_value(config, "customization.text.font", "small")
|
||||||
|
self.assertEqual(config["customization"]["text"]["font"], "small")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -3558,21 +3558,29 @@ def _get_schema_property(schema, key_path):
|
|||||||
|
|
||||||
parts = key_path.split('.')
|
parts = key_path.split('.')
|
||||||
current = schema['properties']
|
current = schema['properties']
|
||||||
|
i = 0
|
||||||
|
|
||||||
for i, part in enumerate(parts):
|
while i < len(parts):
|
||||||
if part not in current:
|
# Try progressively longer candidates, longest first, so schema keys that
|
||||||
return None
|
# themselves contain dots (e.g. league keys like "fifa.world") are matched
|
||||||
|
# instead of being mistaken for nested "fifa" -> "world" objects.
|
||||||
prop = current[part]
|
matched = False
|
||||||
|
for j in range(len(parts), i, -1):
|
||||||
# If this is the last part, return the property
|
candidate = '.'.join(parts[i:j])
|
||||||
if i == len(parts) - 1:
|
if isinstance(current, dict) and candidate in current:
|
||||||
return prop
|
prop = current[candidate]
|
||||||
|
# Consumed all remaining parts — this is the target property.
|
||||||
# If this is an object with properties, navigate deeper
|
if j == len(parts):
|
||||||
if isinstance(prop, dict) and 'properties' in prop:
|
return prop
|
||||||
current = prop['properties']
|
# Navigate deeper through an object with properties.
|
||||||
else:
|
if isinstance(prop, dict) and 'properties' in prop:
|
||||||
|
current = prop['properties']
|
||||||
|
i = j
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
# Matched a non-object before consuming the path — can't go deeper.
|
||||||
|
return None
|
||||||
|
if not matched:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return None
|
return None
|
||||||
@@ -3745,10 +3753,45 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_key_segments(key_path, config):
|
||||||
|
"""Split a dot-notation path into segments, greedily preserving keys that
|
||||||
|
themselves contain dots (e.g. league keys like "fifa.world").
|
||||||
|
|
||||||
|
At each level the longest candidate that matches a key already present in the
|
||||||
|
config wins; otherwise the path splits on the next dot (the normal
|
||||||
|
nested-create case). Because dotted keys such as ``leagues."fifa.world"``
|
||||||
|
always exist in the saved config being updated, this routes the value to the
|
||||||
|
real league object instead of fabricating a ``leagues.fifa.world`` tree.
|
||||||
|
"""
|
||||||
|
parts = key_path.split('.')
|
||||||
|
segments = []
|
||||||
|
node = config
|
||||||
|
i = 0
|
||||||
|
while i < len(parts):
|
||||||
|
matched = False
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for j in range(len(parts), i, -1):
|
||||||
|
candidate = '.'.join(parts[i:j])
|
||||||
|
if candidate in node:
|
||||||
|
segments.append(candidate)
|
||||||
|
node = node[candidate]
|
||||||
|
i = j
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
if not matched:
|
||||||
|
part = parts[i]
|
||||||
|
segments.append(part)
|
||||||
|
node = node.get(part) if isinstance(node, dict) else None
|
||||||
|
i += 1
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
def _set_nested_value(config, key_path, value):
|
def _set_nested_value(config, key_path, value):
|
||||||
"""
|
"""
|
||||||
Set a value in a nested dict using dot notation path.
|
Set a value in a nested dict using dot notation path.
|
||||||
Handles existing nested dicts correctly by merging instead of replacing.
|
Handles existing nested dicts correctly by merging instead of replacing.
|
||||||
|
Keys containing dots (e.g. league keys like "fifa.world") are preserved when
|
||||||
|
they already exist in the config rather than being split into nested objects.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: The config dict to modify
|
config: The config dict to modify
|
||||||
@@ -3759,21 +3802,21 @@ def _set_nested_value(config, key_path, value):
|
|||||||
if value is _SKIP_FIELD:
|
if value is _SKIP_FIELD:
|
||||||
return
|
return
|
||||||
|
|
||||||
parts = key_path.split('.')
|
segments = _resolve_key_segments(key_path, config)
|
||||||
current = config
|
current = config
|
||||||
|
|
||||||
# Navigate/create intermediate dicts
|
# Navigate/create intermediate dicts
|
||||||
for i, part in enumerate(parts[:-1]):
|
for seg in segments[:-1]:
|
||||||
if part not in current:
|
if seg not in current:
|
||||||
current[part] = {}
|
current[seg] = {}
|
||||||
elif not isinstance(current[part], dict):
|
elif not isinstance(current[seg], dict):
|
||||||
# If the existing value is not a dict, replace it with a dict
|
# If the existing value is not a dict, replace it with a dict
|
||||||
current[part] = {}
|
current[seg] = {}
|
||||||
current = current[part]
|
current = current[seg]
|
||||||
|
|
||||||
# Set the final value (don't overwrite with empty dict if value is None and we want to preserve structure)
|
# Set the final value (don't overwrite with empty dict if value is None and we want to preserve structure)
|
||||||
if value is not None or parts[-1] not in current:
|
if value is not None or segments[-1] not in current:
|
||||||
current[parts[-1]] = value
|
current[segments[-1]] = value
|
||||||
|
|
||||||
|
|
||||||
def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None):
|
def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None):
|
||||||
|
|||||||
Reference in New Issue
Block a user