lifx-emulator 1.0.2__py3-none-any.whl → 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. lifx_emulator/__init__.py +1 -1
  2. lifx_emulator/__main__.py +26 -51
  3. lifx_emulator/api/__init__.py +18 -0
  4. lifx_emulator/api/app.py +154 -0
  5. lifx_emulator/api/mappers/__init__.py +5 -0
  6. lifx_emulator/api/mappers/device_mapper.py +114 -0
  7. lifx_emulator/api/models.py +133 -0
  8. lifx_emulator/api/routers/__init__.py +11 -0
  9. lifx_emulator/api/routers/devices.py +130 -0
  10. lifx_emulator/api/routers/monitoring.py +52 -0
  11. lifx_emulator/api/routers/scenarios.py +247 -0
  12. lifx_emulator/api/services/__init__.py +8 -0
  13. lifx_emulator/api/services/device_service.py +198 -0
  14. lifx_emulator/{api.py → api/templates/dashboard.html} +0 -942
  15. lifx_emulator/devices/__init__.py +37 -0
  16. lifx_emulator/devices/device.py +333 -0
  17. lifx_emulator/devices/manager.py +256 -0
  18. lifx_emulator/{async_storage.py → devices/persistence.py} +3 -3
  19. lifx_emulator/{state_restorer.py → devices/state_restorer.py} +2 -2
  20. lifx_emulator/devices/states.py +333 -0
  21. lifx_emulator/factories/__init__.py +37 -0
  22. lifx_emulator/factories/builder.py +371 -0
  23. lifx_emulator/factories/default_config.py +158 -0
  24. lifx_emulator/factories/factory.py +221 -0
  25. lifx_emulator/factories/firmware_config.py +59 -0
  26. lifx_emulator/factories/serial_generator.py +82 -0
  27. lifx_emulator/handlers/base.py +1 -1
  28. lifx_emulator/handlers/device_handlers.py +10 -28
  29. lifx_emulator/handlers/light_handlers.py +5 -9
  30. lifx_emulator/handlers/multizone_handlers.py +1 -1
  31. lifx_emulator/handlers/tile_handlers.py +1 -1
  32. lifx_emulator/products/generator.py +389 -170
  33. lifx_emulator/products/registry.py +52 -40
  34. lifx_emulator/products/specs.py +12 -13
  35. lifx_emulator/protocol/base.py +115 -61
  36. lifx_emulator/protocol/generator.py +18 -5
  37. lifx_emulator/protocol/packets.py +7 -7
  38. lifx_emulator/repositories/__init__.py +22 -0
  39. lifx_emulator/repositories/device_repository.py +155 -0
  40. lifx_emulator/repositories/storage_backend.py +107 -0
  41. lifx_emulator/scenarios/__init__.py +22 -0
  42. lifx_emulator/{scenario_manager.py → scenarios/manager.py} +11 -91
  43. lifx_emulator/scenarios/models.py +112 -0
  44. lifx_emulator/{scenario_persistence.py → scenarios/persistence.py} +82 -47
  45. lifx_emulator/server.py +38 -64
  46. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.0.0.dist-info}/METADATA +1 -1
  47. lifx_emulator-2.0.0.dist-info/RECORD +62 -0
  48. lifx_emulator/device.py +0 -750
  49. lifx_emulator/device_states.py +0 -114
  50. lifx_emulator/factories.py +0 -380
  51. lifx_emulator/storage_protocol.py +0 -100
  52. lifx_emulator-1.0.2.dist-info/RECORD +0 -40
  53. /lifx_emulator/{observers.py → devices/observers.py} +0 -0
  54. /lifx_emulator/{state_serializer.py → devices/state_serializer.py} +0 -0
  55. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.0.0.dist-info}/WHEEL +0 -0
  56. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.0.0.dist-info}/entry_points.txt +0 -0
  57. {lifx_emulator-1.0.2.dist-info → lifx_emulator-2.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,107 @@
1
+ """Storage backend interfaces for device and scenario persistence.
2
+
3
+ Provides abstraction for persistent storage operations,
4
+ following the Repository Pattern and Dependency Inversion Principle.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
10
+
11
+ if TYPE_CHECKING:
12
+ from lifx_emulator.scenarios import HierarchicalScenarioManager
13
+
14
+
15
+ @runtime_checkable
16
+ class IDeviceStorageBackend(Protocol):
17
+ """Interface for device state persistence operations.
18
+
19
+ This protocol defines the contract for loading and saving device state.
20
+ Concrete implementations can use async file I/O, databases,
21
+ or other storage backends.
22
+ """
23
+
24
+ async def save_device_state(self, device_state: Any) -> None:
25
+ """Save device state to persistent storage (async).
26
+
27
+ Args:
28
+ device_state: DeviceState instance to persist
29
+ """
30
+ raise NotImplementedError
31
+
32
+ def load_device_state(self, serial: str) -> dict | None:
33
+ """Load device state from persistent storage (sync).
34
+
35
+ Args:
36
+ serial: Device serial number (12-character hex string)
37
+
38
+ Returns:
39
+ Dictionary with device state data, or None if not found
40
+ """
41
+ raise NotImplementedError
42
+
43
+ def delete_device_state(self, serial: str) -> bool:
44
+ """Delete device state from persistent storage.
45
+
46
+ Args:
47
+ serial: Device serial number
48
+
49
+ Returns:
50
+ True if state was deleted, False if not found
51
+ """
52
+ raise NotImplementedError
53
+
54
+ def list_devices(self) -> list[str]:
55
+ """List all device serials with saved state.
56
+
57
+ Returns:
58
+ List of serial numbers
59
+ """
60
+ raise NotImplementedError
61
+
62
+ def delete_all_device_states(self) -> int:
63
+ """Delete all device states from persistent storage.
64
+
65
+ Returns:
66
+ Number of device states deleted
67
+ """
68
+ raise NotImplementedError
69
+
70
+ async def shutdown(self) -> None:
71
+ """Gracefully shutdown storage backend, flushing pending writes."""
72
+ raise NotImplementedError
73
+
74
+
75
+ @runtime_checkable
76
+ class IScenarioStorageBackend(Protocol):
77
+ """Interface for scenario configuration persistence operations.
78
+
79
+ This protocol defines the contract for loading and saving scenario configurations.
80
+ Concrete implementations can use async file I/O, databases,
81
+ or other storage backends.
82
+ """
83
+
84
+ async def load(self) -> HierarchicalScenarioManager:
85
+ """Load scenario configuration from persistent storage (async).
86
+
87
+ Returns:
88
+ Scenario manager with loaded configuration, or default manager
89
+ if no saved data
90
+ """
91
+ raise NotImplementedError
92
+
93
+ async def save(self, manager: HierarchicalScenarioManager) -> None:
94
+ """Save scenario configuration to persistent storage (async).
95
+
96
+ Args:
97
+ manager: Scenario manager whose configuration should be saved
98
+ """
99
+ raise NotImplementedError
100
+
101
+ async def delete(self) -> bool:
102
+ """Delete scenario configuration from persistent storage (async).
103
+
104
+ Returns:
105
+ True if configuration was deleted, False if it didn't exist
106
+ """
107
+ raise NotImplementedError
@@ -0,0 +1,22 @@
1
+ """Scenario management module for LIFX emulator.
2
+
3
+ This module contains all scenario-related functionality including:
4
+ - Scenario manager (HierarchicalScenarioManager)
5
+ - Scenario models (ScenarioConfig)
6
+ - Scenario persistence (async file storage)
7
+ - Device type classification (get_device_type)
8
+ """
9
+
10
+ from lifx_emulator.scenarios.manager import (
11
+ HierarchicalScenarioManager,
12
+ get_device_type,
13
+ )
14
+ from lifx_emulator.scenarios.models import ScenarioConfig
15
+ from lifx_emulator.scenarios.persistence import ScenarioPersistenceAsyncFile
16
+
17
+ __all__ = [
18
+ "HierarchicalScenarioManager",
19
+ "ScenarioConfig",
20
+ "ScenarioPersistenceAsyncFile",
21
+ "get_device_type",
22
+ ]
@@ -5,96 +5,12 @@ test scenarios at multiple scopes with precedence-based resolution.
5
5
  """
6
6
 
7
7
  import random
8
- from dataclasses import dataclass, field
9
- from typing import TYPE_CHECKING, Any
8
+ from typing import TYPE_CHECKING
10
9
 
11
- if TYPE_CHECKING:
12
- from lifx_emulator.device import EmulatedLifxDevice
13
-
14
-
15
- @dataclass
16
- class ScenarioConfig:
17
- """Individual scenario configuration.
18
-
19
- Scenarios define testing behaviors for the emulator, such as:
20
- - Dropping specific packet types (no response)
21
- - Adding response delays
22
- - Sending malformed/corrupted responses
23
- - Overriding firmware version
24
- """
25
-
26
- drop_packets: dict[int, float] = field(default_factory=dict)
27
- """Packet types to drop with drop rates (0.1-1.0).
28
-
29
- Maps packet type to drop rate where:
30
- - 1.0 = always drop (100%)
31
- - 0.5 = drop 50% of packets
32
- - 0.1 = drop 10% of packets
33
- Examples: {101: 1.0} (always drop), {102: 0.6} (drop 60% of packets)
34
- """
10
+ from lifx_emulator.scenarios.models import ScenarioConfig
35
11
 
36
- response_delays: dict[int, float] = field(default_factory=dict)
37
- """Response delays in seconds by packet type"""
38
-
39
- malformed_packets: list[int] = field(default_factory=list)
40
- """Packet types to send truncated/corrupted"""
41
-
42
- invalid_field_values: list[int] = field(default_factory=list)
43
- """Packet types to send with all 0xFF bytes"""
44
-
45
- firmware_version: tuple[int, int] | None = None
46
- """Override firmware version (major, minor)"""
47
-
48
- partial_responses: list[int] = field(default_factory=list)
49
- """Packet types to send incomplete multizone/tile data"""
50
-
51
- send_unhandled: bool = False
52
- """Send StateUnhandled for unknown packet types"""
53
-
54
- def to_dict(self) -> dict[str, Any]:
55
- """Convert to dictionary for API serialization."""
56
- return {
57
- "drop_packets": {str(k): v for k, v in self.drop_packets.items()},
58
- "response_delays": {str(k): v for k, v in self.response_delays.items()},
59
- "malformed_packets": self.malformed_packets,
60
- "invalid_field_values": self.invalid_field_values,
61
- "firmware_version": self.firmware_version,
62
- "partial_responses": self.partial_responses,
63
- "send_unhandled": self.send_unhandled,
64
- }
65
-
66
- @classmethod
67
- def from_dict(cls, data: dict[str, Any]) -> "ScenarioConfig":
68
- """Create from dictionary (API input)."""
69
- firmware = data.get("firmware_version")
70
- if firmware and not isinstance(firmware, tuple):
71
- firmware = tuple(firmware)
72
-
73
- # Convert drop_packets keys from string to int
74
- drop_packets_input = data.get("drop_packets", {})
75
- drop_packets = (
76
- {int(k): v for k, v in drop_packets_input.items()}
77
- if isinstance(drop_packets_input, dict)
78
- else {}
79
- )
80
-
81
- # Convert response_delays keys from string to int
82
- response_delays_input = data.get("response_delays", {})
83
- response_delays = (
84
- {int(k): v for k, v in response_delays_input.items()}
85
- if isinstance(response_delays_input, dict)
86
- else {}
87
- )
88
-
89
- return cls(
90
- drop_packets=drop_packets,
91
- response_delays=response_delays,
92
- malformed_packets=data.get("malformed_packets", []),
93
- invalid_field_values=data.get("invalid_field_values", []),
94
- firmware_version=firmware,
95
- partial_responses=data.get("partial_responses", []),
96
- send_unhandled=data.get("send_unhandled", False),
97
- )
12
+ if TYPE_CHECKING:
13
+ from lifx_emulator.devices import EmulatedLifxDevice
98
14
 
99
15
 
100
16
  def get_device_type(device: "EmulatedLifxDevice") -> str:
@@ -144,7 +60,9 @@ class HierarchicalScenarioManager:
144
60
  self.type_scenarios: dict[str, ScenarioConfig] = {} # type → config
145
61
  self.location_scenarios: dict[str, ScenarioConfig] = {} # location → config
146
62
  self.group_scenarios: dict[str, ScenarioConfig] = {} # group → config
147
- self.global_scenario: ScenarioConfig = ScenarioConfig()
63
+ self.global_scenario: ScenarioConfig = ScenarioConfig(
64
+ firmware_version=None, send_unhandled=False
65
+ )
148
66
 
149
67
  def set_device_scenario(self, serial: str, config: ScenarioConfig):
150
68
  """Set scenario for specific device by serial."""
@@ -184,7 +102,9 @@ class HierarchicalScenarioManager:
184
102
 
185
103
  def clear_global_scenario(self):
186
104
  """Clear global scenario (reset to empty)."""
187
- self.global_scenario = ScenarioConfig()
105
+ self.global_scenario = ScenarioConfig(
106
+ firmware_version=None, send_unhandled=False
107
+ )
188
108
 
189
109
  def get_global_scenario(self) -> ScenarioConfig:
190
110
  """Get global scenario configuration."""
@@ -265,7 +185,7 @@ class HierarchicalScenarioManager:
265
185
  Merged ScenarioConfig
266
186
  """
267
187
  # Start with empty config
268
- merged = ScenarioConfig()
188
+ merged = ScenarioConfig(firmware_version=None, send_unhandled=False)
269
189
 
270
190
  # Layer in each scope (general to specific)
271
191
  # Later scopes override or merge with earlier ones
@@ -0,0 +1,112 @@
1
+ """Shared domain models for LIFX emulator.
2
+
3
+ This module contains Pydantic models that are used across multiple layers
4
+ of the application (domain, API, persistence, etc.).
5
+ """
6
+
7
+ from pydantic import BaseModel, Field, field_validator
8
+
9
+
10
+ class ScenarioConfig(BaseModel):
11
+ """Scenario configuration for testing LIFX protocol behavior.
12
+
13
+ Scenarios define testing behaviors for the emulator:
14
+ - Dropping specific packet types (no response)
15
+ - Adding response delays
16
+ - Sending malformed/corrupted responses
17
+ - Overriding firmware version
18
+ - Sending incomplete data
19
+
20
+ This is used by:
21
+ - HierarchicalScenarioManager (domain layer)
22
+ - API endpoints (API layer)
23
+ - ScenarioPersistence (persistence layer)
24
+ """
25
+
26
+ drop_packets: dict[int, float] = Field(
27
+ default_factory=dict,
28
+ description="Map of packet types to drop rates (0.0-1.0). "
29
+ "1.0 = always drop, 0.5 = drop 50%, 0.1 = drop 10%. "
30
+ "Example: {101: 1.0, 102: 0.6}",
31
+ )
32
+ response_delays: dict[int, float] = Field(
33
+ default_factory=dict,
34
+ description="Map of packet types to delay in seconds before responding",
35
+ )
36
+ malformed_packets: list[int] = Field(
37
+ default_factory=list,
38
+ description="List of packet types to send with truncated/corrupted payloads",
39
+ )
40
+ invalid_field_values: list[int] = Field(
41
+ default_factory=list,
42
+ description="List of packet types to send with all 0xFF bytes in fields",
43
+ )
44
+ firmware_version: tuple[int, int] | None = Field(
45
+ None, description="Override firmware version (major, minor). Example: [3, 70]"
46
+ )
47
+ partial_responses: list[int] = Field(
48
+ default_factory=list,
49
+ description="List of packet types to send with incomplete data",
50
+ )
51
+ send_unhandled: bool = Field(
52
+ False, description="Send unhandled message responses for unknown packet types"
53
+ )
54
+
55
+ @field_validator("drop_packets", mode="before")
56
+ @classmethod
57
+ def convert_drop_packets_keys(cls, v):
58
+ """Convert string keys to integers for drop_packets.
59
+
60
+ This allows JSON serialization where keys must be strings,
61
+ but internally we use integer packet types.
62
+ """
63
+ if isinstance(v, dict):
64
+ return {int(k): float(val) for k, val in v.items()}
65
+ return v
66
+
67
+ @field_validator("response_delays", mode="before")
68
+ @classmethod
69
+ def convert_response_delays_keys(cls, v):
70
+ """Convert string keys to integers for response_delays.
71
+
72
+ This allows JSON serialization where keys must be strings,
73
+ but internally we use integer packet types.
74
+ """
75
+ if isinstance(v, dict):
76
+ return {int(k): float(val) for k, val in v.items()}
77
+ return v
78
+
79
+ @classmethod
80
+ def from_dict(cls, data: dict) -> "ScenarioConfig":
81
+ """Create from dictionary (backward compatibility wrapper).
82
+
83
+ Note: This wraps Pydantic's .model_validate() for backward compatibility.
84
+ The field_validators automatically handle string-to-int key conversion.
85
+
86
+ Args:
87
+ data: Dictionary with scenario configuration
88
+
89
+ Returns:
90
+ ScenarioConfig instance
91
+ """
92
+ return cls.model_validate(data)
93
+
94
+ def to_dict(self) -> dict:
95
+ """Convert to dictionary for JSON serialization.
96
+
97
+ Note: Pydantic models have .model_dump() which does this,
98
+ but we keep this method for backward compatibility with
99
+ existing code that expects string keys for packet types.
100
+
101
+ Returns:
102
+ Dictionary with string keys for drop_packets and response_delays
103
+ """
104
+ return {
105
+ "drop_packets": {str(k): v for k, v in self.drop_packets.items()},
106
+ "response_delays": {str(k): v for k, v in self.response_delays.items()},
107
+ "malformed_packets": self.malformed_packets,
108
+ "invalid_field_values": self.invalid_field_values,
109
+ "firmware_version": self.firmware_version,
110
+ "partial_responses": self.partial_responses,
111
+ "send_unhandled": self.send_unhandled,
112
+ }
@@ -1,46 +1,75 @@
1
- """Persistence layer for scenario configurations.
1
+ """Async persistent storage for scenario configurations.
2
2
 
3
- This module provides JSON serialization and deserialization for scenarios,
4
- allowing them to persist across emulator restarts.
3
+ This module provides async JSON serialization and deserialization for scenarios,
4
+ allowing them to persist across emulator restarts without blocking the event loop.
5
5
  """
6
6
 
7
+ from __future__ import annotations
8
+
9
+ import asyncio
7
10
  import json
8
11
  import logging
12
+ from concurrent.futures import ThreadPoolExecutor
9
13
  from pathlib import Path
10
14
  from typing import Any
11
15
 
12
- from lifx_emulator.scenario_manager import HierarchicalScenarioManager, ScenarioConfig
16
+ from lifx_emulator.scenarios.manager import HierarchicalScenarioManager, ScenarioConfig
13
17
 
14
18
  logger = logging.getLogger(__name__)
15
19
 
20
+ DEFAULT_STORAGE_DIR = Path.home() / ".lifx-emulator"
21
+
16
22
 
17
- class ScenarioPersistence:
18
- """Handles scenario persistence to disk.
23
+ class ScenarioPersistenceAsyncFile:
24
+ """Async persistent storage for scenario configurations.
19
25
 
26
+ Non-blocking asynchronous I/O for scenario persistence.
20
27
  Scenarios are stored in JSON format at ~/.lifx-emulator/scenarios.json
21
28
  with separate sections for each scope level.
29
+
30
+ Features:
31
+ - Async I/O operations (no event loop blocking)
32
+ - Executor-based I/O for file operations
33
+ - Atomic writes (write to temp file, then rename)
34
+ - Graceful error handling and recovery
22
35
  """
23
36
 
24
- def __init__(self, storage_path: Path | None = None):
25
- """Initialize scenario persistence.
37
+ def __init__(self, storage_path: Path | str | None = None):
38
+ """Initialize async scenario persistence.
26
39
 
27
40
  Args:
28
41
  storage_path: Directory to store scenarios.json
29
42
  Defaults to ~/.lifx-emulator
30
43
  """
31
44
  if storage_path is None:
32
- storage_path = Path.home() / ".lifx-emulator"
45
+ storage_path = DEFAULT_STORAGE_DIR
33
46
 
34
47
  self.storage_path = Path(storage_path)
35
48
  self.scenario_file = self.storage_path / "scenarios.json"
36
49
 
37
- def load(self) -> HierarchicalScenarioManager:
38
- """Load scenarios from disk.
50
+ # Single-thread executor for serialized I/O operations
51
+ self.executor = ThreadPoolExecutor(
52
+ max_workers=1, thread_name_prefix="scenario-io"
53
+ )
54
+
55
+ logger.debug("Async scenario storage initialized at %s", self.storage_path)
56
+
57
+ async def load(self) -> HierarchicalScenarioManager:
58
+ """Load scenarios from disk (async).
39
59
 
40
60
  Returns:
41
61
  HierarchicalScenarioManager with loaded scenarios.
42
62
  If file doesn't exist, returns empty manager.
43
63
  """
64
+ loop = asyncio.get_running_loop()
65
+ return await loop.run_in_executor(self.executor, self._sync_load)
66
+
67
+ def _sync_load(self) -> HierarchicalScenarioManager:
68
+ """Synchronous load operation (runs in executor).
69
+
70
+ Returns:
71
+ HierarchicalScenarioManager with loaded scenarios
72
+ """
44
73
  manager = HierarchicalScenarioManager()
45
74
 
46
75
  if not self.scenario_file.exists():
@@ -53,12 +82,14 @@ class ScenarioPersistence:
53
82
 
54
83
  # Load global scenario
55
84
  if "global" in data and data["global"]:
56
- manager.global_scenario = ScenarioConfig.from_dict(data["global"])
85
+ manager.global_scenario = ScenarioConfig.model_validate(data["global"])
57
86
  logger.debug("Loaded global scenario")
58
87
 
59
88
  # Load device-specific scenarios
60
89
  for serial, config_data in data.get("devices", {}).items():
61
- manager.device_scenarios[serial] = ScenarioConfig.from_dict(config_data)
90
+ manager.device_scenarios[serial] = ScenarioConfig.model_validate(
91
+ config_data
92
+ )
62
93
  if manager.device_scenarios:
63
94
  logger.debug(
64
95
  "Loaded %s device scenario(s)", len(manager.device_scenarios)
@@ -66,7 +97,7 @@ class ScenarioPersistence:
66
97
 
67
98
  # Load type-specific scenarios
68
99
  for device_type, config_data in data.get("types", {}).items():
69
- manager.type_scenarios[device_type] = ScenarioConfig.from_dict(
100
+ manager.type_scenarios[device_type] = ScenarioConfig.model_validate(
70
101
  config_data
71
102
  )
72
103
  if manager.type_scenarios:
@@ -74,7 +105,7 @@ class ScenarioPersistence:
74
105
 
75
106
  # Load location-specific scenarios
76
107
  for location, config_data in data.get("locations", {}).items():
77
- manager.location_scenarios[location] = ScenarioConfig.from_dict(
108
+ manager.location_scenarios[location] = ScenarioConfig.model_validate(
78
109
  config_data
79
110
  )
80
111
  if manager.location_scenarios:
@@ -84,7 +115,9 @@ class ScenarioPersistence:
84
115
 
85
116
  # Load group-specific scenarios
86
117
  for group, config_data in data.get("groups", {}).items():
87
- manager.group_scenarios[group] = ScenarioConfig.from_dict(config_data)
118
+ manager.group_scenarios[group] = ScenarioConfig.model_validate(
119
+ config_data
120
+ )
88
121
  if manager.group_scenarios:
89
122
  logger.debug(
90
123
  "Loaded %s group scenario(s)", len(manager.group_scenarios)
@@ -100,8 +133,17 @@ class ScenarioPersistence:
100
133
  logger.error("Failed to load scenarios from %s: %s", self.scenario_file, e)
101
134
  return manager
102
135
 
103
- def save(self, manager: HierarchicalScenarioManager) -> None:
104
- """Save scenarios to disk.
136
+ async def save(self, manager: HierarchicalScenarioManager) -> None:
137
+ """Save scenarios to disk (async).
138
+
139
+ Args:
140
+ manager: HierarchicalScenarioManager to save
141
+ """
142
+ loop = asyncio.get_running_loop()
143
+ await loop.run_in_executor(self.executor, self._sync_save, manager)
144
+
145
+ def _sync_save(self, manager: HierarchicalScenarioManager) -> None:
146
+ """Synchronous save operation (runs in executor).
105
147
 
106
148
  Args:
107
149
  manager: HierarchicalScenarioManager to save
@@ -156,8 +198,17 @@ class ScenarioPersistence:
156
198
  logger.error("Failed to save scenarios to %s: %s", self.scenario_file, e)
157
199
  raise
158
200
 
159
- def delete(self) -> bool:
160
- """Delete the scenario file.
201
+ async def delete(self) -> bool:
202
+ """Delete the scenario file (async).
203
+
204
+ Returns:
205
+ True if file was deleted, False if it didn't exist
206
+ """
207
+ loop = asyncio.get_running_loop()
208
+ return await loop.run_in_executor(self.executor, self._sync_delete)
209
+
210
+ def _sync_delete(self) -> bool:
211
+ """Synchronous delete operation (runs in executor).
161
212
 
162
213
  Returns:
163
214
  True if file was deleted, False if it didn't exist
@@ -172,35 +223,19 @@ class ScenarioPersistence:
172
223
  raise
173
224
  return False
174
225
 
226
+ async def shutdown(self) -> None:
227
+ """Gracefully shutdown executor.
175
228
 
176
- def _deserialize_response_delays(data: dict[str, Any]) -> dict[int, float]:
177
- """Convert string keys back to integers for response_delays.
178
-
179
- JSON only supports string keys, so we convert them back to ints.
180
-
181
- Args:
182
- data: Dictionary with string keys
183
-
184
- Returns:
185
- Dictionary with integer keys
186
- """
187
- if not data:
188
- return {}
189
- return {int(k): v for k, v in data.items()}
190
-
191
-
192
- # Monkey-patch ScenarioConfig.from_dict to handle string keys
193
- _original_from_dict = ScenarioConfig.from_dict
229
+ This should be called before the application exits.
230
+ """
231
+ logger.info("Shutting down async scenario storage...")
194
232
 
233
+ # Shutdown executor (non-blocking to avoid hanging on Windows)
234
+ loop = asyncio.get_running_loop()
235
+ await loop.run_in_executor(None, self.executor.shutdown, True)
195
236
 
196
- @classmethod
197
- def _from_dict_with_conversion(cls, data: dict[str, Any]) -> ScenarioConfig:
198
- """Create from dictionary with int key conversion."""
199
- # Convert response_delays string keys to ints
200
- if "response_delays" in data and data["response_delays"]:
201
- data = data.copy()
202
- data["response_delays"] = _deserialize_response_delays(data["response_delays"])
203
- return _original_from_dict(data)
237
+ logger.info("Async scenario storage shutdown complete")
204
238
 
205
239
 
206
- ScenarioConfig.from_dict = _from_dict_with_conversion # type: ignore
240
+ # Note: Pydantic's field_validators in ScenarioConfig handle string-to-int
241
+ # key conversion automatically, so no additional deserialization logic is needed.