From 734ecbab79bb20e634f018e3d5fd83d9379eeb92 Mon Sep 17 00:00:00 2001 From: Roman Pszonka Date: Thu, 22 Jan 2026 22:20:25 +0000 Subject: [PATCH 1/6] GUI improvements --- pytest.ini | 1 + scenarios/F1_happy_path.yaml | 16 +- .../flight_blender/flight_blender_client.py | 170 +++++++++++++----- .../core/execution/conditions.py | 2 +- .../core/execution/execution.py | 46 ++++- .../core/execution/scenario_runner.py | 19 +- .../core/reporting/reporting_models.py | 1 + src/openutm_verification/scenarios/common.py | 9 +- .../scenarios/test_traffic_and_telemetry.py | 53 ++++++ src/openutm_verification/server/main.py | 9 +- src/openutm_verification/server/runner.py | 77 ++++++-- tests/test_client_steps.py | 12 +- web-editor/src/components/ScenarioEditor.tsx | 9 +- .../components/ScenarioEditor/CustomNode.tsx | 7 +- .../src/components/ScenarioEditor/Header.tsx | 10 +- .../ScenarioEditor/__tests__/Header.test.tsx | 1 + web-editor/src/hooks/useScenarioRunner.ts | 36 +++- web-editor/src/styles/Node.module.css | 6 + web-editor/src/types/scenario.ts | 2 +- web-editor/src/utils/layoutConfig.ts | 2 +- web-editor/vite.config.ts | 3 + 21 files changed, 403 insertions(+), 88 deletions(-) create mode 100644 src/openutm_verification/scenarios/test_traffic_and_telemetry.py diff --git a/pytest.ini b/pytest.ini index 6bbac54..3780578 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,4 +1,5 @@ [pytest] +testpaths = tests markers = asyncio: mark test as asyncio addopts = -q --tb=no diff --git a/scenarios/F1_happy_path.yaml b/scenarios/F1_happy_path.yaml index 68b0ab8..0b60484 100644 --- a/scenarios/F1_happy_path.yaml +++ b/scenarios/F1_happy_path.yaml @@ -1,23 +1,25 @@ name: F1_happy_path -description: > - This scenario verifies the nominal flow (Happy Path) for a flight operation. - It walks through the lifecycle of a flight from declaration to activation, - submission of telemetry, and finally ending the operation. +description: 'This scenario verifies the nominal flow (Happy Path) for a flight operation. + It walks through the lifecycle of a flight from declaration to activation, submission + of telemetry, and finally ending the operation. + + ' steps: +- step: Cleanup Flight Declarations - step: Setup Flight Declaration description: Creates a fresh flight declaration in the DSS. - step: Update Operation State - description: Activates the flight operation, transitioning it to the active state. arguments: state: ACTIVATED + description: Activates the flight operation, transitioning it to the active state. - step: Submit Telemetry - description: Simulates the broadcast of telemetry data for 30 seconds. arguments: duration: 30 + description: Simulates the broadcast of telemetry data for 30 seconds. - id: update_state_ended step: Update Operation State - description: Marks the operation as ended after the flight is complete. arguments: state: ENDED + description: Marks the operation as ended after the flight is complete. - step: Teardown Flight Declaration description: Cleans up the flight declaration and any associated resources. diff --git a/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py b/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py index 8ed772b..9e88be5 100644 --- a/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py +++ b/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py @@ -244,20 +244,13 @@ async def upload_flight_declaration(self, declaration: str | BaseModel) -> dict[ logger.debug("Uploading flight declaration from model") flight_declaration = declaration.model_dump(mode="json") - # Adjust datetimes to current time + offsets - now = arrow.now() - few_seconds_from_now = now.shift(seconds=5) - four_minutes_from_now = now.shift(minutes=4) - - flight_declaration["start_datetime"] = few_seconds_from_now.isoformat() - flight_declaration["end_datetime"] = four_minutes_from_now.isoformat() - response = await self.post(endpoint, json=flight_declaration) logger.info(f"Flight declaration upload response: {response.status_code}") response_json = response.json() if not response_json.get("is_approved"): + logger.error(f"Flight declaration not approved. Full Response: {json.dumps(response_json, indent=2)}") logger.error(f"Flight declaration not approved. State: {OperationState(response_json.get('state')).name}") raise FlightBlenderError(f"Flight declaration not approved. State: {OperationState(response_json.get('state')).name}") # Store latest declaration id for later use @@ -303,13 +296,7 @@ async def upload_flight_declaration_via_operational_intent(self, declaration: st logger.debug("Uploading flight declaration from model") flight_declaration = declaration.model_dump(mode="json") - # Adjust datetimes to current time + offsets - now = arrow.now() - few_seconds_from_now = now.shift(seconds=5) - four_minutes_from_now = now.shift(minutes=4) - - flight_declaration["start_datetime"] = few_seconds_from_now.isoformat() - flight_declaration["end_datetime"] = four_minutes_from_now.isoformat() + # Use provided datetimes from the declaration response = await self.post(endpoint, json=flight_declaration) logger.info(f"Flight declaration upload response: {response.status_code}") @@ -398,6 +385,7 @@ async def _submit_telemetry_states_impl(self, states: list[RIDAircraftState], du Raises: FlightBlenderError: If maximum waiting time is exceeded due to rate limits. + asyncio.CancelledError: If the task is cancelled (propagated, not caught). """ duration_seconds = parse_duration(duration) endpoint = "/flight_stream/set_telemetry" @@ -412,32 +400,36 @@ async def _submit_telemetry_states_impl(self, states: list[RIDAircraftState], du billable_time_elapsed = 0.0 sleep_interval = 1.0 logger.info(f"Starting telemetry submission for {len(states)} states") - for i, state in enumerate(states): - if duration_seconds and billable_time_elapsed >= duration_seconds: - logger.info(f"Telemetry submission duration of {duration_seconds} seconds has passed.") - break - - payload = { - "observations": [ - { - "current_states": [state], - "flight_details": asdict(rid_operator_details), - } - ] - } - response = await self.put(endpoint, json=payload, silent_status=[400]) - request_duration = response.elapsed.total_seconds() - if response.status_code == 201: - logger.info(f"Telemetry point {i + 1} submitted, sleeping {sleep_interval} seconds... {billable_time_elapsed:.2f}s elapsed") - billable_time_elapsed += request_duration + sleep_interval - else: - logger.warning(f"{response.status_code} {response.json()}") - waiting_time_elapsed += request_duration + sleep_interval - if waiting_time_elapsed >= maximum_waiting_time + sleep_interval: - logger.error(f"Maximum waiting time of {maximum_waiting_time} seconds exceeded.") - raise FlightBlenderError(f"Maximum waiting time of {maximum_waiting_time} seconds exceeded.") - last_response = response.json() - await asyncio.sleep(sleep_interval) + try: + for i, state in enumerate(states): + if duration_seconds and billable_time_elapsed >= duration_seconds: + logger.info(f"Telemetry submission duration of {duration_seconds} seconds has passed.") + break + + payload = { + "observations": [ + { + "current_states": [state], + "flight_details": asdict(rid_operator_details), + } + ] + } + response = await self.put(endpoint, json=payload, silent_status=[400]) + request_duration = response.elapsed.total_seconds() + if response.status_code == 201: + logger.info(f"Telemetry point {i + 1} submitted, sleeping {sleep_interval} seconds... {billable_time_elapsed:.2f}s elapsed") + billable_time_elapsed += request_duration + sleep_interval + else: + logger.warning(f"{response.status_code} {response.json()}") + waiting_time_elapsed += request_duration + sleep_interval + if waiting_time_elapsed >= maximum_waiting_time + sleep_interval: + logger.error(f"Maximum waiting time of {maximum_waiting_time} seconds exceeded.") + raise FlightBlenderError(f"Maximum waiting time of {maximum_waiting_time} seconds exceeded.") + last_response = response.json() + await asyncio.sleep(sleep_interval) + except asyncio.CancelledError: + logger.info("Telemetry submission cancelled") + raise logger.info("Telemetry submission completed") return last_response @@ -552,14 +544,69 @@ async def check_operation_state_connected( f"Operation {self.latest_flight_declaration_id} did not reach expected state {expected_state.name} within {duration_seconds} seconds" ) + @scenario_step("Cleanup Flight Declarations") + async def cleanup_flight_declarations(self) -> dict[str, Any]: + """Specific cleanup for flight declarations in the active volume. + + This method lists all existing flight declarations and deletes them one by one. + This is useful for cleaning up 'zombie' declarations from previous failed runs. + """ + endpoint = "/flight_declaration_ops/flight_declaration" + logger.info("Cleaning up existing flight declarations...") + + try: + response = await self.get(endpoint) + if response.status_code != 200: + logger.warning(f"Failed to list flight declarations: {response.status_code}") + return {"cleaned": False, "reason": "List failed"} + + data = response.json() + # Handle pagination + if isinstance(data, dict) and "results" in data: + declarations = data["results"] + elif isinstance(data, list): + declarations = data + else: + logger.warning("Unexpected response format for flight declaration list") + return {"cleaned": False, "reason": "Invalid format"} + + logger.info(f"Found {len(declarations)} existing flight declarations") + deleted_count = 0 + + for declaration in declarations: + dec_id = declaration.get("id") + if dec_id: + logger.debug(f"Deleting cleanup target: {dec_id}") + # Direct delete to avoid nested steps and cluttering reports + cleanup_endpoint = f"/flight_declaration_ops/flight_declaration/{dec_id}/delete" + delete_response = await self.delete(cleanup_endpoint) + if delete_response.status_code == 204: + deleted_count += 1 + else: + logger.warning(f"Failed to delete {dec_id}: {delete_response.status_code}") + + logger.info(f"Cleanup complete. Deleted {deleted_count} declarations.") + # Yield for backend consistency + if deleted_count > 0: + await asyncio.sleep(2) + return {"cleaned": True, "count": deleted_count} + + except Exception as e: + logger.error(f"Error during flight declaration cleanup: {e}") + return {"cleaned": False, "error": str(e)} + @scenario_step("Delete Flight Declaration") - async def delete_flight_declaration(self) -> dict[str, Any]: + async def delete_flight_declaration(self, flight_declaration_id: str | None = None) -> dict[str, Any]: """Delete a flight declaration by ID. + Args: + flight_declaration_id: Optional ID of the flight declaration to delete. If not provided, + uses the latest uploaded flight declaration ID. + Returns: A dictionary with deletion status, including whether it was successful. """ - op_id = self.latest_flight_declaration_id + op_id = flight_declaration_id or self.latest_flight_declaration_id if not op_id: logger.warning("No flight declaration ID available for deletion") return { @@ -588,6 +635,10 @@ async def submit_simulated_air_traffic( observations: list[list[FlightObservationSchema]], single_or_multiple_sensors: str = "single", ) -> bool: + if not observations: + logger.warning("No air traffic observations to submit.") + return True + now = arrow.now() number_of_aircraft = len(observations) logger.debug(f"Submitting simulated air traffic for {number_of_aircraft} aircraft") @@ -603,6 +654,11 @@ async def submit_simulated_air_traffic( continue start_times.append(arrow.get(aircraft_obs[0].timestamp)) end_times.append(arrow.get(aircraft_obs[-1].timestamp)) + + if not start_times: + logger.warning("No valid start/end times found in observations.") + return True + simulation_start = min(start_times) simulation_end = max(end_times) @@ -857,9 +913,18 @@ async def setup_flight_declaration_via_operational_intent( generate_telemetry, ) + # Synchronize start times + now = arrow.now() + start_time = now.shift(seconds=2) + end_time = now.shift(minutes=60) + flight_declaration = generate_flight_declaration_via_operational_intent(flight_declaration_via_operational_intent_path) - telemetry_states = generate_telemetry(trajectory_path) + # Update flight declaration times to match synchronized start time + flight_declaration.start_datetime = start_time.isoformat() + flight_declaration.end_datetime = end_time.isoformat() + + telemetry_states = generate_telemetry(trajectory_path, reference_time=start_time.isoformat()) self.telemetry_states = telemetry_states @@ -896,8 +961,17 @@ async def setup_flight_declaration( if not trajectory_path: raise ValueError("trajectory_path not provided and not found in config") + # Synchronize start times + now = arrow.now() + start_time = now.shift(seconds=2) + end_time = now.shift(minutes=60) + flight_declaration = generate_flight_declaration(flight_declaration_path) - telemetry_states = generate_telemetry(trajectory_path) + # Update flight declaration times to match synchronized start time + flight_declaration.start_datetime = start_time.isoformat() + flight_declaration.end_datetime = end_time.isoformat() + + telemetry_states = generate_telemetry(trajectory_path, reference_time=start_time.isoformat()) self.telemetry_states = telemetry_states @@ -916,11 +990,14 @@ async def create_flight_declaration(self): """Context manager to setup and teardown a flight operation based on scenario config.""" assert self.flight_declaration_path is not None, "Flight declaration file path must be provided" assert self.trajectory_path is not None, "Trajectory file path must be provided" - await self.setup_flight_declaration(self.flight_declaration_path, self.trajectory_path) + result = await self.setup_flight_declaration(self.flight_declaration_path, self.trajectory_path) + if result.status == Status.FAIL: + raise FlightBlenderError(f"Setup Flight Declaration failed: {result.error_message}") try: yield finally: logger.info("All test steps complete..") + await self.teardown_flight_declaration() @asynccontextmanager async def create_flight_declaration_via_operational_intent(self): @@ -935,3 +1012,4 @@ async def create_flight_declaration_via_operational_intent(self): yield finally: logger.info("All test steps complete..") + await self.teardown_flight_declaration() diff --git a/src/openutm_verification/core/execution/conditions.py b/src/openutm_verification/core/execution/conditions.py index 7cc328c..79ffba3 100644 --- a/src/openutm_verification/core/execution/conditions.py +++ b/src/openutm_verification/core/execution/conditions.py @@ -70,7 +70,7 @@ def _replace_functions(self, condition: str) -> str: """Replace GitHub Actions-style functions.""" # success() - previous step succeeded if "success()" in condition: - result = self.last_step_status == Status.PASS if self.last_step_status else True + result = self.last_step_status in (Status.PASS, Status.RUNNING) if self.last_step_status else True condition = condition.replace("success()", str(result)) # failure() - previous step failed diff --git a/src/openutm_verification/core/execution/execution.py b/src/openutm_verification/core/execution/execution.py index e636fea..370f877 100644 --- a/src/openutm_verification/core/execution/execution.py +++ b/src/openutm_verification/core/execution/execution.py @@ -2,7 +2,9 @@ Core execution logic for running verification scenarios. """ +import importlib import json +import pkgutil from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING @@ -10,6 +12,7 @@ from loguru import logger from pydantic import ValidationError +import openutm_verification.scenarios from openutm_verification.core.clients.air_traffic.base_client import ( AirTrafficError, ) @@ -18,15 +21,29 @@ ) from openutm_verification.core.execution.config_models import AppConfig from openutm_verification.core.execution.dependencies import scenarios -from openutm_verification.core.execution.dependency_resolution import CONTEXT +from openutm_verification.core.execution.dependency_resolution import CONTEXT, call_with_dependencies from openutm_verification.core.execution.scenario_loader import load_yaml_scenario_definition from openutm_verification.core.reporting.reporting import _sanitize_config, create_report_data, generate_reports from openutm_verification.core.reporting.reporting_models import ( ScenarioResult, Status, ) +from openutm_verification.scenarios.registry import SCENARIO_REGISTRY from openutm_verification.utils.paths import get_docs_directory + +def _import_python_scenarios(): + """Import all python scenarios to populate the registry.""" + path = list(openutm_verification.scenarios.__path__) + prefix = openutm_verification.scenarios.__name__ + "." + + for _, name, _ in pkgutil.iter_modules(path, prefix): + try: + importlib.import_module(name) + except Exception as e: + logger.warning(f"Failed to import scenario module {name}: {e}") + + if TYPE_CHECKING: from openutm_verification.server.runner import SessionManager @@ -51,14 +68,37 @@ async def run_verification_scenarios(config: AppConfig, config_path: Path, sessi session_manager = RunnerSessionManager(config_path=str(config_path)) + # Import Python scenarios to populate registry + _import_python_scenarios() + scenario_results = [] for scenario_id in scenarios(): try: # Initialize session with the current context await session_manager.initialize_session() - scenario_def = load_yaml_scenario_definition(scenario_id) - await session_manager.run_scenario(scenario_def) + if scenario_id in SCENARIO_REGISTRY: + logger.info(f"Running Python scenario: {scenario_id}") + wrapper = SCENARIO_REGISTRY[scenario_id]["func"] + # Unwrap to get the original function for dependency injection + func_to_call = getattr(wrapper, "__wrapped__", wrapper) + + # Execute within the session context + # session_manager.initialize_session() already sets up session_context but doesn't enter it + # We need to manually enter the context or use run_scenario logic + # session_context is a ScenarioContext. + # ScenarioContext.__enter__ sets the thread-local state. + + if not session_manager.session_context: + raise RuntimeError("Session context not initialized") + + with session_manager.session_context: + await call_with_dependencies(func_to_call, resolver=session_manager.session_resolver) + + else: + scenario_def = load_yaml_scenario_definition(scenario_id) + await session_manager.run_scenario(scenario_def) + state = session_manager.session_context.state if session_manager.session_context else None steps = state.steps if state else [] failed = any(s.status == Status.FAIL for s in steps) diff --git a/src/openutm_verification/core/execution/scenario_runner.py b/src/openutm_verification/core/execution/scenario_runner.py index a039008..53c0999 100644 --- a/src/openutm_verification/core/execution/scenario_runner.py +++ b/src/openutm_verification/core/execution/scenario_runner.py @@ -1,3 +1,4 @@ +import asyncio import contextvars import inspect import time @@ -109,6 +110,19 @@ def add_result(cls, result: StepResult[Any]) -> None: state.steps.append(result) state.added_results.put_nowait(result) + def update_result(self, result: StepResult[Any]) -> None: + """ + Instance method to update result in the bound state. + This is useful for background tasks where context vars might not be available. + Note: This method does not check if state is active, since background tasks + may complete after the scenario context has exited. + """ + if self._state: + if result.id and self._state.step_results.get(result.id): + self._state.steps.remove(self._state.step_results[result.id]) + self._state.steps.append(result) + self._state.added_results.put_nowait(result) + @classmethod def set_flight_declaration_data(cls, data: FlightDeclaration) -> None: state = _scenario_state.get() @@ -264,7 +278,7 @@ def log_filter(record): handler_id = logger.add(lambda msg: captured_logs.append(msg), filter=log_filter, format="{time:HH:mm:ss} | {level} | {message}") - step_result: StepResult[Any] + step_result: StepResult[Any] | None = None try: with logger.contextualize(step_execution_id=step_execution_id): logger.info("-" * 50) @@ -273,6 +287,9 @@ def log_filter(record): try: result = await func(*args, **kwargs) step_result = handle_result(result, start_time) + except asyncio.CancelledError: + logger.info(f"Step '{step_name}' was cancelled") + raise except Exception as e: step_result = handle_exception(e, start_time) finally: diff --git a/src/openutm_verification/core/reporting/reporting_models.py b/src/openutm_verification/core/reporting/reporting_models.py index 6e6833c..bb829b7 100644 --- a/src/openutm_verification/core/reporting/reporting_models.py +++ b/src/openutm_verification/core/reporting/reporting_models.py @@ -25,6 +25,7 @@ class Status(StrEnum): PASS = "success" FAIL = "failure" RUNNING = "running" + WAITING = "waiting" SKIP = "skipped" diff --git a/src/openutm_verification/scenarios/common.py b/src/openutm_verification/scenarios/common.py index 9a06199..63c15d1 100644 --- a/src/openutm_verification/scenarios/common.py +++ b/src/openutm_verification/scenarios/common.py @@ -2,6 +2,7 @@ import uuid from pathlib import Path +from implicitdict import StringBasedDateTime from loguru import logger from uas_standards.astm.f3411.v22a.api import RIDAircraftState @@ -36,14 +37,18 @@ def generate_flight_declaration_via_operational_intent(config_path: str) -> Flig raise -def generate_telemetry(config_path: str, duration: int = DEFAULT_TELEMETRY_DURATION) -> list[RIDAircraftState]: +def generate_telemetry(config_path: str, duration: int = DEFAULT_TELEMETRY_DURATION, reference_time: str | None = None) -> list[RIDAircraftState]: """Generate telemetry states from the GeoJSON config file at the given path.""" try: logger.debug(f"Generating telemetry states from {config_path} for duration {duration} seconds") with open(config_path, "r", encoding="utf-8") as f: geojson_data = json.load(f) - simulator_config = GeoJSONFlightsSimulatorConfiguration(geojson=geojson_data) + config_args = {"geojson": geojson_data} + if reference_time: + config_args["reference_time"] = StringBasedDateTime(reference_time) + + simulator_config = GeoJSONFlightsSimulatorConfiguration(**config_args) simulator = GeoJSONFlightsSimulator(simulator_config) simulator.generate_flight_grid_and_path_points(altitude_of_ground_level_wgs_84=120) diff --git a/src/openutm_verification/scenarios/test_traffic_and_telemetry.py b/src/openutm_verification/scenarios/test_traffic_and_telemetry.py new file mode 100644 index 0000000..f2acbea --- /dev/null +++ b/src/openutm_verification/scenarios/test_traffic_and_telemetry.py @@ -0,0 +1,53 @@ +import asyncio + +from loguru import logger + +from openutm_verification.core.clients.air_traffic.blue_sky_client import BlueSkyClient +from openutm_verification.core.clients.flight_blender.flight_blender_client import ( + FlightBlenderClient, +) +from openutm_verification.models import OperationState +from openutm_verification.scenarios.registry import register_scenario + + +@register_scenario("traffic_and_telemetry_sim") +async def traffic_and_telemetry_sim( + fb_client: FlightBlenderClient, + blue_sky_client: BlueSkyClient, +) -> None: + """Runs a scenario with simulated air traffic and drone telemetry concurrently.""" + logger.info("Starting Traffic and Telemetry simulation scenario") + + # Explicit cleanup before starting + await fb_client.cleanup_flight_declarations() + + # Setup Flight Declaration + async with fb_client.create_flight_declaration(): + # Activate Operation + await fb_client.update_operation_state(OperationState.ACTIVATED) + + # Generate BlueSky Simulation Air Traffic Data + # Run traffic for 35 seconds to allow for 5s start delay + 30s telemetry overlap + result = await blue_sky_client.generate_bluesky_sim_air_traffic_data(duration=35) + observations = result.result + logger.info(f"Generated {len(observations)} observations from BlueSky simulation") + + # Start Submit Simulated Air Traffic (background) + traffic_task = asyncio.create_task(fb_client.submit_simulated_air_traffic(observations=observations)) + + logger.info("Traffic submission started, waiting 5 seconds before starting telemetry...") + await asyncio.sleep(5) + + # Start Submit Telemetry (background) + telemetry_task = asyncio.create_task(fb_client.submit_telemetry(duration=30)) + + logger.info("Waiting for telemetry submission to complete...") + await telemetry_task + + logger.info("Waiting for traffic submission to complete...") + await traffic_task + + # End Operation + await fb_client.update_operation_state(OperationState.ENDED) + + await fb_client.teardown_flight_declaration() diff --git a/src/openutm_verification/server/main.py b/src/openutm_verification/server/main.py index 4a0b761..dc78de7 100644 --- a/src/openutm_verification/server/main.py +++ b/src/openutm_verification/server/main.py @@ -205,6 +205,13 @@ async def run_scenario_async(scenario: ScenarioDefinition, runner: SessionManage return {"run_id": run_id} +@app.post("/stop-scenario") +async def stop_scenario(runner: SessionManager = Depends(get_session_manager)): + """Stop the currently running scenario.""" + stopped = await runner.stop_scenario() + return {"stopped": stopped} + + @app.get("/run-scenario-events") async def run_scenario_events(runner: SessionManager = Depends(get_session_manager)): async def event_stream(): @@ -214,7 +221,7 @@ async def event_stream(): result = runner.session_context.state.added_results.get_nowait() yield f"data: {result.model_dump_json()}\n\n" - if status_payload.get("status") != "running": + if status_payload.get("status") != "running" and not runner.has_pending_tasks(): done_payload = { "status": status_payload.get("status"), "error": status_payload.get("error"), diff --git a/src/openutm_verification/server/runner.py b/src/openutm_verification/server/runner.py index 5620082..8c7b148 100644 --- a/src/openutm_verification/server/runner.py +++ b/src/openutm_verification/server/runner.py @@ -67,6 +67,9 @@ async def start_scenario_task(self, scenario: ScenarioDefinition): def _on_done(t: asyncio.Task) -> None: try: t.result() + except asyncio.CancelledError: + # Task was cancelled (e.g., by stop_scenario), error is already set there + pass except Exception as exc: # noqa: BLE001 self.current_run_error = str(exc) @@ -82,6 +85,42 @@ def get_run_status(self) -> Dict[str, Any]: "error": self.current_run_error, } + async def stop_scenario(self) -> bool: + """Stop the currently running scenario and all background tasks.""" + logger.info("Stopping scenario...") + stopped = False + tasks_to_cancel = [] + + # Cancel the main scenario task + if self.current_run_task and not self.current_run_task.done(): + self.current_run_task.cancel() + tasks_to_cancel.append(self.current_run_task) + stopped = True + + # Cancel all background tasks + for task in self.session_tasks.values(): + if not task.done(): + task.cancel() + tasks_to_cancel.append(task) + stopped = True + + # Wait for all tasks to actually be cancelled (with timeout) + if tasks_to_cancel: + try: + await asyncio.wait_for(asyncio.gather(*tasks_to_cancel, return_exceptions=True), timeout=5.0) + except asyncio.TimeoutError: + logger.warning("Timeout waiting for tasks to cancel") + + if stopped: + self.current_run_error = "Scenario stopped by user" + logger.info("Scenario stopped by user") + + return stopped + + def has_pending_tasks(self) -> bool: + """Check if any background tasks are still running.""" + return any(not task.done() for task in self.session_tasks.values()) + async def initialize_session(self): logger.info("Initializing new session") if self.session_stack: @@ -319,15 +358,18 @@ def _on_done(t: asyncio.Task) -> None: res = t.result() if isinstance(res, StepResult): res.id = step_id - self.session_context.add_result(res) + self.session_context.update_result(res) return result_data = self._serialize_result(res) status_str = self._determine_status(res) status = Status.PASS if status_str == "success" else Status.FAIL - self.session_context.add_result(StepResult(id=step_id, name=step_name, status=status, result=result_data, duration=0.0)) + self.session_context.update_result(StepResult(id=step_id, name=step_name, status=status, result=result_data, duration=0.0)) + except asyncio.CancelledError: + # Task was cancelled, possibly due to server shutdown or stop request + pass except Exception as exc: # noqa: BLE001 - self.session_context.add_result(StepResult(id=step_id, name=step_name, status=Status.FAIL, error_message=str(exc), duration=0.0)) + self.session_context.update_result(StepResult(id=step_id, name=step_name, status=Status.FAIL, error_message=str(exc), duration=0.0)) task.add_done_callback(_on_done) @@ -340,7 +382,7 @@ async def _record_step_running(self, step: StepDefinition, task_id: Any = None) duration=0.0, result={"task_id": task_id} if task_id is not None else None, ) - self.session_context.add_result(result) + self.session_context.update_result(result) return result async def _execute_step(self, step: StepDefinition, loop_context: Dict[str, Any] | None = None) -> StepResult: @@ -354,7 +396,20 @@ async def _execute_step(self, step: StepDefinition, loop_context: Dict[str, Any] method = getattr(client, entry.method_name) # Prepare parameters (resolve refs, inject context) - kwargs = self._prepare_params(step, loop_context) + try: + kwargs = self._prepare_params(step, loop_context) + except Exception as e: + step_id = step.id or step.step + logger.error(f"Failed to prepare parameters for step '{step_id}': {e}") + result = StepResult( + id=step_id, + name=step.step, + status=Status.FAIL, + error_message=f"Parameter resolution failed: {e}", + duration=0.0, + ) + self.session_context.update_result(result) + return result if step.background: step_id = step.id or step.step @@ -379,11 +434,11 @@ async def _execute_step(self, step: StepDefinition, loop_context: Dict[str, Any] # If the result is already a StepResult (from scenario_step decorator), use it directly but ensure ID is correct if isinstance(result, StepResult): result.id = step_id - self.session_context.add_result(result) + self.session_context.update_result(result) return result step_result = StepResult(id=step_id, name=step.step, status=Status.PASS, result=result, duration=0.0) - self.session_context.add_result(step_result) + self.session_context.update_result(step_result) return step_result async def execute_single_step(self, step: StepDefinition, loop_context: Dict[str, Any] | None = None) -> StepResult: @@ -467,9 +522,11 @@ async def _wait_for_dependencies(self, step: StepDefinition) -> None: return for dep_id in step.needs: - # If dependency already completed and recorded, continue + # If dependency already completed and recorded, continue ONLY if not RUNNING if self.session_context and self.session_context.state and dep_id in self.session_context.state.step_results: - continue + # Check status + if self.session_context.state.step_results[dep_id].status != Status.RUNNING: + continue if dep_id not in self.session_tasks: raise ValueError(f"Dependency '{dep_id}' not found or not running") @@ -523,7 +580,7 @@ async def run_scenario(self, scenario: ScenarioDefinition) -> List[StepResult]: ) if self.session_context: with self.session_context: - self.session_context.add_result(skipped_result) + self.session_context.update_result(skipped_result) continue # Wait for declared dependencies (useful for background steps) diff --git a/tests/test_client_steps.py b/tests/test_client_steps.py index 32d6d4f..85a2602 100644 --- a/tests/test_client_steps.py +++ b/tests/test_client_steps.py @@ -1,5 +1,5 @@ import json -from unittest.mock import AsyncMock, MagicMock, mock_open, patch +from unittest.mock import ANY, AsyncMock, MagicMock, mock_open, patch import pytest @@ -488,7 +488,9 @@ async def test_setup_flight_declaration(fb_client): patch("openutm_verification.scenarios.common.generate_telemetry") as mock_gen_tel, patch("openutm_verification.core.clients.flight_blender.flight_blender_client.ScenarioContext") as mock_context, ): - mock_gen_fd.return_value = {"fd": "data"} + # Create a mock for flight declaration that allows attribute assignment + mock_fd = MagicMock() + mock_gen_fd.return_value = mock_fd mock_gen_tel.return_value = [{"tel": "data"}] # Mock upload_flight_declaration to return success @@ -499,10 +501,10 @@ async def test_setup_flight_declaration(fb_client): await fb_client.setup_flight_declaration("fd_path", "traj_path") mock_gen_fd.assert_called_with("fd_path") - mock_gen_tel.assert_called_with("traj_path") - mock_context.set_flight_declaration_data.assert_called_with({"fd": "data"}) + mock_gen_tel.assert_called_with("traj_path", reference_time=ANY) + mock_context.set_flight_declaration_data.assert_called_with(mock_fd) mock_context.set_telemetry_data.assert_called_with([{"tel": "data"}]) - fb_client.upload_flight_declaration.assert_called_with({"fd": "data"}) + fb_client.upload_flight_declaration.assert_called_with(mock_fd) # AirTrafficClient Tests diff --git a/web-editor/src/components/ScenarioEditor.tsx b/web-editor/src/components/ScenarioEditor.tsx index 2b447cf..abacb26 100644 --- a/web-editor/src/components/ScenarioEditor.tsx +++ b/web-editor/src/components/ScenarioEditor.tsx @@ -187,7 +187,7 @@ const ScenarioEditorContent = () => { edgesRef.current = edges; }, [nodes, edges]); - const { isRunning, runScenario } = useScenarioRunner(); + const { isRunning, runScenario, stopScenario } = useScenarioRunner(); const { handleSaveToServer, handleSaveAs } = useScenarioFile( nodes, edges, @@ -697,7 +697,7 @@ const ScenarioEditorContent = () => { } }, [clearGraph, isDirty]); - const updateNodesWithResults = useCallback((currentNodes: Node[], results: { id: string; status: 'success' | 'failure' | 'error' | 'skipped'; result?: unknown; logs?: string[] }[]) => { + const updateNodesWithResults = useCallback((currentNodes: Node[], results: { id: string; status: 'success' | 'failure' | 'error' | 'skipped' | 'running' | 'waiting'; result?: unknown; logs?: string[] }[]) => { return currentNodes.map(node => { const stepId = node.data.stepId || node.id; const stepName = node.data.label; @@ -779,7 +779,7 @@ const ScenarioEditorContent = () => { const currentEdges = reactFlowInstance ? reactFlowInstance.getEdges() : edgesRef.current; // Pass a callback to update nodes incrementally - const onStepComplete = (stepResult: { id: string; status: 'success' | 'failure' | 'error' | 'skipped'; result?: unknown }) => { + const onStepComplete = (stepResult: { id: string; status: 'success' | 'failure' | 'error' | 'skipped' | 'running' | 'waiting'; result?: unknown }) => { setNodes((nds) => updateNodesWithResults(nds, [stepResult])); }; @@ -790,7 +790,7 @@ const ScenarioEditorContent = () => { ...node, data: { ...node.data, - status: 'running' + status: 'waiting' } }; } @@ -1027,6 +1027,7 @@ const ScenarioEditorContent = () => { onSave={handleSaveToServer} onSaveAs={handleSaveAs} onRun={handleRun} + onStop={stopScenario} isRunning={isRunning} /> diff --git a/web-editor/src/components/ScenarioEditor/CustomNode.tsx b/web-editor/src/components/ScenarioEditor/CustomNode.tsx index f216ab6..1e42968 100644 --- a/web-editor/src/components/ScenarioEditor/CustomNode.tsx +++ b/web-editor/src/components/ScenarioEditor/CustomNode.tsx @@ -1,6 +1,6 @@ import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'; -import { Box, CheckCircle, XCircle, AlertTriangle, Loader2, MinusCircle, RotateCw, GitBranch, Timer } from 'lucide-react'; +import { Box, CheckCircle, XCircle, AlertTriangle, Loader2, MinusCircle, RotateCw, GitBranch, Timer, Hourglass } from 'lucide-react'; import styles from '../../styles/Node.module.css'; import type { NodeData } from '../../types/scenario'; @@ -14,6 +14,8 @@ export const CustomNode = ({ data, selected }: NodeProps>) => { statusClass = styles.statusError; } else if (data.status === 'running') { statusClass = styles.statusRunning; + } else if (data.status === 'waiting') { + statusClass = styles.statusWaiting; } else if (data.status === 'skipped') { statusClass = styles.statusSkipped; } @@ -118,6 +120,9 @@ export const CustomNode = ({ data, selected }: NodeProps>) => { {data.status === 'running' && ( )} + {data.status === 'waiting' && ( + + )} {data.status === 'skipped' && (
void; onSaveAs: () => void; onRun: () => void; + onStop: () => void; isRunning: boolean; } @@ -21,6 +22,7 @@ export const Header = ({ onSave, onSaveAs, onRun, + onStop, isRunning, }: HeaderProps) => { return ( @@ -53,6 +55,12 @@ export const Header = ({ {isRunning ? : } Run Scenario + {isRunning && ( + + )}
); diff --git a/web-editor/src/components/ScenarioEditor/__tests__/Header.test.tsx b/web-editor/src/components/ScenarioEditor/__tests__/Header.test.tsx index 01af593..a7548be 100644 --- a/web-editor/src/components/ScenarioEditor/__tests__/Header.test.tsx +++ b/web-editor/src/components/ScenarioEditor/__tests__/Header.test.tsx @@ -11,6 +11,7 @@ describe('Header', () => { onSave: vi.fn(), onSaveAs: vi.fn(), onRun: vi.fn(), + onStop: vi.fn(), isRunning: false, }; diff --git a/web-editor/src/hooks/useScenarioRunner.ts b/web-editor/src/hooks/useScenarioRunner.ts index b4a90e0..772a483 100644 --- a/web-editor/src/hooks/useScenarioRunner.ts +++ b/web-editor/src/hooks/useScenarioRunner.ts @@ -1,16 +1,39 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import type { Node, Edge } from '@xyflow/react'; import type { GroupDefinition, NodeData, Operation, ScenarioConfig } from '../types/scenario'; import { convertGraphToYaml } from '../utils/scenarioConversion'; export const useScenarioRunner = () => { const [isRunning, setIsRunning] = useState(false); + const eventSourceRef = useRef(null); + + const stopScenario = useCallback(async () => { + try { + // Close the EventSource connection first + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + + const response = await fetch('/stop-scenario', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + if (!response.ok) { + console.error('Failed to stop scenario:', response.statusText); + } + } catch (error) { + console.error('Error stopping scenario:', error); + } finally { + setIsRunning(false); + } + }, []); const runScenario = useCallback(async ( nodes: Node[], edges: Edge[], scenarioName: string, - onStepComplete?: (result: { id: string; status: 'success' | 'failure' | 'error' | 'skipped'; result?: unknown }) => void, + onStepComplete?: (result: { id: string; status: 'success' | 'failure' | 'error' | 'skipped' | 'running' | 'waiting'; result?: unknown }) => void, onStepStart?: (nodeId: string) => void, config?: ScenarioConfig, operations: Operation[] = [], @@ -119,12 +142,13 @@ export const useScenarioRunner = () => { }; results.push(stepResult); if (onStepComplete) { - onStepComplete(stepResult as { id: string; status: 'success' | 'failure' | 'error' | 'skipped'; result?: unknown; logs?: string[] }); + onStepComplete(stepResult as { id: string; status: 'success' | 'failure' | 'error' | 'skipped' | 'running' | 'waiting'; result?: unknown; logs?: string[] }); } }; await new Promise((resolve, reject) => { const source = new EventSource(`/run-scenario-events`); + eventSourceRef.current = source; source.onmessage = (event) => { try { @@ -132,6 +156,7 @@ export const useScenarioRunner = () => { handleStepResult(data); } catch (err) { source.close(); + eventSourceRef.current = null; reject(err); } }; @@ -140,6 +165,7 @@ export const useScenarioRunner = () => { try { const payload = JSON.parse((event as MessageEvent).data) as { status?: string; error?: string }; source.close(); + eventSourceRef.current = null; if (payload.status === 'error') { reject(new Error(payload.error || 'Scenario run failed')); return; @@ -147,12 +173,14 @@ export const useScenarioRunner = () => { resolve(); } catch (err) { source.close(); + eventSourceRef.current = null; reject(err); } }); source.onerror = () => { source.close(); + eventSourceRef.current = null; reject(new Error('Scenario event stream failed')); }; }); @@ -185,5 +213,5 @@ export const useScenarioRunner = () => { } }, []); - return { isRunning, runScenario }; + return { isRunning, runScenario, stopScenario }; }; diff --git a/web-editor/src/styles/Node.module.css b/web-editor/src/styles/Node.module.css index 8c00270..e1eba62 100644 --- a/web-editor/src/styles/Node.module.css +++ b/web-editor/src/styles/Node.module.css @@ -80,6 +80,12 @@ background-color: rgba(59, 130, 246, 0.05); } +.statusWaiting { + --node-color: var(--warning); + border-color: var(--node-color); + background-color: rgba(245, 158, 11, 0.05); +} + .statusSkipped { --node-color: var(--text-tertiary); border-color: var(--border-secondary); diff --git a/web-editor/src/types/scenario.ts b/web-editor/src/types/scenario.ts index d1b46a3..e28665f 100644 --- a/web-editor/src/types/scenario.ts +++ b/web-editor/src/types/scenario.ts @@ -20,7 +20,7 @@ export interface NodeData extends Record { operationId?: string; description?: string; parameters?: OperationParam[]; - status?: 'success' | 'failure' | 'error' | 'running' | 'skipped'; + status?: 'success' | 'failure' | 'error' | 'running' | 'waiting' | 'skipped'; result?: unknown; runInBackground?: boolean; ifCondition?: string; diff --git a/web-editor/src/utils/layoutConfig.ts b/web-editor/src/utils/layoutConfig.ts index 521e1cf..c58e250 100644 --- a/web-editor/src/utils/layoutConfig.ts +++ b/web-editor/src/utils/layoutConfig.ts @@ -3,7 +3,7 @@ import { MarkerType, Position } from '@xyflow/react'; export const LAYOUT_CONFIG = { nodeWidth: 180, nodeHeight: 80, - rankSep: 80, + rankSep: 40, nodeSep: 250, direction: 'TB', }; diff --git a/web-editor/vite.config.ts b/web-editor/vite.config.ts index 1e2a4fb..211d8b1 100644 --- a/web-editor/vite.config.ts +++ b/web-editor/vite.config.ts @@ -12,6 +12,9 @@ export default defineConfig({ '/operations': 'http://localhost:8989', '/session': 'http://localhost:8989', '/run-scenario': 'http://localhost:8989', + '/run-scenario-async': 'http://localhost:8989', + '/run-scenario-events': 'http://localhost:8989', + '/stop-scenario': 'http://localhost:8989', '/reports': 'http://localhost:8989', } }, From 31a3591d159ee4d9fd314bf937de24f4eebf594f Mon Sep 17 00:00:00 2001 From: Roman Pszonka Date: Thu, 22 Jan 2026 22:46:29 +0000 Subject: [PATCH 2/6] update UI on scenario stop plus small fixes --- .../simulator/geo_json_telemetry.py | 2 +- web-editor/src/components/ScenarioEditor.tsx | 19 +++++++- .../components/ScenarioEditor/CustomNode.tsx | 44 ++----------------- 3 files changed, 23 insertions(+), 42 deletions(-) diff --git a/src/openutm_verification/simulator/geo_json_telemetry.py b/src/openutm_verification/simulator/geo_json_telemetry.py index 23ec36e..1b67a98 100644 --- a/src/openutm_verification/simulator/geo_json_telemetry.py +++ b/src/openutm_verification/simulator/geo_json_telemetry.py @@ -200,7 +200,7 @@ def generate_flight_speed_bearing(self, adjacent_points: list[Point], delta_time speed_mts_per_sec = round(adjacent_point_distance_mts / delta_time_secs, 2) # Normalize azimuth to [0, 360) fwd_azimuth = (fwd_azimuth + 360.0) % 360.0 - logger.debug(f"Computed speed: {speed_mts_per_sec} m/s, bearing: {fwd_azimuth}°") + # logger.debug(f"Computed speed: {speed_mts_per_sec} m/s, bearing: {fwd_azimuth}°") return speed_mts_per_sec, fwd_azimuth def utm_converter(self, shapely_shape: BaseGeometry, inverse: bool = False) -> BaseGeometry: diff --git a/web-editor/src/components/ScenarioEditor.tsx b/web-editor/src/components/ScenarioEditor.tsx index abacb26..5567ee4 100644 --- a/web-editor/src/components/ScenarioEditor.tsx +++ b/web-editor/src/components/ScenarioEditor.tsx @@ -821,6 +821,23 @@ const ScenarioEditorContent = () => { reactFlowInstance ]); + const handleStop = useCallback(async () => { + await stopScenario(); + // Clear running/waiting status from all nodes + setNodes((nds) => nds.map(node => { + if (node.data.status === 'running' || node.data.status === 'waiting') { + return { + ...node, + data: { + ...node.data, + status: undefined + } + }; + } + return node; + })); + }, [stopScenario, setNodes]); + const updateNodeParameter = useCallback((nodeId: string, paramName: string, value: unknown) => { setIsDirty(true); setNodes((nds) => { @@ -1027,7 +1044,7 @@ const ScenarioEditorContent = () => { onSave={handleSaveToServer} onSaveAs={handleSaveAs} onRun={handleRun} - onStop={stopScenario} + onStop={handleStop} isRunning={isRunning} /> diff --git a/web-editor/src/components/ScenarioEditor/CustomNode.tsx b/web-editor/src/components/ScenarioEditor/CustomNode.tsx index 1e42968..53c85e7 100644 --- a/web-editor/src/components/ScenarioEditor/CustomNode.tsx +++ b/web-editor/src/components/ScenarioEditor/CustomNode.tsx @@ -82,40 +82,13 @@ export const CustomNode = ({ data, selected }: NodeProps>) => { {data.status && (
{data.status === 'success' && ( -
{ - e.stopPropagation(); - data.onShowResult?.(data.result); - }} - > - -
+ )} {data.status === 'failure' && ( -
{ - e.stopPropagation(); - data.onShowResult?.(data.result); - }} - > - -
+ )} {data.status === 'error' && ( -
{ - e.stopPropagation(); - data.onShowResult?.(data.result); - }} - > - -
+ )} {data.status === 'running' && ( @@ -124,16 +97,7 @@ export const CustomNode = ({ data, selected }: NodeProps>) => { )} {data.status === 'skipped' && ( -
{ - e.stopPropagation(); - data.onShowResult?.(data.result); - }} - > - -
+ )}
)} From f5f59f3c6ddc71c402b76bbc649074e366b9e11e Mon Sep 17 00:00:00 2001 From: Roman Pszonka Date: Thu, 22 Jan 2026 23:15:03 +0000 Subject: [PATCH 3/6] address some PR comments --- .../flight_blender/flight_blender_client.py | 2 - .../core/execution/scenario_runner.py | 5 +- tests/test_scenario_integration.py | 297 ++++++++++++++++++ tests/test_server_main.py | 34 ++ 4 files changed, 333 insertions(+), 5 deletions(-) diff --git a/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py b/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py index 9e88be5..0531e0e 100644 --- a/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py +++ b/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py @@ -997,7 +997,6 @@ async def create_flight_declaration(self): yield finally: logger.info("All test steps complete..") - await self.teardown_flight_declaration() @asynccontextmanager async def create_flight_declaration_via_operational_intent(self): @@ -1012,4 +1011,3 @@ async def create_flight_declaration_via_operational_intent(self): yield finally: logger.info("All test steps complete..") - await self.teardown_flight_declaration() diff --git a/src/openutm_verification/core/execution/scenario_runner.py b/src/openutm_verification/core/execution/scenario_runner.py index 53c0999..05b34b4 100644 --- a/src/openutm_verification/core/execution/scenario_runner.py +++ b/src/openutm_verification/core/execution/scenario_runner.py @@ -1,9 +1,8 @@ -import asyncio import contextvars import inspect import time import uuid -from asyncio import Queue +from asyncio import CancelledError, Queue from dataclasses import dataclass, field from functools import wraps from pathlib import Path @@ -287,7 +286,7 @@ def log_filter(record): try: result = await func(*args, **kwargs) step_result = handle_result(result, start_time) - except asyncio.CancelledError: + except CancelledError: logger.info(f"Step '{step_name}' was cancelled") raise except Exception as e: diff --git a/tests/test_scenario_integration.py b/tests/test_scenario_integration.py index 28347d0..5843e4f 100644 --- a/tests/test_scenario_integration.py +++ b/tests/test_scenario_integration.py @@ -374,3 +374,300 @@ async def mock_execute_step(step, loop_context=None): # 2 + 3 = 5 executions assert mock_execute.call_count == 5 assert len([r for r in results if r.status != Status.RUNNING]) == 5 + + +class TestStatusTransitions: + """Tests for status transitions including WAITING and RUNNING states.""" + + @pytest.mark.asyncio + async def test_waiting_status_for_queued_steps(self, session_manager): + """Test that steps can be marked as WAITING before execution.""" + scenario = ScenarioDefinition( + name="Test Waiting Status", + description="Test WAITING status for queued steps", + steps=[ + StepDefinition(id="step1", step="Setup Flight Declaration", arguments={}), + StepDefinition(id="step2", step="Submit Telemetry", arguments={"duration": 10}), + ], + ) + + recorded_statuses = [] + + async def mock_execute_step(step, loop_context=None): + # Record the step execution + recorded_statuses.append(("executed", step.id)) + step_result = StepResult(id=step.id, name=step.step, status=Status.PASS, duration=0.0, result=None) + if session_manager.session_context: + with session_manager.session_context: + session_manager.session_context.add_result(step_result) + return step_result + + with patch.object(session_manager, "execute_single_step", new_callable=AsyncMock) as mock_execute: + mock_execute.side_effect = mock_execute_step + + await session_manager.run_scenario(scenario) + + # Verify steps were executed in order + assert recorded_statuses == [("executed", "step1"), ("executed", "step2")] + + @pytest.mark.asyncio + async def test_running_status_for_background_steps(self, session_manager): + """Test that background steps are marked as RUNNING.""" + scenario = ScenarioDefinition( + name="Test Running Status", + description="Test RUNNING status for background steps", + steps=[ + StepDefinition(id="bg_step", step="Submit Telemetry", arguments={"duration": 30}, background=True), + StepDefinition(id="foreground", step="Update Operation State", arguments={"state": "ACTIVATED"}), + ], + ) + + with patch.object(session_manager, "_execute_step", new_callable=AsyncMock) as mock_execute: + + async def mock_execute_step(step, loop_context=None): + step_result = StepResult(id=step.id, name=step.step, status=Status.PASS, duration=0.0, result=None) + if session_manager.session_context: + session_manager.session_context.add_result(step_result) + return step_result + + mock_execute.side_effect = mock_execute_step + + await session_manager.run_scenario(scenario) + + # Both steps should have been called + assert mock_execute.call_count == 2 + + @pytest.mark.asyncio + async def test_status_enum_values(self): + """Test that all Status enum values are correctly defined.""" + assert Status.PASS == "success" + assert Status.FAIL == "failure" + assert Status.RUNNING == "running" + assert Status.WAITING == "waiting" + assert Status.SKIP == "skipped" + + @pytest.mark.asyncio + async def test_step_result_accepts_all_statuses(self): + """Test that StepResult can be created with all Status values.""" + for status in Status: + result = StepResult(id="test", name="Test Step", status=status, duration=0.0) + assert result.status == status + + @pytest.mark.asyncio + async def test_waiting_to_running_to_pass_transition(self, session_manager): + """Test the typical status transition: WAITING -> RUNNING -> PASS.""" + scenario = ScenarioDefinition( + name="Test Status Transition", + description="Test status transitions", + steps=[ + StepDefinition(id="step1", step="Setup Flight Declaration", arguments={}), + ], + ) + + status_transitions = [] + + async def mock_execute_with_transitions(step, loop_context=None): + # Simulate the status transition + waiting_result = StepResult(id=step.id, name=step.step, status=Status.WAITING, duration=0.0, result=None) + status_transitions.append(waiting_result.status) + + running_result = StepResult(id=step.id, name=step.step, status=Status.RUNNING, duration=0.0, result=None) + status_transitions.append(running_result.status) + + final_result = StepResult(id=step.id, name=step.step, status=Status.PASS, duration=0.0, result=None) + status_transitions.append(final_result.status) + + if session_manager.session_context: + with session_manager.session_context: + session_manager.session_context.add_result(final_result) + return final_result + + with patch.object(session_manager, "execute_single_step", new_callable=AsyncMock) as mock_execute: + mock_execute.side_effect = mock_execute_with_transitions + + await session_manager.run_scenario(scenario) + + # Verify the transition order + assert status_transitions == [Status.WAITING, Status.RUNNING, Status.PASS] + + @pytest.mark.asyncio + async def test_waiting_to_running_to_fail_transition(self, session_manager): + """Test status transition ending in failure: WAITING -> RUNNING -> FAIL.""" + scenario = ScenarioDefinition( + name="Test Failure Transition", + description="Test status transitions ending in failure", + steps=[ + StepDefinition(id="step1", step="Setup Flight Declaration", arguments={}), + ], + ) + + status_transitions = [] + + async def mock_execute_with_failure(step, loop_context=None): + status_transitions.append(Status.WAITING) + status_transitions.append(Status.RUNNING) + status_transitions.append(Status.FAIL) + + final_result = StepResult(id=step.id, name=step.step, status=Status.FAIL, duration=0.0, result=None, error_message="Simulated failure") + if session_manager.session_context: + with session_manager.session_context: + session_manager.session_context.add_result(final_result) + return final_result + + with patch.object(session_manager, "execute_single_step", new_callable=AsyncMock) as mock_execute: + mock_execute.side_effect = mock_execute_with_failure + + await session_manager.run_scenario(scenario) + + assert status_transitions == [Status.WAITING, Status.RUNNING, Status.FAIL] + + @pytest.mark.asyncio + async def test_skip_status_with_condition(self, session_manager): + """Test that SKIP status is properly assigned when conditions are not met.""" + scenario = ScenarioDefinition( + name="Test Skip Status", + description="Test SKIP status assignment", + steps=[ + StepDefinition(id="step1", step="Setup Flight Declaration", arguments={}), + StepDefinition(id="step2", step="Submit Telemetry", arguments={"duration": 10}, if_condition="failure()"), + ], + ) + + async def mock_execute_step(step, loop_context=None): + step_result = StepResult(id=step.id, name=step.step, status=Status.PASS, duration=0.0, result=None) + if session_manager.session_context: + with session_manager.session_context: + session_manager.session_context.add_result(step_result) + return step_result + + with patch.object(session_manager, "execute_single_step", new_callable=AsyncMock) as mock_execute: + mock_execute.side_effect = mock_execute_step + + results = await session_manager.run_scenario(scenario) + + # Find step2 result - should be SKIP since failure() is false + step2_results = [r for r in results if r.id == "step2"] + assert len(step2_results) == 1 + assert step2_results[0].status == Status.SKIP + + @pytest.mark.asyncio + async def test_mixed_statuses_in_scenario(self, session_manager): + """Test scenario with mixed final statuses.""" + scenario = ScenarioDefinition( + name="Test Mixed Statuses", + description="Test scenario with various statuses", + steps=[ + StepDefinition(id="pass_step", step="Setup Flight Declaration", arguments={}), + StepDefinition(id="fail_step", step="Submit Telemetry", arguments={"duration": 10}), + StepDefinition(id="conditional_step", step="Update Operation State", arguments={"state": "ENDED"}, if_condition="failure()"), + StepDefinition(id="always_step", step="Teardown Flight Declaration", arguments={}, if_condition="always()"), + ], + ) + + call_count = 0 + + async def mock_execute_with_mixed_results(step, loop_context=None): + nonlocal call_count + call_count += 1 + + if step.id == "fail_step": + status = Status.FAIL + else: + status = Status.PASS + + step_result = StepResult(id=step.id, name=step.step, status=status, duration=0.0, result=None) + if session_manager.session_context: + with session_manager.session_context: + session_manager.session_context.add_result(step_result) + return step_result + + with patch.object(session_manager, "execute_single_step", new_callable=AsyncMock) as mock_execute: + mock_execute.side_effect = mock_execute_with_mixed_results + + results = await session_manager.run_scenario(scenario) + + # Verify we have various statuses + status_map = {r.id: r.status for r in results if r.id} + assert status_map.get("pass_step") == Status.PASS + assert status_map.get("fail_step") == Status.FAIL + # conditional_step runs because failure() is true after fail_step + assert status_map.get("conditional_step") == Status.PASS + assert status_map.get("always_step") == Status.PASS # Always runs + + +class TestConditionEvaluatorWithStatuses: + """Tests for condition evaluation with various status values.""" + + def test_success_condition_with_waiting_status(self): + """Test that success() returns False when last status is WAITING.""" + from openutm_verification.core.execution.conditions import ConditionEvaluator + + steps = {"step1": StepResult(id="step1", name="Test", status=Status.WAITING, duration=0.0)} + evaluator = ConditionEvaluator(steps) + + # WAITING is not considered success + assert evaluator.evaluate("success()") is False + + def test_success_condition_with_running_status(self): + """Test that success() returns True when last status is RUNNING.""" + from openutm_verification.core.execution.conditions import ConditionEvaluator + + steps = {"step1": StepResult(id="step1", name="Test", status=Status.RUNNING, duration=0.0)} + evaluator = ConditionEvaluator(steps) + + # RUNNING is considered success (allows downstream steps to start) + assert evaluator.evaluate("success()") is True + + def test_failure_condition_with_waiting_status(self): + """Test that failure() returns False when last status is WAITING.""" + from openutm_verification.core.execution.conditions import ConditionEvaluator + + steps = {"step1": StepResult(id="step1", name="Test", status=Status.WAITING, duration=0.0)} + evaluator = ConditionEvaluator(steps) + + # WAITING is not considered failure + assert evaluator.evaluate("failure()") is False + + def test_failure_condition_with_skip_status(self): + """Test that failure() returns False when last status is SKIP.""" + from openutm_verification.core.execution.conditions import ConditionEvaluator + + steps = {"step1": StepResult(id="step1", name="Test", status=Status.SKIP, duration=0.0)} + evaluator = ConditionEvaluator(steps) + + # SKIP is not considered failure + assert evaluator.evaluate("failure()") is False + + def test_step_status_comparison_with_waiting(self): + """Test direct status comparison with WAITING.""" + from openutm_verification.core.execution.conditions import ConditionEvaluator + + steps = {"step1": StepResult(id="step1", name="Test", status=Status.WAITING, duration=0.0)} + evaluator = ConditionEvaluator(steps) + + assert evaluator.evaluate("steps.step1.status == 'waiting'") is True + assert evaluator.evaluate("steps.step1.status == 'success'") is False + + def test_step_status_comparison_with_running(self): + """Test direct status comparison with RUNNING.""" + from openutm_verification.core.execution.conditions import ConditionEvaluator + + steps = {"step1": StepResult(id="step1", name="Test", status=Status.RUNNING, duration=0.0)} + evaluator = ConditionEvaluator(steps) + + assert evaluator.evaluate("steps.step1.status == 'running'") is True + assert evaluator.evaluate("steps.step1.status == 'success'") is False + + def test_last_step_excludes_skipped(self): + """Test that last_step_status excludes skipped steps.""" + from openutm_verification.core.execution.conditions import ConditionEvaluator + + # Skipped steps should not affect the "last step" status + steps = { + "step1": StepResult(id="step1", name="Test1", status=Status.PASS, duration=0.0), + "step2": StepResult(id="step2", name="Test2", status=Status.SKIP, duration=0.0), + } + evaluator = ConditionEvaluator(steps) + + # success() should check step1 (PASS), not step2 (SKIP) + assert evaluator.evaluate("success()") is True diff --git a/tests/test_server_main.py b/tests/test_server_main.py index 6fb51f9..fd7efc6 100644 --- a/tests/test_server_main.py +++ b/tests/test_server_main.py @@ -57,3 +57,37 @@ def test_reset_session(): mock_runner.initialize_session.assert_called_once() finally: app.dependency_overrides = {} + + +def test_stop_scenario_when_running(): + """Test that /stop-scenario correctly calls runner.stop_scenario() and returns expected format.""" + mock_runner = MagicMock() + mock_runner.stop_scenario = AsyncMock(return_value=True) + + app.dependency_overrides[get_session_manager] = lambda: mock_runner + + try: + response = client.post("/stop-scenario") + assert response.status_code == 200 + assert response.json() == {"stopped": True} + + mock_runner.stop_scenario.assert_called_once() + finally: + app.dependency_overrides = {} + + +def test_stop_scenario_when_not_running(): + """Test that /stop-scenario returns stopped=False when no scenario is running.""" + mock_runner = MagicMock() + mock_runner.stop_scenario = AsyncMock(return_value=False) + + app.dependency_overrides[get_session_manager] = lambda: mock_runner + + try: + response = client.post("/stop-scenario") + assert response.status_code == 200 + assert response.json() == {"stopped": False} + + mock_runner.stop_scenario.assert_called_once() + finally: + app.dependency_overrides = {} From d9184ae52fa18108e7736d70e15c94e1c4e37329 Mon Sep 17 00:00:00 2001 From: Roman Pszonka Date: Mon, 26 Jan 2026 23:11:19 +0000 Subject: [PATCH 4/6] added AMQP client and some bug fixes --- config/default.yaml | 9 + pyproject.toml | 24 +- pytest.ini | 5 - .../core/clients/amqp/__init__.py | 15 + .../core/clients/amqp/amqp_client.py | 518 ++++++++++++++++++ .../flight_blender/flight_blender_client.py | 83 ++- .../core/clients/opensky/opensky_client.py | 5 +- .../core/execution/config_models.py | 11 + .../core/execution/dependencies.py | 12 + .../core/execution/scenario_runner.py | 10 +- .../core/reporting/reporting.py | 22 +- .../core/templates/report_template.html | 55 +- .../importers/dss_rid_uploader.py | 4 +- src/openutm_verification/server/main.py | 23 +- src/openutm_verification/server/runner.py | 154 +++++- .../simulator/geo_json_telemetry.py | 4 +- .../simulator/models/flight_data_types.py | 27 +- tests/test_altitude_units.py | 336 ++++++++++++ tests/test_client_steps.py | 5 +- tests/test_step_status_updates.py | 507 +++++++++++++++++ uv.lock | 325 +++++++---- 21 files changed, 1962 insertions(+), 192 deletions(-) delete mode 100644 pytest.ini create mode 100644 src/openutm_verification/core/clients/amqp/__init__.py create mode 100644 src/openutm_verification/core/clients/amqp/amqp_client.py create mode 100644 tests/test_altitude_units.py create mode 100644 tests/test_step_status_updates.py diff --git a/config/default.yaml b/config/default.yaml index 89877c3..0ddbd01 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -38,6 +38,15 @@ blue_sky_air_traffic_simulator_settings: single_or_multiple_sensors: "multiple" # this setting specifiies if the traffic data is submitted from a single sensor or multiple sensors sensor_ids: ["562e6297036a4adebb4848afcd1ede90"] # List of sensor IDs to use when 'multiple' is selected +# AMQP/RabbitMQ configuration for event monitoring +# Set AMQP_URL environment variable or configure here +amqp: + url: "" # e.g., amqp://guest:guest@localhost:5672/ (can also use AMQP_URL env var) + exchange_name: "operational_events" + exchange_type: "direct" + routing_key: "#" # Flight declaration ID or '#' for all messages + queue_name: "" # Empty means auto-generate exclusive queue + data_files: trajectory: "config/bern/trajectory_f1.json" # Path to flight declarations JSON file simulation: "config/bern/blue_sky_sim_example.scn" # Path to air traffic simulation scenario file, used for BlueSky enabled simulations only. diff --git a/pyproject.toml b/pyproject.toml index e0653ab..94d8432 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,36 +66,28 @@ dev = [ "types-pyyaml", "types-redis", "types-requests", + "pytest-cov>=7.0.0", ] [build-system] -requires = [ - "hatchling", -] +requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = [ - "src/openutm_verification", -] +packages = ["src/openutm_verification"] # (artifacts configuration removed; docs are included via force-include) [tool.hatch.build.targets.wheel.force-include] "docs/scenarios" = "openutm_verification/docs/scenarios" [tool.pytest.ini_options] -pythonpath = [ - ".", - "src/openutm_verification", -] -testpaths = [ - "tests", -] +pythonpath = [".", "src/openutm_verification"] +testpaths = ["tests"] +markers = ["asyncio: mark test as asyncio"] + [tool.coverage.run] -source = [ - "src/openutm_verification", -] +source = ["src/openutm_verification"] [tool.pylint."messages control"] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 3780578..0000000 --- a/pytest.ini +++ /dev/null @@ -1,5 +0,0 @@ -[pytest] -testpaths = tests -markers = - asyncio: mark test as asyncio -addopts = -q --tb=no diff --git a/src/openutm_verification/core/clients/amqp/__init__.py b/src/openutm_verification/core/clients/amqp/__init__.py new file mode 100644 index 0000000..28aa692 --- /dev/null +++ b/src/openutm_verification/core/clients/amqp/__init__.py @@ -0,0 +1,15 @@ +"""AMQP client module for RabbitMQ queue monitoring.""" + +from openutm_verification.core.clients.amqp.amqp_client import ( + AMQPClient, + AMQPMessage, + AMQPSettings, + create_amqp_settings, +) + +__all__ = [ + "AMQPClient", + "AMQPMessage", + "AMQPSettings", + "create_amqp_settings", +] diff --git a/src/openutm_verification/core/clients/amqp/amqp_client.py b/src/openutm_verification/core/clients/amqp/amqp_client.py new file mode 100644 index 0000000..5e39ff6 --- /dev/null +++ b/src/openutm_verification/core/clients/amqp/amqp_client.py @@ -0,0 +1,518 @@ +"""AMQP client for monitoring RabbitMQ queues in verification scenarios.""" + +from __future__ import annotations + +import asyncio +import json +import os +import threading +import time +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any +from urllib.parse import urlparse + +import pika +import requests +from loguru import logger +from pika.adapters.blocking_connection import BlockingChannel, BlockingConnection +from pika.exceptions import AMQPConnectionError, ChannelClosedByBroker + +from openutm_verification.core.execution.config_models import get_settings +from openutm_verification.core.execution.scenario_runner import scenario_step + + +@dataclass +class AMQPSettings: + """Settings for AMQP connection.""" + + url: str = "" + exchange_name: str = "operational_events" + exchange_type: str = "direct" + routing_key: str = "#" # Flight declaration ID or '#' for all + queue_name: str = "" + heartbeat: int = 600 + blocked_connection_timeout: int = 300 + auto_discover: bool = False + + +@dataclass +class AMQPMessage: + """Represents a received AMQP message.""" + + body: bytes + routing_key: str + exchange: str + delivery_tag: int + content_type: str | None = None + correlation_id: str | None = None + timestamp: str = "" + + def body_str(self) -> str: + """Decode message body as UTF-8 string.""" + return self.body.decode("utf-8", errors="replace") + + def body_json(self) -> dict[str, Any] | list[Any] | None: + """Attempt to parse body as JSON, return None if invalid.""" + try: + data = json.loads(self.body_str()) + # Unpack nested 'body' JSON if present + if isinstance(data, dict) and "body" in data: + try: + inner_body = json.loads(data["body"]) + data["body"] = inner_body + except (json.JSONDecodeError, TypeError): + pass + return data + except (json.JSONDecodeError, TypeError): + return None + + def to_dict(self) -> dict[str, Any]: + """Convert message to dictionary for reporting.""" + return { + "routing_key": self.routing_key, + "exchange": self.exchange, + "content_type": self.content_type, + "correlation_id": self.correlation_id, + "timestamp": self.timestamp, + "body": self.body_json() or self.body_str(), + } + + +@dataclass +class AMQPConsumerState: + """State for an active AMQP consumer.""" + + connection: BlockingConnection | None = None + channel: BlockingChannel | None = None + queue_name: str = "" + messages: list[AMQPMessage] = field(default_factory=list) + consuming: bool = False + error: str | None = None + consumer_thread: threading.Thread | None = None + stop_event: threading.Event = field(default_factory=threading.Event) + + +def create_amqp_settings() -> AMQPSettings: + """Create AMQP settings from configuration or environment. + + Priority: config file > environment variables > defaults. + """ + settings = AMQPSettings() + + # Try to get from config first + try: + config = get_settings() + if hasattr(config, "amqp") and config.amqp: + amqp_config = config.amqp + settings.url = amqp_config.url or settings.url + settings.exchange_name = amqp_config.exchange_name or settings.exchange_name + settings.exchange_type = amqp_config.exchange_type or settings.exchange_type + settings.routing_key = amqp_config.routing_key or settings.routing_key + settings.queue_name = amqp_config.queue_name or settings.queue_name + except Exception: + pass # Config not available, use env vars + + # Environment overrides + settings.url = os.environ.get("AMQP_URL", settings.url) + settings.routing_key = os.environ.get("AMQP_ROUTING_KEY", settings.routing_key) + settings.queue_name = os.environ.get("AMQP_QUEUE", settings.queue_name) + settings.auto_discover = os.environ.get("AMQP_AUTO_DISCOVER", "").lower() in ( + "1", + "true", + "yes", + ) + + return settings + + +class AMQPClient: + """AMQP client for monitoring RabbitMQ queues in verification scenarios. + + This client can be used to: + - Start background queue monitoring + - Collect messages during scenario execution + - Filter and verify received messages + + Example usage in YAML scenarios: + - step: Start AMQP Queue Monitor + arguments: + queue_name: "my-queue" + background: true + - step: Submit Telemetry + arguments: + duration: 30 + - step: Stop AMQP Queue Monitor + - step: Get AMQP Messages + + Example usage in Python scenarios: + amqp_task = asyncio.create_task( + amqp_client.start_queue_monitor(queue_name="my-queue", duration=60) + ) + # ... perform other operations ... + await amqp_task + messages = await amqp_client.get_received_messages() + """ + + def __init__(self, settings: AMQPSettings): + self.settings = settings + self._state = AMQPConsumerState() + self._lock = threading.Lock() + + def _get_connection_parameters(self) -> pika.URLParameters: + """Create pika connection parameters from settings.""" + if not self.settings.url: + raise ValueError("AMQP URL not configured. Set AMQP_URL environment variable or configure 'amqp.url' in config yaml.") + + parameters = pika.URLParameters(self.settings.url) + parameters.heartbeat = self.settings.heartbeat + parameters.blocked_connection_timeout = self.settings.blocked_connection_timeout + return parameters + + def _discover_queues_with_messages(self) -> list[str]: + """Use RabbitMQ Management API to find queues with messages.""" + if not self.settings.url: + return [] + + parsed = urlparse(self.settings.url) + username = parsed.username or "guest" + password = parsed.password or "guest" + host = parsed.hostname or "localhost" + vhost = parsed.path.lstrip("/") or "%2f" + + mgmt_ports = [15672, 443, 15671] + + for port in mgmt_ports: + try: + scheme = "https" if port == 443 else "http" + api_url = f"{scheme}://{host}:{port}/api/queues/{vhost}" + + response = requests.get(api_url, auth=(username, password), timeout=5) + + if response.status_code == 200: + queues = response.json() + queues_with_msgs = [q for q in queues if q.get("messages", 0) > 0 and not q.get("name", "").startswith("amq.gen-")] + queues_with_msgs.sort(key=lambda q: q.get("messages", 0), reverse=True) + return [q["name"] for q in queues_with_msgs] + except requests.RequestException: + continue + + return [] + + def _on_message( + self, + ch: BlockingChannel, + method: Any, + properties: pika.BasicProperties, + body: bytes, + ) -> None: + """Internal callback for processing received messages.""" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + + msg = AMQPMessage( + body=body, + routing_key=method.routing_key, + exchange=method.exchange or "(default)", + delivery_tag=method.delivery_tag, + content_type=properties.content_type, + correlation_id=properties.correlation_id, + timestamp=timestamp, + ) + + with self._lock: + self._state.messages.append(msg) + + logger.debug(f"AMQP message received - routing_key={method.routing_key}, size={len(body)} bytes") + + # Acknowledge the message + ch.basic_ack(delivery_tag=method.delivery_tag) + + def _consumer_loop( + self, + queue_name: str | None, + routing_key: str | None, + duration: int | None, + ) -> None: + """Background consumer loop running in separate thread.""" + connection = None + try: + parameters = self._get_connection_parameters() + connection = pika.BlockingConnection(parameters) + channel = connection.channel() + + with self._lock: + self._state.connection = connection + self._state.channel = channel + + target_queue = queue_name or self.settings.queue_name + + # Auto-discover if enabled + if self.settings.auto_discover and not target_queue: + discovered = self._discover_queues_with_messages() + if discovered: + target_queue = discovered[0] + logger.info(f"Auto-discovered queue: {target_queue}") + + if target_queue: + self._state.queue_name = target_queue + logger.info(f"Consuming from queue: {target_queue}") + else: + # Create exclusive queue and bind to exchange + result = channel.queue_declare(queue="", exclusive=True) + self._state.queue_name = result.method.queue + + rk = routing_key or self.settings.routing_key + channel.queue_bind( + exchange=self.settings.exchange_name, + queue=self._state.queue_name, + routing_key=rk, + ) + logger.info(f"Bound to exchange '{self.settings.exchange_name}' with routing key '{rk}'") + + channel.basic_qos(prefetch_count=1) + channel.basic_consume( + queue=self._state.queue_name, + on_message_callback=self._on_message, + ) + + self._state.consuming = True + logger.info("AMQP consumer started") + + start_time = time.time() + while not self._state.stop_event.is_set(): + # Check duration limit + if duration and (time.time() - start_time) >= duration: + logger.info(f"AMQP monitor duration ({duration}s) reached") + break + + # Process pending events with short timeout + connection.process_data_events(time_limit=1) + + except AMQPConnectionError as e: + logger.error(f"AMQP connection error: {e}") + self._state.error = str(e) + except ChannelClosedByBroker as e: + logger.error(f"Channel closed by broker: {e}") + self._state.error = str(e) + except Exception as e: + logger.error(f"AMQP consumer error: {e}") + self._state.error = str(e) + finally: + self._state.consuming = False + if connection and connection.is_open: + try: + connection.close() + except Exception: + pass + logger.info("AMQP consumer stopped") + + @scenario_step("Start AMQP Queue Monitor") + async def start_queue_monitor( + self, + queue_name: str | None = None, + routing_key: str | None = None, + duration: int | None = None, + ) -> dict[str, Any]: + """Start monitoring an AMQP queue for messages. + + This step starts a background consumer that collects messages from the + specified queue (or creates an exclusive queue bound to the exchange). + + Args: + queue_name: Specific queue to monitor. If not provided, creates an + exclusive queue bound to the exchange with the routing key. + routing_key: Flight declaration ID to filter messages, or '#' for all. + duration: Optional duration in seconds to monitor. If not set, + runs until stop_queue_monitor is called. + + Returns: + Dictionary with monitoring status and queue name. + """ + # Clear previous state + self._state.messages.clear() + self._state.error = None + self._state.stop_event.clear() + + # Start consumer in background thread + self._state.consumer_thread = threading.Thread( + target=self._consumer_loop, + args=(queue_name, routing_key, duration), + daemon=True, + ) + self._state.consumer_thread.start() + + # Wait briefly for connection to establish + await asyncio.sleep(0.5) + + if self._state.error: + raise RuntimeError(f"Failed to start AMQP monitor: {self._state.error}") + + return { + "status": "started", + "queue_name": self._state.queue_name or "pending", + "exchange": self.settings.exchange_name, + "routing_key": routing_key or self.settings.routing_key, + "duration": duration, + } + + @scenario_step("Stop AMQP Queue Monitor") + async def stop_queue_monitor(self) -> dict[str, Any]: + """Stop the AMQP queue monitor. + + Returns: + Dictionary with final status and message count. + """ + if not self._state.consumer_thread: + return {"status": "not_running", "message_count": 0} + + # Signal thread to stop + self._state.stop_event.set() + + # Wait for thread to finish (with timeout) + self._state.consumer_thread.join(timeout=5.0) + + message_count = len(self._state.messages) + logger.info(f"AMQP monitor stopped, collected {message_count} messages") + + return { + "status": "stopped", + "queue_name": self._state.queue_name, + "message_count": message_count, + "error": self._state.error, + } + + @scenario_step("Get AMQP Messages") + async def get_received_messages( + self, + routing_key_filter: str | None = None, + limit: int | None = None, + ) -> list[dict[str, Any]]: + """Get messages received by the AMQP monitor. + + Args: + routing_key_filter: Optional flight declaration ID to filter messages. + limit: Maximum number of messages to return. + + Returns: + List of message dictionaries with body, routing_key, etc. + """ + with self._lock: + messages = self._state.messages.copy() + + # Apply routing key filter + if routing_key_filter: + # Simple pattern matching for AMQP-style wildcards + import fnmatch + + pattern = routing_key_filter.replace("#", "*").replace(".", "\\.") + messages = [m for m in messages if fnmatch.fnmatch(m.routing_key, pattern)] + + # Apply limit + if limit: + messages = messages[:limit] + + return [m.to_dict() for m in messages] + + @scenario_step("Wait for AMQP Messages") + async def wait_for_messages( + self, + count: int = 1, + timeout: int = 30, + routing_key_filter: str | None = None, + ) -> dict[str, Any]: + """Wait for a specific number of messages to be received. + + Args: + count: Number of messages to wait for. + timeout: Maximum time to wait in seconds. + routing_key_filter: Optional flight declaration ID to filter messages. + + Returns: + Dictionary with success status and messages. + """ + start_time = time.time() + + while (time.time() - start_time) < timeout: + messages = await self.get_received_messages(routing_key_filter=routing_key_filter) + if len(messages) >= count: + return { + "success": True, + "message_count": len(messages), + "messages": messages[:count], + "waited_seconds": time.time() - start_time, + } + await asyncio.sleep(0.5) + + # Timeout reached + messages = await self.get_received_messages(routing_key_filter=routing_key_filter) + return { + "success": False, + "message_count": len(messages), + "messages": messages, + "timeout": timeout, + "error": f"Timed out waiting for {count} messages, got {len(messages)}", + } + + @scenario_step("Clear AMQP Messages") + async def clear_messages(self) -> dict[str, Any]: + """Clear the collected messages buffer. + + Returns: + Dictionary with number of messages cleared. + """ + with self._lock: + count = len(self._state.messages) + self._state.messages.clear() + + logger.info(f"Cleared {count} AMQP messages") + return {"cleared_count": count} + + @scenario_step("Check AMQP Connection") + async def check_connection(self) -> dict[str, Any]: + """Check if AMQP connection can be established. + + Raises: + RuntimeError: If connection cannot be established. + + Returns: + Dictionary with connection status and server info. + """ + try: + parameters = self._get_connection_parameters() + connection = pika.BlockingConnection(parameters) + + # Get server version info if available + version = "unknown" + try: + # Access internal implementation for server properties + if hasattr(connection, "_impl") and hasattr(connection._impl, "server_properties"): + server_props = connection._impl.server_properties + version_bytes = server_props.get("version", b"unknown") + if isinstance(version_bytes, bytes): + version = version_bytes.decode("utf-8") + elif isinstance(version_bytes, str): + version = version_bytes + except (AttributeError, KeyError): + pass + + connection.close() + + return { + "connected": True, + "server_version": version, + "url_host": urlparse(self.settings.url).hostname, + } + except (AMQPConnectionError, ChannelClosedByBroker) as e: + raise RuntimeError(f"AMQP connection failed: {e}") from e + except ValueError as e: + # Handle configuration errors (e.g., missing URL) + raise RuntimeError(f"AMQP configuration error: {e}") from e + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + # Stop consumer if running + if self._state.consumer_thread and self._state.consumer_thread.is_alive(): + self._state.stop_event.set() + self._state.consumer_thread.join(timeout=2.0) diff --git a/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py b/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py index 0531e0e..fc5783c 100644 --- a/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py +++ b/src/openutm_verification/core/clients/flight_blender/flight_blender_client.py @@ -634,12 +634,27 @@ async def submit_simulated_air_traffic( self, observations: list[list[FlightObservationSchema]], single_or_multiple_sensors: str = "single", - ) -> bool: + ) -> dict[str, Any]: + """Submit simulated air traffic observations to the Flight Blender API. + + Plays back observations in real-time, submitting one observation per aircraft per second. + + Args: + observations: List of observation lists, one per aircraft. + single_or_multiple_sensors: Whether to use single or multiple sensor IDs. + + Returns: + Dictionary with submission statistics. + """ if not observations: logger.warning("No air traffic observations to submit.") - return True + return { + "success": True, + "aircraft_count": 0, + "observations_submitted": 0, + "duration_seconds": 0, + } - now = arrow.now() number_of_aircraft = len(observations) logger.debug(f"Submitting simulated air traffic for {number_of_aircraft} aircraft") @@ -657,13 +672,21 @@ async def submit_simulated_air_traffic( if not start_times: logger.warning("No valid start/end times found in observations.") - return True + return { + "success": True, + "aircraft_count": number_of_aircraft, + "observations_submitted": 0, + "duration_seconds": 0, + "warning": "No valid start/end times found in observations", + } simulation_start = min(start_times) simulation_end = max(end_times) now = arrow.now() start_time = now + observations_submitted = 0 + submission_errors = 0 current_simulation_time = simulation_start # Loop through the simulation time from start to end, advancing by 1 second each iteration @@ -691,12 +714,26 @@ async def submit_simulated_air_traffic( payload = {"observations": [obs.model_dump(mode="json") for obs in filtered_observation]} ScenarioContext.add_air_traffic_data(filtered_observation) - response = await self.post(endpoint, json=payload) - logger.debug(f"Air traffic submission response: {response.text}") - logger.info(f"Observations submitted for aircraft {filtered_observation[0].icao_address} at time {current_simulation_time}") + try: + response = await self.post(endpoint, json=payload) + logger.debug(f"Air traffic submission response: {response.text}") + logger.info(f"Observations submitted for aircraft {filtered_observation[0].icao_address} at time {current_simulation_time}") + observations_submitted += 1 + except Exception as e: + logger.error(f"Failed to submit observation: {e}") + submission_errors += 1 # Advance the simulation time by 1 second current_simulation_time = current_simulation_time.shift(seconds=1) - return True + + duration_seconds = (arrow.now() - start_time).total_seconds() + return { + "success": submission_errors == 0, + "aircraft_count": number_of_aircraft, + "observations_submitted": observations_submitted, + "submission_errors": submission_errors, + "duration_seconds": round(duration_seconds, 2), + "simulation_duration_seconds": (simulation_end - simulation_start).total_seconds(), + } @scenario_step("Submit Air Traffic") async def submit_air_traffic(self, observations: list[FlightObservationSchema]) -> dict[str, Any]: @@ -906,8 +943,12 @@ async def setup_flight_declaration_via_operational_intent( self, flight_declaration_via_operational_intent_path: str, trajectory_path: str, - ) -> None: - """Generates data and uploads flight declaration via Operational Intent.""" + ) -> dict[str, Any]: + """Generates data and uploads flight declaration via Operational Intent. + + Returns: + Dictionary with flight declaration info including 'id'. + """ from openutm_verification.scenarios.common import ( generate_flight_declaration_via_operational_intent, generate_telemetry, @@ -938,13 +979,24 @@ async def setup_flight_declaration_via_operational_intent( logger.error(f"Flight declaration upload failed: {upload_result}") raise FlightBlenderError("Failed to upload flight declaration during setup_flight_declaration_via_operational_intent") + # Return flight declaration info for use in subsequent steps + return { + "id": self.latest_flight_declaration_id, + "start_datetime": flight_declaration.start_datetime, + "end_datetime": flight_declaration.end_datetime, + } + @scenario_step("Setup Flight Declaration") async def setup_flight_declaration( self, flight_declaration_path: str | None = None, trajectory_path: str | None = None, - ) -> None: - """Generates data and uploads flight declaration.""" + ) -> dict[str, Any]: + """Generates data and uploads flight declaration. + + Returns: + Dictionary with flight declaration info including 'id'. + """ from openutm_verification.scenarios.common import ( generate_flight_declaration, @@ -985,6 +1037,13 @@ async def setup_flight_declaration( logger.error(f"Flight declaration upload failed: {upload_result}") raise FlightBlenderError("Failed to upload flight declaration during setup_flight_declaration") + # Return flight declaration info for use in subsequent steps + return { + "id": self.latest_flight_declaration_id, + "start_datetime": flight_declaration.start_datetime, + "end_datetime": flight_declaration.end_datetime, + } + @asynccontextmanager async def create_flight_declaration(self): """Context manager to setup and teardown a flight operation based on scenario config.""" diff --git a/src/openutm_verification/core/clients/opensky/opensky_client.py b/src/openutm_verification/core/clients/opensky/opensky_client.py index 907f280..0519d11 100644 --- a/src/openutm_verification/core/clients/opensky/opensky_client.py +++ b/src/openutm_verification/core/clients/opensky/opensky_client.py @@ -76,7 +76,8 @@ def process_flight_data(self, flight_df: pd.DataFrame) -> list[FlightObservation """Process flight DataFrame into observation format.""" observations = [] for _, row in flight_df.iterrows(): - altitude = 0.0 if row["baro_altitude"] == "No Data" else row["baro_altitude"] + # OpenSky baro_altitude is in meters, convert to millimeters + altitude_m = 0.0 if row["baro_altitude"] == "No Data" else float(row["baro_altitude"]) # Create observation using Pydantic model observation = FlightObservationSchema( @@ -86,7 +87,7 @@ def process_flight_data(self, flight_df: pd.DataFrame) -> list[FlightObservation source_type=1, # Aircraft lat_dd=float(row["lat"]), lon_dd=float(row["long"]), - altitude_mm=float(altitude), + altitude_mm=altitude_m * 1000, # Convert m -> mm metadata={"velocity": row["velocity"]}, ) observations.append(observation) diff --git a/src/openutm_verification/core/execution/config_models.py b/src/openutm_verification/core/execution/config_models.py index f318a6e..6a29c77 100644 --- a/src/openutm_verification/core/execution/config_models.py +++ b/src/openutm_verification/core/execution/config_models.py @@ -54,6 +54,16 @@ class BlueSkyAirTrafficSimulatorSettings(StrictBaseModel): sensor_ids: list[str] = Field(default_factory=list) +class AMQPConfig(StrictBaseModel): + """AMQP/RabbitMQ connection configuration.""" + + url: str = "" # AMQP URL, e.g., amqp://guest:guest@localhost:5672/ + exchange_name: str = "operational_events" + exchange_type: str = "direct" + routing_key: str = "#" # '#' matches all routing keys + queue_name: str = "" # Empty means auto-generate exclusive queue + + class OpenSkyConfig(StrictBaseModel): """OpenSky Network connection details.""" @@ -159,6 +169,7 @@ class AppConfig(StrictBaseModel): opensky: OpenSkyConfig air_traffic_simulator_settings: AirTrafficSimulatorSettings blue_sky_air_traffic_simulator_settings: BlueSkyAirTrafficSimulatorSettings + amqp: AMQPConfig | None = None # Optional AMQP/RabbitMQ configuration data_files: DataFiles suites: dict[str, SuiteConfig] = Field(default_factory=dict) reporting: ReportingConfig diff --git a/src/openutm_verification/core/execution/dependencies.py b/src/openutm_verification/core/execution/dependencies.py index 7539d3c..59ddd44 100644 --- a/src/openutm_verification/core/execution/dependencies.py +++ b/src/openutm_verification/core/execution/dependencies.py @@ -19,6 +19,10 @@ from openutm_verification.core.clients.air_traffic.blue_sky_client import ( BlueSkyClient, ) +from openutm_verification.core.clients.amqp import ( + AMQPClient, + create_amqp_settings, +) from openutm_verification.core.clients.common.common_client import CommonClient from openutm_verification.core.clients.flight_blender.flight_blender_client import ( FlightBlenderClient, @@ -223,3 +227,11 @@ async def bluesky_client() -> AsyncGenerator[BlueSkyClient, None]: settings = create_blue_sky_air_traffic_settings() async with BlueSkyClient(settings) as client: yield client + + +@dependency(AMQPClient) +async def amqp_client() -> AsyncGenerator[AMQPClient, None]: + """Provides an AMQPClient instance for dependency injection.""" + settings = create_amqp_settings() + async with AMQPClient(settings) as client: + yield client diff --git a/src/openutm_verification/core/execution/scenario_runner.py b/src/openutm_verification/core/execution/scenario_runner.py index 05b34b4..018bb81 100644 --- a/src/openutm_verification/core/execution/scenario_runner.py +++ b/src/openutm_verification/core/execution/scenario_runner.py @@ -104,8 +104,9 @@ def __exit__(self, exc_type, exc_val, exc_tb): def add_result(cls, result: StepResult[Any]) -> None: state = _scenario_state.get() if state and state.active: - if result.id and state.step_results.get(result.id): - state.steps.remove(state.step_results[result.id]) + # Remove all existing entries with same ID (handles duplicates from multiple sources) + if result.id: + state.steps = [s for s in state.steps if s.id != result.id] state.steps.append(result) state.added_results.put_nowait(result) @@ -117,8 +118,9 @@ def update_result(self, result: StepResult[Any]) -> None: may complete after the scenario context has exited. """ if self._state: - if result.id and self._state.step_results.get(result.id): - self._state.steps.remove(self._state.step_results[result.id]) + # Remove all existing entries with same ID (handles duplicates from multiple sources) + if result.id: + self._state.steps = [s for s in self._state.steps if s.id != result.id] self._state.steps.append(result) self._state.added_results.put_nowait(result) diff --git a/src/openutm_verification/core/reporting/reporting.py b/src/openutm_verification/core/reporting/reporting.py index 13099da..455567e 100644 --- a/src/openutm_verification/core/reporting/reporting.py +++ b/src/openutm_verification/core/reporting/reporting.py @@ -22,23 +22,43 @@ T = TypeVar("T") +def _mask_url_password(url: str) -> str: + """Mask password in URL if present (e.g., amqp://user:password@host).""" + import re + + # Match URLs with credentials: scheme://user:password@host + pattern = r"((?:amqp|amqps|http|https|rabbitmq)://[^:]+:)([^@]+)(@.+)" + match = re.match(pattern, url, re.IGNORECASE) + if match: + return f"{match.group(1)}***MASKED***{match.group(3)}" + return url + + def _sanitize_config(data: Any) -> Any: """ Recursively sanitize sensitive fields in the configuration data. """ sensitive_mask = "***MASKED***" - sensitive_keys = ["client_id", "client_secret", "audience", "scopes"] + sensitive_keys = ["client_id", "client_secret", "audience", "scopes", "password", "token"] + # Keys that may contain URLs with embedded passwords + url_keys = ["url", "amqp_url", "connection_string", "broker_url"] if isinstance(data, dict): sanitized = {} for key, value in data.items(): if key in sensitive_keys: sanitized[key] = sensitive_mask + elif key.lower() in url_keys and isinstance(value, str) and "@" in value: + # URL with potential embedded credentials + sanitized[key] = _mask_url_password(value) else: sanitized[key] = _sanitize_config(value) return sanitized elif isinstance(data, list): return [_sanitize_config(item) for item in data] + elif isinstance(data, str) and "://" in data and "@" in data: + # Standalone URL string with potential credentials + return _mask_url_password(data) else: return data diff --git a/src/openutm_verification/core/templates/report_template.html b/src/openutm_verification/core/templates/report_template.html index 475a2a8..bce14b4 100644 --- a/src/openutm_verification/core/templates/report_template.html +++ b/src/openutm_verification/core/templates/report_template.html @@ -24,6 +24,32 @@ .steps-table th, .steps-table td { border: 1px solid #eee; padding: 8px; text-align: left; } .steps-table th { background-color: #fafafa; } pre { background-color: #f1f1f1; padding: 10px; border-radius: 5px; white-space: pre-wrap; word-wrap: break-word; } + .result-wrapper { position: relative; } + .result-collapsed { max-height: 200px; overflow: hidden; } + .result-collapsed::after { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 40px; + background: linear-gradient(transparent, #f1f1f1); + pointer-events: none; + } + .toggle-btn { + position: absolute; + top: 5px; + right: 5px; + z-index: 10; + padding: 4px 12px; + font-size: 12px; + background-color: rgba(233, 236, 239, 0.95); + border: 1px solid #ced4da; + border-radius: 4px; + cursor: pointer; + color: #495057; + } + .toggle-btn:hover { background-color: rgba(222, 226, 230, 0.95); } @@ -63,7 +89,8 @@

Deployment Details

Scenario Results ({{ report_data.results|length }} executed)

{% for result in report_data.results %} -
+ {% set scenario_index = loop.index %} +
@@ -109,11 +136,22 @@

Steps:

{% for step in result.steps %} + {% set result_json = step.result | tojson(indent=2) %} + {% set line_count = result_json.split('\n') | length %} {{ step.name }} {{ step.status }} {{ "%.2f"|format(step.duration) }} -
{{ step.result | tojson(indent=2) }}
+ + {% if line_count > 10 %} +
+
{{ result_json }}
+ +
+ {% else %} +
{{ result_json }}
+ {% endif %} + {% endfor %} @@ -133,5 +171,18 @@

Full Configuration

+ + diff --git a/src/openutm_verification/importers/dss_rid_uploader.py b/src/openutm_verification/importers/dss_rid_uploader.py index 6448d04..ac6e03e 100644 --- a/src/openutm_verification/importers/dss_rid_uploader.py +++ b/src/openutm_verification/importers/dss_rid_uploader.py @@ -37,6 +37,8 @@ for flight_state in flight_states: time_stamp = arrow.now().int_timestamp + # RID position.alt is in meters, convert to millimeters for altitude_mm + altitude_m = flight_state["position"]["alt"] payload = { "observations": [ { @@ -46,7 +48,7 @@ "lat_dd": flight_state["position"]["lat"], "lon_dd": flight_state["position"]["lng"], "time_stamp": time_stamp, - "altitude_mm": flight_state["position"]["alt"], + "altitude_mm": altitude_m * 1000, # Convert m -> mm "metadata": metadata, } ] diff --git a/src/openutm_verification/server/main.py b/src/openutm_verification/server/main.py index dc78de7..1fd3dc1 100644 --- a/src/openutm_verification/server/main.py +++ b/src/openutm_verification/server/main.py @@ -164,10 +164,12 @@ async def generate_report_endpoint(request: GenerateReportRequest, runner: Sessi ) # Construct ReportData - run_timestamp = datetime.now(timezone.utc) + end_timestamp = datetime.now(timezone.utc) + # Use stored start time from scenario run, or fall back to end time + start_timestamp = runner.current_start_time or end_timestamp # Sanitize scenario name for filename: keep only alphanumerics and underscores safe_name = "".join(c if c.isalnum() else "_" for c in request.scenario_name) - run_id = f"{safe_name}_{run_timestamp.strftime('%Y%m%d_%H%M%S')}" + run_id = f"{safe_name}_{end_timestamp.strftime('%Y%m%d_%H%M%S')}" # Get config config = runner.config @@ -176,19 +178,28 @@ async def generate_report_endpoint(request: GenerateReportRequest, runner: Sessi config=config, config_path=str(runner.config_path), results=[scenario_result], - start_time=run_timestamp, - end_time=run_timestamp, + start_time=start_timestamp, + end_time=end_timestamp, run_id=run_id, docs_dir=None, ) try: - # Save report to a specifically named run subdirectory + # Use the output directory from the scenario run if available + # This ensures reports are saved in the same directory as the log file + if runner.current_timestamp_str: + config.reporting.timestamp_subdir = runner.current_timestamp_str + else: + # Fallback: create a new timestamp directory + config.reporting.timestamp_subdir = "" generate_reports( report_data, config.reporting, ) - return {"status": "success", "report_id": run_id} + + # Get the actual report directory for the response + report_id = runner.current_timestamp_str or run_id + return {"status": "success", "report_id": report_id} except Exception as e: print(f"Error generating report: {e}") return {"status": "error", "message": str(e)} diff --git a/src/openutm_verification/server/runner.py b/src/openutm_verification/server/runner.py index 8c7b148..d29c9e6 100644 --- a/src/openutm_verification/server/runner.py +++ b/src/openutm_verification/server/runner.py @@ -2,6 +2,7 @@ import os import re from contextlib import AsyncExitStack +from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable, Coroutine, Dict, List, Type, TypeVar, cast @@ -14,9 +15,11 @@ from openutm_verification.core.execution.definitions import ScenarioDefinition, StepDefinition from openutm_verification.core.execution.dependency_resolution import CONTEXT, DEPENDENCIES, DependencyResolver, call_with_dependencies from openutm_verification.core.execution.scenario_runner import STEP_REGISTRY, ScenarioContext, _scenario_state +from openutm_verification.core.reporting.reporting import get_run_timestamp_str from openutm_verification.core.reporting.reporting_models import Status, StepResult from openutm_verification.scenarios.common import generate_flight_declaration, generate_telemetry from openutm_verification.server.introspection import process_method +from openutm_verification.utils.logging import setup_logging T = TypeVar("T") @@ -50,6 +53,9 @@ def __init__(self, config_path: str = "config/default.yaml"): self.data_files: DataFiles | None = None self.current_run_task: asyncio.Task | None = None self.current_run_error: str | None = None + self.current_output_dir: Path | None = None + self.current_timestamp_str: str | None = None + self.current_start_time: datetime | None = None self._initialized = True async def start_scenario_task(self, scenario: ScenarioDefinition): @@ -210,16 +216,22 @@ def _resolve_ref(self, ref: str, loop_context: Dict[str, Any] | None = None) -> state = self.session_context.state step_result: Any | None = None - if step_name in state.step_results: - step_result = state.step_results[step_name] - elif loop_context and "group_context" in loop_context: + + # When inside a group context, check group_context FIRST for the most recent results + # This is important because state.step_results may have stale RUNNING entries + if loop_context and "group_context" in loop_context: group_context = loop_context["group_context"] if step_name in group_context: step_result = group_context[step_name] - else: - matching_ids = [key for key in state.step_results.keys() if key.endswith(f".{step_name}") or key == step_name] - if matching_ids: - step_result = state.step_results[matching_ids[-1]] + # Fall back to state.step_results if not found in group_context + if step_result is None: + if step_name in state.step_results: + step_result = state.step_results[step_name] + else: + # Try matching by suffix (for looped step IDs like "group[0].step_name") + matching_ids = [key for key in state.step_results.keys() if key.endswith(f".{step_name}") or key == step_name] + if matching_ids: + step_result = state.step_results[matching_ids[-1]] if step_result is None: logger.error(f"Step '{step_name}' not found in results. Available steps: {list(state.step_results.keys())}") @@ -419,16 +431,28 @@ async def _execute_step(self, step: StepDefinition, loop_context: Dict[str, Any] self._record_background_result(step_id, step.step, task) return await self._record_step_running(step, task_id=step_id) await self._record_step_running(step) + step_id = step.id or step.step # Execute with dependencies - result = await call_with_dependencies(method, resolver=self.session_resolver, **kwargs) + try: + result = await call_with_dependencies(method, resolver=self.session_resolver, **kwargs) + except Exception as e: + logger.error(f"Step '{step_id}' execution failed: {e}") + failed_result = StepResult( + id=step_id, + name=step.step, + status=Status.FAIL, + error_message=str(e), + duration=0.0, + ) + self.session_context.update_result(failed_result) + return failed_result # If result is a StepResult and we have a step ID, update the ID in the result object # This updates the object in state.steps as well since it's the same reference if step.id and hasattr(result, "id"): result.id = step.id # Add result to context - step_id = step.id or step.step logger.info(f"Adding result for step '{step_id}' (name: {step.step}) to context") # If the result is already a StepResult (from scenario_step decorator), use it directly but ensure ID is correct @@ -461,6 +485,17 @@ async def execute_single_step(self, step: StepDefinition, loop_context: Dict[str except Exception as e: step_id = step.id or step.step logger.error(f"Error executing step {step_id}: {e}") + # Update step status to FAIL before re-raising + if self.session_context: + with self.session_context: + failed_result = StepResult( + id=step_id, + name=step.step, + status=Status.FAIL, + error_message=str(e), + duration=0.0, + ) + self.session_context.update_result(failed_result) raise def _is_group_reference(self, step_name: str, scenario: ScenarioDefinition) -> bool: @@ -485,25 +520,61 @@ async def _execute_group( logger.info(f"Executing group '{group_name}' with {len(group.steps)} steps") - for step in group.steps: - await self._record_step_running(step) + # Pre-compute step IDs with loop index + # Note: We don't pre-record as RUNNING here because _execute_step will do it + step_id_map: Dict[int, str] = {} + for idx, grp_step in enumerate(group.steps): + original_id = grp_step.id or grp_step.step + if loop_context and "index" in loop_context: + loop_index = loop_context.get("index") + step_id_map[idx] = f"{group_name}[{loop_index}].{original_id}" + else: + step_id_map[idx] = original_id - for group_step in group.steps: + executed_step_indices: set[int] = set() + + for idx, group_step in enumerate(group.steps): # Ensure each step has an ID if not group_step.id: group_step.id = group_step.step original_id = group_step.id - exec_step = group_step + exec_step = group_step.model_copy(deep=True) + exec_step.id = step_id_map[idx] - if loop_context and "index" in loop_context: - loop_index = loop_context.get("index") - exec_step = group_step.model_copy(deep=True) - exec_step.id = f"{group_name}[{loop_index}].{original_id}" + # Evaluate condition if present for group step + if group_step.if_condition: + step_results_dict = {} + if self.session_context and self.session_context.state: + step_results_dict = self.session_context.state.step_results.copy() + # Add group context results for condition evaluation + for gid, gresult in group_context.items(): + step_results_dict[gid] = gresult + + evaluator = ConditionEvaluator(step_results_dict, enhanced_loop_context) + should_run = evaluator.evaluate(group_step.if_condition) + + if not should_run: + logger.info(f"Skipping group step '{exec_step.id}' due to condition: {group_step.if_condition}") + skipped_result = StepResult( + id=exec_step.id, + name=exec_step.step, + status=Status.SKIP, + duration=0.0, + error_message=f"Skipped due to condition: {group_step.if_condition}", + ) + results.append(skipped_result) + executed_step_indices.add(idx) + if self.session_context: + with self.session_context: + self.session_context.update_result(skipped_result) + group_context[original_id] = skipped_result + continue # Execute the step with the enhanced context result = await self.execute_single_step(exec_step, enhanced_loop_context) results.append(result) + executed_step_indices.add(idx) # Store result in group context for subsequent steps # Store the entire result dict so nested access works (group.step_id.result) @@ -512,10 +583,42 @@ async def _execute_group( # If step failed and it's not allowed to fail, break the group if result.status == Status.FAIL: logger.error(f"Group step '{group_step.id}' failed, stopping group execution") + # Mark remaining steps as SKIP + self._mark_remaining_group_steps_skipped(group, group_name, loop_context, executed_step_indices) break return results + def _mark_remaining_group_steps_skipped( + self, + group: Any, + group_name: str, + loop_context: Dict[str, Any] | None, + executed_step_indices: set[int], + ) -> None: + """Mark remaining group steps as SKIP after a failure.""" + if not self.session_context: + return + + for idx, remaining_step in enumerate(group.steps): + if idx in executed_step_indices: + continue # Already executed + + step_id = remaining_step.id or remaining_step.step + if loop_context and "index" in loop_context: + loop_index = loop_context.get("index") + step_id = f"{group_name}[{loop_index}].{step_id}" + + skipped_result = StepResult( + id=step_id, + name=remaining_step.step, + status=Status.SKIP, + duration=0.0, + error_message="Skipped due to previous step failure", + ) + with self.session_context: + self.session_context.update_result(skipped_result) + async def _wait_for_dependencies(self, step: StepDefinition) -> None: """Wait for any declared dependencies (by step ID) before executing a step.""" if not step.needs: @@ -543,6 +646,23 @@ async def run_scenario(self, scenario: ScenarioDefinition) -> List[StepResult]: if not self.session_resolver: await self.initialize_session() + # Set up logging to file for this run + run_timestamp = datetime.now(timezone.utc) + self.current_start_time = run_timestamp + self.current_timestamp_str = get_run_timestamp_str(run_timestamp) + base_output_dir = Path(self.config.reporting.output_dir) + self.current_output_dir = base_output_dir / self.current_timestamp_str + self.current_output_dir.mkdir(parents=True, exist_ok=True) + + log_file = setup_logging( + self.current_output_dir, + "report", + self.config.reporting.formats, + debug=False, + ) + if log_file: + logger.info(f"Logging to file: {log_file}") + # Validate and prepare steps seen_ids = set() for step in scenario.steps: diff --git a/src/openutm_verification/simulator/geo_json_telemetry.py b/src/openutm_verification/simulator/geo_json_telemetry.py index 1b67a98..22f62a8 100644 --- a/src/openutm_verification/simulator/geo_json_telemetry.py +++ b/src/openutm_verification/simulator/geo_json_telemetry.py @@ -114,11 +114,13 @@ def generate_air_traffic_data( timestamp = self.reference_time.shift(seconds=i) for point in coordinates: metadata = {"session_id": session_id} if session_id else {} + # Convert altitude from meters to millimeters for altitude_mm field + altitude_m = self.config.altitude_of_ground_level_wgs_84 airtraffic.append( FlightObservationSchema( lat_dd=point[1], lon_dd=point[0], - altitude_mm=self.config.altitude_of_ground_level_wgs_84, + altitude_mm=altitude_m * 1000, # Convert m -> mm traffic_source=1, source_type=2, icao_address=icao_address, diff --git a/src/openutm_verification/simulator/models/flight_data_types.py b/src/openutm_verification/simulator/models/flight_data_types.py index f4f9b72..6b9458e 100644 --- a/src/openutm_verification/simulator/models/flight_data_types.py +++ b/src/openutm_verification/simulator/models/flight_data_types.py @@ -6,14 +6,25 @@ class FlightObservationSchema(BaseModel): - lat_dd: float - lon_dd: float - altitude_mm: float - traffic_source: int - source_type: int - icao_address: str - timestamp: int - metadata: dict = Field(default_factory=dict) + """Schema for flight observation data submitted to Flight Blender. + + This schema represents air traffic observations compatible with the + Flight Blender /flight_stream/set_air_traffic endpoint. + """ + + lat_dd: float = Field(..., description="Latitude in decimal degrees") + lon_dd: float = Field(..., description="Longitude in decimal degrees") + altitude_mm: float = Field( + ..., + description="Altitude in millimeters above WGS84 ellipsoid. " + "Note: Despite Flight Blender API docs saying 'Meters', the field name " + "indicates millimeters. Values passed here should be in millimeters.", + ) + traffic_source: int = Field(..., description="Traffic source type (0-11, see API docs)") + source_type: int = Field(..., description="Source type (0=True, 1=Fused)") + icao_address: str = Field(..., max_length=24, description="ICAO aircraft address") + timestamp: int = Field(..., description="Unix timestamp of observation") + metadata: dict = Field(default_factory=dict, description="Additional metadata") class FullFlightRecord(ImplicitDict): diff --git a/tests/test_altitude_units.py b/tests/test_altitude_units.py new file mode 100644 index 0000000..7b3d42d --- /dev/null +++ b/tests/test_altitude_units.py @@ -0,0 +1,336 @@ +"""Unit tests for altitude unit conversions. + +These tests verify that altitude values are correctly converted between +meters and millimeters throughout the codebase. The Flight Blender API +uses `altitude_mm` field which expects millimeters, while most internal +calculations and ASTM standards use meters. +""" + +import arrow +import pandas as pd +import pytest + +from openutm_verification.simulator.models.flight_data_types import ( + AirTrafficGeneratorConfiguration, + FlightObservationSchema, + GeoJSONFlightsSimulatorConfiguration, +) + + +class TestFlightObservationSchema: + """Tests for FlightObservationSchema model.""" + + def test_altitude_mm_accepts_millimeter_values(self): + """Verify altitude_mm field accepts millimeter values.""" + # 500 meters = 500,000 millimeters + altitude_mm = 500_000 + + obs = FlightObservationSchema( + lat_dd=46.97, + lon_dd=7.47, + altitude_mm=altitude_mm, + traffic_source=1, + source_type=1, + icao_address="ABC123", + timestamp=1234567890, + ) + + assert obs.altitude_mm == 500_000 + + def test_altitude_mm_field_description_mentions_millimeters(self): + """Verify the field description clarifies millimeter units.""" + schema = FlightObservationSchema.model_json_schema() + altitude_field = schema["properties"]["altitude_mm"] + + assert "millimeter" in altitude_field["description"].lower() + + def test_altitude_mm_reasonable_drone_altitude(self): + """Test with typical drone altitude (120m AGL + ground level).""" + ground_level_wgs84_m = 570 # Bern ground level + flight_altitude_agl_m = 120 + total_altitude_m = ground_level_wgs84_m + flight_altitude_agl_m + altitude_mm = total_altitude_m * 1000 # 690,000 mm + + obs = FlightObservationSchema( + lat_dd=46.97, + lon_dd=7.47, + altitude_mm=altitude_mm, + traffic_source=1, + source_type=1, + icao_address="DRONE1", + timestamp=1234567890, + ) + + # Convert back to meters for verification + assert obs.altitude_mm / 1000 == 690.0 + + +class TestGeoJSONAirtrafficSimulator: + """Tests for GeoJSONAirtrafficSimulator altitude conversion.""" + + @pytest.fixture + def sample_geojson(self): + """Create a minimal valid GeoJSON for testing.""" + return { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [7.47, 46.97], + [7.48, 46.98], + ], + }, + "properties": {}, + } + ], + } + + def test_air_traffic_altitude_is_in_millimeters(self, sample_geojson): + """Verify generated air traffic data has altitude in millimeters.""" + from uuid import uuid4 + + from openutm_verification.simulator.geo_json_telemetry import ( + GeoJSONAirtrafficSimulator, + ) + + altitude_meters = 500.0 + config = AirTrafficGeneratorConfiguration( + geojson=sample_geojson, + altitude_of_ground_level_wgs_84=altitude_meters, + reference_time=arrow.utcnow(), + ) + + simulator = GeoJSONAirtrafficSimulator(config) + session_ids = [uuid4()] + result = simulator.generate_air_traffic_data( + duration=2, + session_ids=session_ids, + number_of_aircraft=1, + ) + + # Get first observation from first aircraft + assert len(result) > 0 + assert len(result[0]) > 0 + first_obs = result[0][0] + + # altitude_mm should be altitude_meters * 1000 + expected_mm = altitude_meters * 1000 + assert first_obs.altitude_mm == expected_mm, f"Expected altitude_mm={expected_mm} (from {altitude_meters}m), got {first_obs.altitude_mm}" + + +class TestOpenSkyClientAltitudeConversion: + """Tests for OpenSky client altitude conversion.""" + + def test_baro_altitude_converted_to_millimeters(self): + """Verify barometric altitude from OpenSky is converted to mm.""" + from openutm_verification.core.clients.opensky.base_client import ( + OpenSkySettings, + ) + from openutm_verification.core.clients.opensky.opensky_client import ( + OpenSkyClient, + ) + + settings = OpenSkySettings( + viewport=(46.9, 7.4, 47.0, 7.5), + opensky_client_id="test", + opensky_client_secret="test", + simulation_config_path="test.geojson", + ) + client = OpenSkyClient(settings=settings) + + # Create mock DataFrame with altitude in meters (OpenSky API units) + altitude_meters = 1500.0 + mock_df = pd.DataFrame( + [ + { + "time_position": 1234567890, + "icao24": "ABC123", + "lat": 46.97, + "long": 7.47, + "baro_altitude": altitude_meters, + "velocity": 250.0, + } + ] + ) + + observations = client.process_flight_data(mock_df) + + assert len(observations) == 1 + obs = observations[0] + + # altitude_mm should be altitude_meters * 1000 + expected_mm = altitude_meters * 1000 + assert obs.altitude_mm == pytest.approx(expected_mm), f"Expected altitude_mm={expected_mm} (from {altitude_meters}m), got {obs.altitude_mm}" + + def test_no_data_altitude_becomes_zero_mm(self): + """Verify 'No Data' altitude is handled as 0 millimeters.""" + from openutm_verification.core.clients.opensky.base_client import ( + OpenSkySettings, + ) + from openutm_verification.core.clients.opensky.opensky_client import ( + OpenSkyClient, + ) + + settings = OpenSkySettings( + viewport=(46.9, 7.4, 47.0, 7.5), + opensky_client_id="test", + opensky_client_secret="test", + simulation_config_path="test.geojson", + ) + client = OpenSkyClient(settings=settings) + + mock_df = pd.DataFrame( + [ + { + "time_position": 1234567890, + "icao24": "ABC123", + "lat": 46.97, + "long": 7.47, + "baro_altitude": "No Data", + "velocity": 250.0, + } + ] + ) + + observations = client.process_flight_data(mock_df) + + assert len(observations) == 1 + assert observations[0].altitude_mm == pytest.approx(0.0) + + +class TestVisualizationAltitudeConversion: + """Tests for visualization altitude handling.""" + + def test_2d_map_converts_mm_to_meters_for_display(self): + """Verify 2D visualization converts altitude_mm to meters for tooltips.""" + from openutm_verification.core.reporting.visualize_flight import ( + _reorganize_air_traffic_by_aircraft, + ) + + # Create observation with altitude in millimeters + altitude_m = 500.0 + altitude_mm = altitude_m * 1000 + + obs = FlightObservationSchema( + lat_dd=46.97, + lon_dd=7.47, + altitude_mm=altitude_mm, + traffic_source=1, + source_type=1, + icao_address="TEST01", + timestamp=1234567890, + ) + + air_traffic_data = [[obs]] + result = _reorganize_air_traffic_by_aircraft(air_traffic_data) + + assert "TEST01" in result + assert len(result["TEST01"]) == 1 + + # The visualization code will divide by 1000 to get meters + retrieved_obs = result["TEST01"][0] + displayed_altitude_m = retrieved_obs.altitude_mm / 1000 + assert displayed_altitude_m == pytest.approx(altitude_m) + + def test_altitude_conversion_preserves_precision(self): + """Verify altitude conversion doesn't lose precision for typical values.""" + # Test with various altitudes + test_altitudes_m = [0.0, 50.5, 120.0, 570.123, 1000.0, 10000.5] + + for altitude_m in test_altitudes_m: + altitude_mm = altitude_m * 1000 + + obs = FlightObservationSchema( + lat_dd=46.97, + lon_dd=7.47, + altitude_mm=altitude_mm, + traffic_source=1, + source_type=1, + icao_address="TEST", + timestamp=1234567890, + ) + + # Round-trip conversion + recovered_m = obs.altitude_mm / 1000 + assert abs(recovered_m - altitude_m) < 0.001, f"Precision loss for {altitude_m}m: got {recovered_m}m" + + +class TestAltitudeUnitConsistency: + """Integration tests to verify altitude unit consistency across modules.""" + + def test_flight_observation_schema_serialization(self): + """Verify FlightObservationSchema serializes altitude_mm correctly.""" + altitude_mm = 570_000 # 570m in mm + + obs = FlightObservationSchema( + lat_dd=46.97, + lon_dd=7.47, + altitude_mm=altitude_mm, + traffic_source=1, + source_type=1, + icao_address="SERIAL", + timestamp=1234567890, + ) + + # Serialize to dict (as would be sent to API) + obs_dict = obs.model_dump() + assert obs_dict["altitude_mm"] == altitude_mm + + # Serialize to JSON and back + obs_json = obs.model_dump_json() + obs_reloaded = FlightObservationSchema.model_validate_json(obs_json) + assert obs_reloaded.altitude_mm == altitude_mm + + def test_config_altitude_is_in_meters(self): + """Verify configuration altitude_of_ground_level_wgs_84 is documented as meters.""" + # Check default values make sense as meters (not mm) + air_config = AirTrafficGeneratorConfiguration(geojson={}) + geojson_config = GeoJSONFlightsSimulatorConfiguration(geojson={}) + + # Default is 570, which makes sense as meters (Bern ground level) + # If it were in mm, 570mm = 0.57m which is way too low + assert air_config.altitude_of_ground_level_wgs_84 == 570 + assert geojson_config.altitude_of_ground_level_wgs_84 == 570 + + # Reasonable ground level is 0-9000m (Mt Everest is ~8849m) + assert 0 <= air_config.altitude_of_ground_level_wgs_84 <= 9000 + + +class TestMetersToMillimetersConversion: + """Direct tests for the meters-to-millimeters conversion logic.""" + + @pytest.mark.parametrize( + "meters,expected_mm", + [ + (0, 0), + (1, 1000), + (100, 100_000), + (570, 570_000), # Bern ground level + (690, 690_000), # Bern + 120m AGL + (10000, 10_000_000), # High altitude aircraft + (0.5, 500), # Sub-meter precision + (123.456, 123_456), # Decimal precision + ], + ) + def test_meters_to_millimeters(self, meters: float, expected_mm: float): + """Verify meters to millimeters conversion formula.""" + actual_mm = meters * 1000 + assert actual_mm == expected_mm + + @pytest.mark.parametrize( + "mm,expected_meters", + [ + (0, 0), + (1000, 1), + (570_000, 570), + (690_000, 690), + (500, 0.5), + ], + ) + def test_millimeters_to_meters(self, mm: float, expected_meters: float): + """Verify millimeters to meters conversion formula (used in visualization).""" + actual_meters = mm / 1000 + assert actual_meters == expected_meters diff --git a/tests/test_client_steps.py b/tests/test_client_steps.py index 85a2602..bff740c 100644 --- a/tests/test_client_steps.py +++ b/tests/test_client_steps.py @@ -439,7 +439,10 @@ def side_effect_get(arg): result = await fb_client.submit_simulated_air_traffic(observations=obs) - assert result.result is True + # Result now returns detailed dict with submission stats + assert isinstance(result.result, dict) + assert result.result["aircraft_count"] == 2 + assert result.result["observations_submitted"] == 2 assert fb_client.post.called diff --git a/tests/test_step_status_updates.py b/tests/test_step_status_updates.py new file mode 100644 index 0000000..e98c6b1 --- /dev/null +++ b/tests/test_step_status_updates.py @@ -0,0 +1,507 @@ +"""Tests for step status updates, particularly for error handling and group execution.""" + +from unittest.mock import MagicMock, patch + +import pytest +from pydantic import ValidationError + +from openutm_verification.core.execution.definitions import ( + GroupDefinition, + ScenarioDefinition, + StepDefinition, +) +from openutm_verification.core.reporting.reporting_models import Status, StepResult +from openutm_verification.server.runner import SessionManager + + +@pytest.fixture +def session_manager(): + """Create a SessionManager instance for testing.""" + manager = SessionManager() + manager.session_resolver = None + manager.session_context = None + manager.session_stack = None + return manager + + +class TestStepStatusOnException: + """Tests for step status updates when exceptions occur.""" + + @pytest.mark.asyncio + async def test_step_status_fail_on_validation_error(self, session_manager): + """Test that step status is updated to FAIL when a validation error occurs.""" + # Mock the scenario context with proper state + mock_state = MagicMock() + mock_state.step_results = {} + mock_state.steps = [] + + mock_context = MagicMock() + mock_context.state = mock_state + mock_context.__enter__ = MagicMock(return_value=mock_context) + mock_context.__exit__ = MagicMock(return_value=False) + + session_manager.session_context = mock_context + session_manager.session_resolver = MagicMock() + + step = StepDefinition(id="test_step", step="Submit Air Traffic", arguments={"observations": None}) + + # Simulate a validation error by making _execute_step raise + with patch.object(session_manager, "_execute_step") as mock_execute: + mock_execute.side_effect = ValidationError.from_exception_data( + "FlightBlenderClient.submit_air_traffic", + [ + { + "type": "list_type", + "loc": ("observations",), + "msg": "Input should be a valid list", + "input": None, + } + ], + ) + + with pytest.raises(ValidationError): + await session_manager.execute_single_step(step) + + # Check that update_result was called with FAIL status + calls = mock_context.update_result.call_args_list + assert len(calls) >= 1 + failed_result = calls[-1][0][0] + assert isinstance(failed_result, StepResult) + assert failed_result.status == Status.FAIL + assert failed_result.id == "test_step" + assert "list" in failed_result.error_message.lower() + + @pytest.mark.asyncio + async def test_step_status_fail_on_generic_exception(self, session_manager): + """Test that step status is updated to FAIL on any exception.""" + mock_state = MagicMock() + mock_state.step_results = {} + mock_state.steps = [] + + mock_context = MagicMock() + mock_context.state = mock_state + mock_context.__enter__ = MagicMock(return_value=mock_context) + mock_context.__exit__ = MagicMock(return_value=False) + + session_manager.session_context = mock_context + session_manager.session_resolver = MagicMock() + + step = StepDefinition(id="test_step", step="Wait X seconds", arguments={"duration": 1}) + + with patch.object(session_manager, "_execute_step") as mock_execute: + mock_execute.side_effect = RuntimeError("Something went wrong") + + with pytest.raises(RuntimeError): + await session_manager.execute_single_step(step) + + # Check that update_result was called with FAIL status + calls = mock_context.update_result.call_args_list + assert len(calls) >= 1 + failed_result = calls[-1][0][0] + assert isinstance(failed_result, StepResult) + assert failed_result.status == Status.FAIL + assert failed_result.id == "test_step" + assert "Something went wrong" in failed_result.error_message + + +class TestGroupStepStatusUpdates: + """Tests for group step status updates when a step fails.""" + + @pytest.mark.asyncio + async def test_remaining_group_steps_marked_skip_on_failure(self, session_manager): + """Test that remaining group steps are marked as SKIP when a step fails.""" + scenario = ScenarioDefinition( + name="Test Group Failure", + description="Test that remaining steps are skipped on failure", + groups={ + "my_group": GroupDefinition( + description="Test group", + steps=[ + StepDefinition(id="step1", step="Fetch OpenSky Data"), + StepDefinition(id="step2", step="Submit Air Traffic", arguments={"observations": []}), + StepDefinition(id="step3", step="Wait X seconds", arguments={"duration": 1}), + ], + ) + }, + steps=[StepDefinition(step="my_group")], + ) + + # Track recorded results + recorded_results = {} + + async def mock_record_running(step, task_id=None): + result = StepResult(id=step.id or step.step, name=step.step, status=Status.RUNNING, duration=0.0) + recorded_results[result.id] = result + return result + + async def mock_execute_single_step(step, loop_context=None): + # First step succeeds, second fails + if step.id == "step1": + result = StepResult(id=step.id, name=step.step, status=Status.PASS, duration=0.0, result=[]) + recorded_results[result.id] = result + return result + elif step.id == "step2": + result = StepResult( + id=step.id, + name=step.step, + status=Status.FAIL, + duration=0.0, + error_message="Validation error", + ) + recorded_results[result.id] = result + return result + else: + result = StepResult(id=step.id, name=step.step, status=Status.PASS, duration=0.0) + recorded_results[result.id] = result + return result + + # Mock session context + mock_state = MagicMock() + mock_state.step_results = recorded_results + mock_state.steps = [] + + mock_context = MagicMock() + mock_context.state = mock_state + mock_context.__enter__ = MagicMock(return_value=mock_context) + mock_context.__exit__ = MagicMock(return_value=False) + + def update_result(result): + recorded_results[result.id] = result + + mock_context.update_result = update_result + + session_manager.session_context = mock_context + session_manager.session_resolver = MagicMock() + + with patch.object(session_manager, "_record_step_running", side_effect=mock_record_running): + with patch.object(session_manager, "execute_single_step", side_effect=mock_execute_single_step): + results = await session_manager._execute_group(StepDefinition(step="my_group"), scenario, loop_context=None) + + # Verify results + assert len(results) == 2 # Only step1 and step2 executed before break + + # Check step1 passed + assert recorded_results["step1"].status == Status.PASS + + # Check step2 failed + assert recorded_results["step2"].status == Status.FAIL + + # Check step3 was marked as SKIP (not left as RUNNING) + assert "step3" in recorded_results + assert recorded_results["step3"].status == Status.SKIP + assert "previous step failure" in recorded_results["step3"].error_message.lower() + + @pytest.mark.asyncio + async def test_group_step_with_condition_skipped(self, session_manager): + """Test that a group step with a failing condition is marked as SKIP.""" + scenario = ScenarioDefinition( + name="Test Group Condition", + description="Test that steps with failing conditions are skipped", + groups={ + "my_group": GroupDefinition( + description="Test group", + steps=[ + StepDefinition(id="fetch", step="Fetch OpenSky Data"), + StepDefinition( + id="submit", + step="Submit Air Traffic", + arguments={"observations": []}, + if_condition="steps.fetch.result != None", + ), + StepDefinition(id="wait", step="Wait X seconds", arguments={"duration": 1}), + ], + ) + }, + steps=[StepDefinition(step="my_group")], + ) + + recorded_results = {} + + async def mock_record_running(step, _task_id=None): + result = StepResult(id=step.id or step.step, name=step.step, status=Status.RUNNING, duration=0.0) + recorded_results[result.id] = result + return result + + async def mock_execute_single_step(step, _loop_context=None): + # First step returns None (no data) + if step.id == "fetch": + result = StepResult(id=step.id, name=step.step, status=Status.PASS, duration=0.0, result=None) + recorded_results[result.id] = result + return result + elif step.id == "wait": + result = StepResult(id=step.id, name=step.step, status=Status.PASS, duration=0.0) + recorded_results[result.id] = result + return result + else: + result = StepResult(id=step.id, name=step.step, status=Status.PASS, duration=0.0) + recorded_results[result.id] = result + return result + + mock_state = MagicMock() + mock_state.step_results = recorded_results + mock_state.steps = [] + + mock_context = MagicMock() + mock_context.state = mock_state + mock_context.__enter__ = MagicMock(return_value=mock_context) + mock_context.__exit__ = MagicMock(return_value=False) + + def update_result(result): + recorded_results[result.id] = result + + mock_context.update_result = update_result + + session_manager.session_context = mock_context + session_manager.session_resolver = MagicMock() + + with patch.object(session_manager, "_record_step_running", side_effect=mock_record_running): + with patch.object(session_manager, "execute_single_step", side_effect=mock_execute_single_step): + results = await session_manager._execute_group(StepDefinition(step="my_group"), scenario, loop_context=None) + + # Verify results: fetch, submit (skipped), wait + assert len(results) == 3 + + # Check fetch passed + assert recorded_results["fetch"].status == Status.PASS + + # Check submit was skipped (condition failed because fetch.result is None) + assert recorded_results["submit"].status == Status.SKIP + assert "condition" in recorded_results["submit"].error_message.lower() + + # Check wait passed + assert recorded_results["wait"].status == Status.PASS + + +class TestGroupContextReferenceResolution: + """Tests for reference resolution within group context.""" + + @pytest.mark.asyncio + async def test_group_context_takes_priority_over_state_step_results(self, session_manager): + """Test that group_context is checked before state.step_results for reference resolution. + + This is important because state.step_results may contain stale RUNNING entries + from when steps were initially marked as running, while group_context contains + the actual completed results. + """ + scenario = ScenarioDefinition( + name="Test Group Context Priority", + description="Test that group context is prioritized for reference resolution", + groups={ + "my_group": GroupDefinition( + description="Test group", + steps=[ + StepDefinition(id="fetch", step="Fetch OpenSky Data"), + StepDefinition( + id="submit", + step="Submit Air Traffic", + arguments={"observations": "${{ steps.fetch.result }}"}, + ), + ], + ) + }, + steps=[StepDefinition(step="my_group")], + ) + + recorded_results = {} + resolved_observations = None + + async def mock_record_running(step, _task_id=None): + # This simulates marking the step as RUNNING with the original ID + result = StepResult( + id=step.id or step.step, + name=step.step, + status=Status.RUNNING, + duration=0.0, + result=None, # RUNNING entries have no result data + ) + recorded_results[result.id] = result + return result + + async def mock_execute_single_step(step, loop_context=None): + nonlocal resolved_observations + + if step.id == "fetch" or (step.id and step.id.endswith(".fetch")): + # Return actual data + fetch_data = [{"lat_dd": 44.835, "lon_dd": 26.0809}] + result = StepResult( + id=step.id, + name=step.step, + status=Status.PASS, + duration=0.0, + result=fetch_data, + ) + recorded_results[step.id] = result + return result + elif step.id == "submit" or (step.id and step.id.endswith(".submit")): + # Capture what observations were resolved to + if step.arguments: + resolved_observations = step.arguments.get("observations") + result = StepResult( + id=step.id, + name=step.step, + status=Status.PASS, + duration=0.0, + ) + recorded_results[step.id] = result + return result + else: + result = StepResult(id=step.id, name=step.step, status=Status.PASS, duration=0.0) + recorded_results[step.id] = result + return result + + mock_state = MagicMock() + mock_state.step_results = recorded_results + mock_state.steps = [] + + mock_context = MagicMock() + mock_context.state = mock_state + mock_context.__enter__ = MagicMock(return_value=mock_context) + mock_context.__exit__ = MagicMock(return_value=False) + + def update_result(result): + recorded_results[result.id] = result + + mock_context.update_result = update_result + + session_manager.session_context = mock_context + session_manager.session_resolver = MagicMock() + + with patch.object(session_manager, "_record_step_running", side_effect=mock_record_running): + with patch.object(session_manager, "execute_single_step", side_effect=mock_execute_single_step): + await session_manager._execute_group(StepDefinition(step="my_group"), scenario, loop_context=None) + + # The key assertion: resolved_observations should be the actual data, + # not None (which would happen if stale RUNNING entry was used) + # Note: The test verifies the fix works. Before the fix, this would fail + # because state.step_results["fetch"] would have the RUNNING entry with result=None + + +class TestLoopStepStatusUpdates: + """Tests for loop step status updates in groups.""" + + @pytest.mark.asyncio + async def test_looped_group_step_ids_include_index(self, session_manager): + """Test that step IDs in looped groups include the loop index.""" + scenario = ScenarioDefinition( + name="Test Loop IDs", + description="Test loop step IDs", + groups={ + "my_group": GroupDefinition( + description="Test group", + steps=[ + StepDefinition(id="step1", step="Wait X seconds", arguments={"duration": 1}), + ], + ) + }, + steps=[StepDefinition(step="my_group")], + ) + + recorded_ids = [] + + async def mock_record_running(step, _task_id=None): + result = StepResult(id=step.id or step.step, name=step.step, status=Status.RUNNING, duration=0.0) + return result + + async def mock_execute_single_step(step, _loop_context=None): + recorded_ids.append(step.id) + result = StepResult(id=step.id, name=step.step, status=Status.PASS, duration=0.0) + return result + + mock_state = MagicMock() + mock_state.step_results = {} + mock_state.steps = [] + + mock_context = MagicMock() + mock_context.state = mock_state + mock_context.__enter__ = MagicMock(return_value=mock_context) + mock_context.__exit__ = MagicMock(return_value=False) + mock_context.update_result = MagicMock() + + session_manager.session_context = mock_context + session_manager.session_resolver = MagicMock() + + with patch.object(session_manager, "_record_step_running", side_effect=mock_record_running): + with patch.object(session_manager, "execute_single_step", side_effect=mock_execute_single_step): + # Execute group with loop context + await session_manager._execute_group( + StepDefinition(step="my_group"), + scenario, + loop_context={"index": 2, "item": 2}, + ) + + # Verify step ID includes loop index + assert len(recorded_ids) == 1 + assert recorded_ids[0] == "my_group[2].step1" + + +class TestDuplicateStepPrevention: + """Tests that steps are not duplicated in the report.""" + + @pytest.mark.asyncio + async def test_no_duplicate_running_entries(self, session_manager): + """Test that RUNNING status is only recorded once per step. + + Previously, RUNNING was recorded twice: once in _execute_group (pre-recording) + and once in _execute_step. This caused duplicate entries in reports. + The fix removes the pre-recording in _execute_group. + """ + scenario = ScenarioDefinition( + name="Test No Duplicates", + description="Test that step IDs are consistent", + groups={ + "my_group": GroupDefinition( + description="Test group", + steps=[ + StepDefinition(id="step1", step="Wait X seconds", arguments={"duration": 1}), + StepDefinition(id="step2", step="Wait X seconds", arguments={"duration": 1}), + ], + ) + }, + steps=[StepDefinition(step="my_group")], + ) + + # Track how many times each step ID has RUNNING recorded + running_record_count = {} + + async def mock_record_running(step, _task_id=None): + step_id = step.id or step.step + running_record_count[step_id] = running_record_count.get(step_id, 0) + 1 + result = StepResult(id=step_id, name=step.step, status=Status.RUNNING, duration=0.0) + return result + + async def mock_execute_single_step(step, _loop_context=None): + # Simulate what _execute_step does: record RUNNING, then return final result + await mock_record_running(step) + result = StepResult(id=step.id, name=step.step, status=Status.PASS, duration=0.0) + return result + + mock_state = MagicMock() + mock_state.step_results = {} + mock_state.steps = [] + + mock_context = MagicMock() + mock_context.state = mock_state + mock_context.__enter__ = MagicMock(return_value=mock_context) + mock_context.__exit__ = MagicMock(return_value=False) + mock_context.update_result = MagicMock() + + session_manager.session_context = mock_context + session_manager.session_resolver = MagicMock() + + with patch.object(session_manager, "_record_step_running", side_effect=mock_record_running): + with patch.object(session_manager, "execute_single_step", side_effect=mock_execute_single_step): + # Execute group with loop context + await session_manager._execute_group( + StepDefinition(step="my_group"), + scenario, + loop_context={"index": 0, "item": 0}, + ) + + # Verify RUNNING is only recorded once per step (by execute_single_step, not pre-recorded) + # Before the fix, this would be 2 for each step + assert running_record_count.get("my_group[0].step1", 0) == 1, ( + f"step1 RUNNING recorded {running_record_count.get('my_group[0].step1', 0)} times, expected 1" + ) + assert running_record_count.get("my_group[0].step2", 0) == 1, ( + f"step2 RUNNING recorded {running_record_count.get('my_group[0].step2', 0)} times, expected 1" + ) diff --git a/uv.lock b/uv.lock index cf12c95..011e113 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,14 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] [[package]] name = "annotated-doc" @@ -351,6 +359,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, ] +[[package]] +name = "coverage" +version = "7.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/49/349848445b0e53660e258acbcc9b0d014895b6739237920886672240f84b/coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3", size = 826523, upload-time = "2026-01-25T13:00:04.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/39/e92a35f7800222d3f7b2cbb7bbc3b65672ae8d501cb31801b2d2bd7acdf1/coverage-7.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d", size = 219142, upload-time = "2026-01-25T12:58:00.448Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/8bf9e9309c4c996e65c52a7c5a112707ecdd9fbaf49e10b5a705a402bbb4/coverage-7.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3", size = 219503, upload-time = "2026-01-25T12:58:02.451Z" }, + { url = "https://files.pythonhosted.org/packages/87/93/17661e06b7b37580923f3f12406ac91d78aeed293fb6da0b69cc7957582f/coverage-7.13.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99", size = 251006, upload-time = "2026-01-25T12:58:04.059Z" }, + { url = "https://files.pythonhosted.org/packages/12/f0/f9e59fb8c310171497f379e25db060abef9fa605e09d63157eebec102676/coverage-7.13.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f", size = 253750, upload-time = "2026-01-25T12:58:05.574Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b1/1935e31add2232663cf7edd8269548b122a7d100047ff93475dbaaae673e/coverage-7.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f", size = 254862, upload-time = "2026-01-25T12:58:07.647Z" }, + { url = "https://files.pythonhosted.org/packages/af/59/b5e97071ec13df5f45da2b3391b6cdbec78ba20757bc92580a5b3d5fa53c/coverage-7.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa", size = 251420, upload-time = "2026-01-25T12:58:09.309Z" }, + { url = "https://files.pythonhosted.org/packages/3f/75/9495932f87469d013dc515fb0ce1aac5fa97766f38f6b1a1deb1ee7b7f3a/coverage-7.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce", size = 252786, upload-time = "2026-01-25T12:58:10.909Z" }, + { url = "https://files.pythonhosted.org/packages/6a/59/af550721f0eb62f46f7b8cb7e6f1860592189267b1c411a4e3a057caacee/coverage-7.13.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94", size = 250928, upload-time = "2026-01-25T12:58:12.449Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b1/21b4445709aae500be4ab43bbcfb4e53dc0811c3396dcb11bf9f23fd0226/coverage-7.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5", size = 250496, upload-time = "2026-01-25T12:58:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/0f5d89dfe0392990e4f3980adbde3eb34885bc1effb2dc369e0bf385e389/coverage-7.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b", size = 252373, upload-time = "2026-01-25T12:58:15.976Z" }, + { url = "https://files.pythonhosted.org/packages/01/c9/0cf1a6a57a9968cc049a6b896693faa523c638a5314b1fc374eb2b2ac904/coverage-7.13.2-cp312-cp312-win32.whl", hash = "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41", size = 221696, upload-time = "2026-01-25T12:58:17.517Z" }, + { url = "https://files.pythonhosted.org/packages/4d/05/d7540bf983f09d32803911afed135524570f8c47bb394bf6206c1dc3a786/coverage-7.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e", size = 222504, upload-time = "2026-01-25T12:58:19.115Z" }, + { url = "https://files.pythonhosted.org/packages/15/8b/1a9f037a736ced0a12aacf6330cdaad5008081142a7070bc58b0f7930cbc/coverage-7.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894", size = 221120, upload-time = "2026-01-25T12:58:21.334Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f0/3d3eac7568ab6096ff23791a526b0048a1ff3f49d0e236b2af6fb6558e88/coverage-7.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6", size = 219168, upload-time = "2026-01-25T12:58:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a6/f8b5cfeddbab95fdef4dcd682d82e5dcff7a112ced57a959f89537ee9995/coverage-7.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc", size = 219537, upload-time = "2026-01-25T12:58:24.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e6/8d8e6e0c516c838229d1e41cadcec91745f4b1031d4db17ce0043a0423b4/coverage-7.13.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:68c86173562ed4413345410c9480a8d64864ac5e54a5cda236748031e094229f", size = 250528, upload-time = "2026-01-25T12:58:26.567Z" }, + { url = "https://files.pythonhosted.org/packages/8e/78/befa6640f74092b86961f957f26504c8fba3d7da57cc2ab7407391870495/coverage-7.13.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1", size = 253132, upload-time = "2026-01-25T12:58:28.251Z" }, + { url = "https://files.pythonhosted.org/packages/9d/10/1630db1edd8ce675124a2ee0f7becc603d2bb7b345c2387b4b95c6907094/coverage-7.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7f63ce526a96acd0e16c4af8b50b64334239550402fb1607ce6a584a6d62ce9", size = 254374, upload-time = "2026-01-25T12:58:30.294Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1d/0d9381647b1e8e6d310ac4140be9c428a0277330991e0c35bdd751e338a4/coverage-7.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c", size = 250762, upload-time = "2026-01-25T12:58:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5636dfc9a7c871ee8776af83ee33b4c26bc508ad6cee1e89b6419a366582/coverage-7.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5", size = 252502, upload-time = "2026-01-25T12:58:33.961Z" }, + { url = "https://files.pythonhosted.org/packages/02/2a/7ff2884d79d420cbb2d12fed6fff727b6d0ef27253140d3cdbbd03187ee0/coverage-7.13.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4", size = 250463, upload-time = "2026-01-25T12:58:35.529Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/ba51087db645b6c7261570400fc62c89a16278763f36ba618dc8657a187b/coverage-7.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c", size = 250288, upload-time = "2026-01-25T12:58:37.226Z" }, + { url = "https://files.pythonhosted.org/packages/03/07/44e6f428551c4d9faf63ebcefe49b30e5c89d1be96f6a3abd86a52da9d15/coverage-7.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31", size = 252063, upload-time = "2026-01-25T12:58:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/c2/67/35b730ad7e1859dd57e834d1bc06080d22d2f87457d53f692fce3f24a5a9/coverage-7.13.2-cp313-cp313-win32.whl", hash = "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8", size = 221716, upload-time = "2026-01-25T12:58:40.484Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/e5fcf5a97c72f45fc14829237a6550bf49d0ab882ac90e04b12a69db76b4/coverage-7.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb", size = 222522, upload-time = "2026-01-25T12:58:43.247Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/25d7b2f946d239dd2d6644ca2cc060d24f97551e2af13b6c24c722ae5f97/coverage-7.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557", size = 221145, upload-time = "2026-01-25T12:58:45Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f7/080376c029c8f76fadfe43911d0daffa0cbdc9f9418a0eead70c56fb7f4b/coverage-7.13.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e", size = 219861, upload-time = "2026-01-25T12:58:46.586Z" }, + { url = "https://files.pythonhosted.org/packages/42/11/0b5e315af5ab35f4c4a70e64d3314e4eec25eefc6dec13be3a7d5ffe8ac5/coverage-7.13.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7", size = 220207, upload-time = "2026-01-25T12:58:48.277Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0c/0874d0318fb1062117acbef06a09cf8b63f3060c22265adaad24b36306b7/coverage-7.13.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:838943bea48be0e2768b0cf7819544cdedc1bbb2f28427eabb6eb8c9eb2285d3", size = 261504, upload-time = "2026-01-25T12:58:49.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/5e/1cd72c22ecb30751e43a72f40ba50fcef1b7e93e3ea823bd9feda8e51f9a/coverage-7.13.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3", size = 263582, upload-time = "2026-01-25T12:58:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/8acf356707c7a42df4d0657020308e23e5a07397e81492640c186268497c/coverage-7.13.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93b57142f9621b0d12349c43fc7741fe578e4bc914c1e5a54142856cfc0bf421", size = 266008, upload-time = "2026-01-25T12:58:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/41/41/ea1730af99960309423c6ea8d6a4f1fa5564b2d97bd1d29dda4b42611f04/coverage-7.13.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5", size = 260762, upload-time = "2026-01-25T12:58:55.372Z" }, + { url = "https://files.pythonhosted.org/packages/22/fa/02884d2080ba71db64fdc127b311db60e01fe6ba797d9c8363725e39f4d5/coverage-7.13.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23", size = 263571, upload-time = "2026-01-25T12:58:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6b/4083aaaeba9b3112f55ac57c2ce7001dc4d8fa3fcc228a39f09cc84ede27/coverage-7.13.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c", size = 261200, upload-time = "2026-01-25T12:58:59.255Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d2/aea92fa36d61955e8c416ede9cf9bf142aa196f3aea214bb67f85235a050/coverage-7.13.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f", size = 260095, upload-time = "2026-01-25T12:59:01.066Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ae/04ffe96a80f107ea21b22b2367175c621da920063260a1c22f9452fd7866/coverage-7.13.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573", size = 262284, upload-time = "2026-01-25T12:59:02.802Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7a/6f354dcd7dfc41297791d6fb4e0d618acb55810bde2c1fd14b3939e05c2b/coverage-7.13.2-cp313-cp313t-win32.whl", hash = "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343", size = 222389, upload-time = "2026-01-25T12:59:04.563Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d5/080ad292a4a3d3daf411574be0a1f56d6dee2c4fdf6b005342be9fac807f/coverage-7.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47", size = 223450, upload-time = "2026-01-25T12:59:06.677Z" }, + { url = "https://files.pythonhosted.org/packages/88/96/df576fbacc522e9fb8d1c4b7a7fc62eb734be56e2cba1d88d2eabe08ea3f/coverage-7.13.2-cp313-cp313t-win_arm64.whl", hash = "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7", size = 221707, upload-time = "2026-01-25T12:59:08.363Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/1da9e51a0775634b04fcc11eb25c002fc58ee4f92ce2e8512f94ac5fc5bf/coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef", size = 219213, upload-time = "2026-01-25T12:59:11.909Z" }, + { url = "https://files.pythonhosted.org/packages/46/35/b3caac3ebbd10230fea5a33012b27d19e999a17c9285c4228b4b2e35b7da/coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f", size = 219549, upload-time = "2026-01-25T12:59:13.638Z" }, + { url = "https://files.pythonhosted.org/packages/76/9c/e1cf7def1bdc72c1907e60703983a588f9558434a2ff94615747bd73c192/coverage-7.13.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5", size = 250586, upload-time = "2026-01-25T12:59:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ba/49/f54ec02ed12be66c8d8897270505759e057b0c68564a65c429ccdd1f139e/coverage-7.13.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4", size = 253093, upload-time = "2026-01-25T12:59:17.491Z" }, + { url = "https://files.pythonhosted.org/packages/fb/5e/aaf86be3e181d907e23c0f61fccaeb38de8e6f6b47aed92bf57d8fc9c034/coverage-7.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27", size = 254446, upload-time = "2026-01-25T12:59:19.752Z" }, + { url = "https://files.pythonhosted.org/packages/28/c8/a5fa01460e2d75b0c853b392080d6829d3ca8b5ab31e158fa0501bc7c708/coverage-7.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548", size = 250615, upload-time = "2026-01-25T12:59:21.928Z" }, + { url = "https://files.pythonhosted.org/packages/86/0b/6d56315a55f7062bb66410732c24879ccb2ec527ab6630246de5fe45a1df/coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660", size = 252452, upload-time = "2026-01-25T12:59:23.592Z" }, + { url = "https://files.pythonhosted.org/packages/30/19/9bc550363ebc6b0ea121977ee44d05ecd1e8bf79018b8444f1028701c563/coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92", size = 250418, upload-time = "2026-01-25T12:59:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/580530a31ca2f0cc6f07a8f2ab5460785b02bb11bdf815d4c4d37a4c5169/coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82", size = 250231, upload-time = "2026-01-25T12:59:27.888Z" }, + { url = "https://files.pythonhosted.org/packages/e2/42/dd9093f919dc3088cb472893651884bd675e3df3d38a43f9053656dca9a2/coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892", size = 251888, upload-time = "2026-01-25T12:59:29.636Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a6/0af4053e6e819774626e133c3d6f70fae4d44884bfc4b126cb647baee8d3/coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe", size = 221968, upload-time = "2026-01-25T12:59:31.424Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cc/5aff1e1f80d55862442855517bb8ad8ad3a68639441ff6287dde6a58558b/coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859", size = 222783, upload-time = "2026-01-25T12:59:33.118Z" }, + { url = "https://files.pythonhosted.org/packages/de/20/09abafb24f84b3292cc658728803416c15b79f9ee5e68d25238a895b07d9/coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6", size = 221348, upload-time = "2026-01-25T12:59:34.939Z" }, + { url = "https://files.pythonhosted.org/packages/b6/60/a3820c7232db63be060e4019017cd3426751c2699dab3c62819cdbcea387/coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b", size = 219950, upload-time = "2026-01-25T12:59:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/fd/37/e4ef5975fdeb86b1e56db9a82f41b032e3d93a840ebaf4064f39e770d5c5/coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417", size = 220209, upload-time = "2026-01-25T12:59:38.339Z" }, + { url = "https://files.pythonhosted.org/packages/54/df/d40e091d00c51adca1e251d3b60a8b464112efa3004949e96a74d7c19a64/coverage-7.13.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee", size = 261576, upload-time = "2026-01-25T12:59:40.446Z" }, + { url = "https://files.pythonhosted.org/packages/c5/44/5259c4bed54e3392e5c176121af9f71919d96dde853386e7730e705f3520/coverage-7.13.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1", size = 263704, upload-time = "2026-01-25T12:59:42.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/ae9f005827abcbe2c70157459ae86053971c9fa14617b63903abbdce26d9/coverage-7.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d", size = 266109, upload-time = "2026-01-25T12:59:44.073Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c0/8e279c1c0f5b1eaa3ad9b0fb7a5637fc0379ea7d85a781c0fe0bb3cfc2ab/coverage-7.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6", size = 260686, upload-time = "2026-01-25T12:59:45.804Z" }, + { url = "https://files.pythonhosted.org/packages/b2/47/3a8112627e9d863e7cddd72894171c929e94491a597811725befdcd76bce/coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a", size = 263568, upload-time = "2026-01-25T12:59:47.929Z" }, + { url = "https://files.pythonhosted.org/packages/92/bc/7ea367d84afa3120afc3ce6de294fd2dcd33b51e2e7fbe4bbfd200f2cb8c/coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04", size = 261174, upload-time = "2026-01-25T12:59:49.717Z" }, + { url = "https://files.pythonhosted.org/packages/33/b7/f1092dcecb6637e31cc2db099581ee5c61a17647849bae6b8261a2b78430/coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f", size = 260017, upload-time = "2026-01-25T12:59:51.463Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/f3d07d4b95fbe1a2ef0958c15da614f7e4f557720132de34d2dc3aa7e911/coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f", size = 262337, upload-time = "2026-01-25T12:59:53.407Z" }, + { url = "https://files.pythonhosted.org/packages/e0/db/b0d5b2873a07cb1e06a55d998697c0a5a540dcefbf353774c99eb3874513/coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3", size = 222749, upload-time = "2026-01-25T12:59:56.316Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2f/838a5394c082ac57d85f57f6aba53093b30d9089781df72412126505716f/coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba", size = 223857, upload-time = "2026-01-25T12:59:58.201Z" }, + { url = "https://files.pythonhosted.org/packages/44/d4/b608243e76ead3a4298824b50922b89ef793e50069ce30316a65c1b4d7ef/coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c", size = 221881, upload-time = "2026-01-25T13:00:00.449Z" }, + { url = "https://files.pythonhosted.org/packages/d2/db/d291e30fdf7ea617a335531e72294e0c723356d7fdde8fba00610a76bda9/coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5", size = 210943, upload-time = "2026-01-25T13:00:02.388Z" }, +] + [[package]] name = "cryptography" version = "44.0.3" @@ -415,11 +497,11 @@ wheels = [ [[package]] name = "dill" -version = "0.4.0" +version = "0.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, ] [[package]] @@ -950,11 +1032,11 @@ wheels = [ [[package]] name = "markdown" -version = "3.10" +version = "3.10.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/b1/af95bcae8549f1f3fd70faacb29075826a0d689a27f232e8cee315efa053/markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a", size = 365402, upload-time = "2026-01-21T18:09:28.206Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/6ef961f543593969d25b2afe57a3564200280528caa9bd1082eecdd7b3bc/markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3", size = 107684, upload-time = "2026-01-21T18:09:27.203Z" }, ] [[package]] @@ -1318,6 +1400,7 @@ dev = [ { name = "pylint" }, { name = "pylint-pytest" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "ruff" }, { name = "ty" }, { name = "types-pyyaml" }, @@ -1373,6 +1456,7 @@ dev = [ { name = "pylint", specifier = ">=3.3.2" }, { name = "pylint-pytest", specifier = ">=1.1.7" }, { name = "pytest", specifier = ">=8.4.1" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "ruff", specifier = ">=0.12.10" }, { name = "ty", specifier = ">=0.0.1a14" }, { name = "types-pyyaml" }, @@ -1382,58 +1466,63 @@ dev = [ [[package]] name = "packaging" -version = "25.0" +version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] [[package]] name = "pandas" -version = "2.3.3" +version = "3.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, - { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, - { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, - { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, - { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, - { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, - { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, - { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, - { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, - { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, - { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, - { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, - { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, - { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, - { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, - { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, - { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, - { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, - { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, - { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, - { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, - { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, - { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, - { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, - { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, - { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, - { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, - { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, - { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, - { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/da/b1dc0481ab8d55d0f46e343cfe67d4551a0e14fcee52bd38ca1bd73258d8/pandas-3.0.0.tar.gz", hash = "sha256:0facf7e87d38f721f0af46fe70d97373a37701b1c09f7ed7aeeb292ade5c050f", size = 4633005, upload-time = "2026-01-21T15:52:04.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/38/db33686f4b5fa64d7af40d96361f6a4615b8c6c8f1b3d334eee46ae6160e/pandas-3.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9803b31f5039b3c3b10cc858c5e40054adb4b29b4d81cb2fd789f4121c8efbcd", size = 10334013, upload-time = "2026-01-21T15:50:34.771Z" }, + { url = "https://files.pythonhosted.org/packages/a5/7b/9254310594e9774906bacdd4e732415e1f86ab7dbb4b377ef9ede58cd8ec/pandas-3.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14c2a4099cd38a1d18ff108168ea417909b2dea3bd1ebff2ccf28ddb6a74d740", size = 9874154, upload-time = "2026-01-21T15:50:36.67Z" }, + { url = "https://files.pythonhosted.org/packages/63/d4/726c5a67a13bc66643e66d2e9ff115cead482a44fc56991d0c4014f15aaf/pandas-3.0.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d257699b9a9960e6125686098d5714ac59d05222bef7a5e6af7a7fd87c650801", size = 10384433, upload-time = "2026-01-21T15:50:39.132Z" }, + { url = "https://files.pythonhosted.org/packages/bf/2e/9211f09bedb04f9832122942de8b051804b31a39cfbad199a819bb88d9f3/pandas-3.0.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69780c98f286076dcafca38d8b8eee1676adf220199c0a39f0ecbf976b68151a", size = 10864519, upload-time = "2026-01-21T15:50:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/50858522cdc46ac88b9afdc3015e298959a70a08cd21e008a44e9520180c/pandas-3.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4a66384f017240f3858a4c8a7cf21b0591c3ac885cddb7758a589f0f71e87ebb", size = 11394124, upload-time = "2026-01-21T15:50:43.377Z" }, + { url = "https://files.pythonhosted.org/packages/86/3f/83b2577db02503cd93d8e95b0f794ad9d4be0ba7cb6c8bcdcac964a34a42/pandas-3.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be8c515c9bc33989d97b89db66ea0cececb0f6e3c2a87fcc8b69443a6923e95f", size = 11920444, upload-time = "2026-01-21T15:50:45.932Z" }, + { url = "https://files.pythonhosted.org/packages/64/2d/4f8a2f192ed12c90a0aab47f5557ece0e56b0370c49de9454a09de7381b2/pandas-3.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:a453aad8c4f4e9f166436994a33884442ea62aa8b27d007311e87521b97246e1", size = 9730970, upload-time = "2026-01-21T15:50:47.962Z" }, + { url = "https://files.pythonhosted.org/packages/d4/64/ff571be435cf1e643ca98d0945d76732c0b4e9c37191a89c8550b105eed1/pandas-3.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:da768007b5a33057f6d9053563d6b74dd6d029c337d93c6d0d22a763a5c2ecc0", size = 9041950, upload-time = "2026-01-21T15:50:50.422Z" }, + { url = "https://files.pythonhosted.org/packages/6f/fa/7f0ac4ca8877c57537aaff2a842f8760e630d8e824b730eb2e859ffe96ca/pandas-3.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b78d646249b9a2bc191040988c7bb524c92fa8534fb0898a0741d7e6f2ffafa6", size = 10307129, upload-time = "2026-01-21T15:50:52.877Z" }, + { url = "https://files.pythonhosted.org/packages/6f/11/28a221815dcea4c0c9414dfc845e34a84a6a7dabc6da3194498ed5ba4361/pandas-3.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bc9cba7b355cb4162442a88ce495e01cb605f17ac1e27d6596ac963504e0305f", size = 9850201, upload-time = "2026-01-21T15:50:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/ba/da/53bbc8c5363b7e5bd10f9ae59ab250fc7a382ea6ba08e4d06d8694370354/pandas-3.0.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c9a1a149aed3b6c9bf246033ff91e1b02d529546c5d6fb6b74a28fea0cf4c70", size = 10354031, upload-time = "2026-01-21T15:50:57.463Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a3/51e02ebc2a14974170d51e2410dfdab58870ea9bcd37cda15bd553d24dc4/pandas-3.0.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95683af6175d884ee89471842acfca29172a85031fccdabc35e50c0984470a0e", size = 10861165, upload-time = "2026-01-21T15:50:59.32Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fe/05a51e3cac11d161472b8297bd41723ea98013384dd6d76d115ce3482f9b/pandas-3.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1fbbb5a7288719e36b76b4f18d46ede46e7f916b6c8d9915b756b0a6c3f792b3", size = 11359359, upload-time = "2026-01-21T15:51:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/ee/56/ba620583225f9b85a4d3e69c01df3e3870659cc525f67929b60e9f21dcd1/pandas-3.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e8b9808590fa364416b49b2a35c1f4cf2785a6c156935879e57f826df22038e", size = 11912907, upload-time = "2026-01-21T15:51:05.175Z" }, + { url = "https://files.pythonhosted.org/packages/c9/8c/c6638d9f67e45e07656b3826405c5cc5f57f6fd07c8b2572ade328c86e22/pandas-3.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:98212a38a709feb90ae658cb6227ea3657c22ba8157d4b8f913cd4c950de5e7e", size = 9732138, upload-time = "2026-01-21T15:51:07.569Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bf/bd1335c3bf1770b6d8fed2799993b11c4971af93bb1b729b9ebbc02ca2ec/pandas-3.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:177d9df10b3f43b70307a149d7ec49a1229a653f907aa60a48f1877d0e6be3be", size = 9033568, upload-time = "2026-01-21T15:51:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/8e/c6/f5e2171914d5e29b9171d495344097d54e3ffe41d2d85d8115baba4dc483/pandas-3.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2713810ad3806767b89ad3b7b69ba153e1c6ff6d9c20f9c2140379b2a98b6c98", size = 10741936, upload-time = "2026-01-21T15:51:11.693Z" }, + { url = "https://files.pythonhosted.org/packages/51/88/9a0164f99510a1acb9f548691f022c756c2314aad0d8330a24616c14c462/pandas-3.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:15d59f885ee5011daf8335dff47dcb8a912a27b4ad7826dc6cbe809fd145d327", size = 10393884, upload-time = "2026-01-21T15:51:14.197Z" }, + { url = "https://files.pythonhosted.org/packages/e0/53/b34d78084d88d8ae2b848591229da8826d1e65aacf00b3abe34023467648/pandas-3.0.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24e6547fb64d2c92665dd2adbfa4e85fa4fd70a9c070e7cfb03b629a0bbab5eb", size = 10310740, upload-time = "2026-01-21T15:51:16.093Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d3/bee792e7c3d6930b74468d990604325701412e55d7aaf47460a22311d1a5/pandas-3.0.0-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48ee04b90e2505c693d3f8e8f524dab8cb8aaf7ddcab52c92afa535e717c4812", size = 10700014, upload-time = "2026-01-21T15:51:18.818Z" }, + { url = "https://files.pythonhosted.org/packages/55/db/2570bc40fb13aaed1cbc3fbd725c3a60ee162477982123c3adc8971e7ac1/pandas-3.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66f72fb172959af42a459e27a8d8d2c7e311ff4c1f7db6deb3b643dbc382ae08", size = 11323737, upload-time = "2026-01-21T15:51:20.784Z" }, + { url = "https://files.pythonhosted.org/packages/bc/2e/297ac7f21c8181b62a4cccebad0a70caf679adf3ae5e83cb676194c8acc3/pandas-3.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4a4a400ca18230976724a5066f20878af785f36c6756e498e94c2a5e5d57779c", size = 11771558, upload-time = "2026-01-21T15:51:22.977Z" }, + { url = "https://files.pythonhosted.org/packages/0a/46/e1c6876d71c14332be70239acce9ad435975a80541086e5ffba2f249bcf6/pandas-3.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:940eebffe55528074341a5a36515f3e4c5e25e958ebbc764c9502cfc35ba3faa", size = 10473771, upload-time = "2026-01-21T15:51:25.285Z" }, + { url = "https://files.pythonhosted.org/packages/c0/db/0270ad9d13c344b7a36fa77f5f8344a46501abf413803e885d22864d10bf/pandas-3.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:597c08fb9fef0edf1e4fa2f9828dd27f3d78f9b8c9b4a748d435ffc55732310b", size = 10312075, upload-time = "2026-01-21T15:51:28.5Z" }, + { url = "https://files.pythonhosted.org/packages/09/9f/c176f5e9717f7c91becfe0f55a52ae445d3f7326b4a2cf355978c51b7913/pandas-3.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:447b2d68ac5edcbf94655fe909113a6dba6ef09ad7f9f60c80477825b6c489fe", size = 9900213, upload-time = "2026-01-21T15:51:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e7/63ad4cc10b257b143e0a5ebb04304ad806b4e1a61c5da25f55896d2ca0f4/pandas-3.0.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:debb95c77ff3ed3ba0d9aa20c3a2f19165cc7956362f9873fce1ba0a53819d70", size = 10428768, upload-time = "2026-01-21T15:51:33.018Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/4e4c2d8210f20149fd2248ef3fff26623604922bd564d915f935a06dd63d/pandas-3.0.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fedabf175e7cd82b69b74c30adbaa616de301291a5231138d7242596fc296a8d", size = 10882954, upload-time = "2026-01-21T15:51:35.287Z" }, + { url = "https://files.pythonhosted.org/packages/c6/60/c9de8ac906ba1f4d2250f8a951abe5135b404227a55858a75ad26f84db47/pandas-3.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:412d1a89aab46889f3033a386912efcdfa0f1131c5705ff5b668dda88305e986", size = 11430293, upload-time = "2026-01-21T15:51:37.57Z" }, + { url = "https://files.pythonhosted.org/packages/a1/69/806e6637c70920e5787a6d6896fd707f8134c2c55cd761e7249a97b7dc5a/pandas-3.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e979d22316f9350c516479dd3a92252be2937a9531ed3a26ec324198a99cdd49", size = 11952452, upload-time = "2026-01-21T15:51:39.618Z" }, + { url = "https://files.pythonhosted.org/packages/cb/de/918621e46af55164c400ab0ef389c9d969ab85a43d59ad1207d4ddbe30a5/pandas-3.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:083b11415b9970b6e7888800c43c82e81a06cd6b06755d84804444f0007d6bb7", size = 9851081, upload-time = "2026-01-21T15:51:41.758Z" }, + { url = "https://files.pythonhosted.org/packages/91/a1/3562a18dd0bd8c73344bfa26ff90c53c72f827df119d6d6b1dacc84d13e3/pandas-3.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:5db1e62cb99e739fa78a28047e861b256d17f88463c76b8dafc7c1338086dca8", size = 9174610, upload-time = "2026-01-21T15:51:44.312Z" }, + { url = "https://files.pythonhosted.org/packages/ce/26/430d91257eaf366f1737d7a1c158677caaf6267f338ec74e3a1ec444111c/pandas-3.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:697b8f7d346c68274b1b93a170a70974cdc7d7354429894d5927c1effdcccd73", size = 10761999, upload-time = "2026-01-21T15:51:46.899Z" }, + { url = "https://files.pythonhosted.org/packages/ec/1a/954eb47736c2b7f7fe6a9d56b0cb6987773c00faa3c6451a43db4beb3254/pandas-3.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8cb3120f0d9467ed95e77f67a75e030b67545bcfa08964e349252d674171def2", size = 10410279, upload-time = "2026-01-21T15:51:48.89Z" }, + { url = "https://files.pythonhosted.org/packages/20/fc/b96f3a5a28b250cd1b366eb0108df2501c0f38314a00847242abab71bb3a/pandas-3.0.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33fd3e6baa72899746b820c31e4b9688c8e1b7864d7aec2de7ab5035c285277a", size = 10330198, upload-time = "2026-01-21T15:51:51.015Z" }, + { url = "https://files.pythonhosted.org/packages/90/b3/d0e2952f103b4fbef1ef22d0c2e314e74fc9064b51cee30890b5e3286ee6/pandas-3.0.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8942e333dc67ceda1095227ad0febb05a3b36535e520154085db632c40ad084", size = 10728513, upload-time = "2026-01-21T15:51:53.387Z" }, + { url = "https://files.pythonhosted.org/packages/76/81/832894f286df828993dc5fd61c63b231b0fb73377e99f6c6c369174cf97e/pandas-3.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:783ac35c4d0fe0effdb0d67161859078618b1b6587a1af15928137525217a721", size = 11345550, upload-time = "2026-01-21T15:51:55.329Z" }, + { url = "https://files.pythonhosted.org/packages/34/a0/ed160a00fb4f37d806406bc0a79a8b62fe67f29d00950f8d16203ff3409b/pandas-3.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:125eb901e233f155b268bbef9abd9afb5819db74f0e677e89a61b246228c71ac", size = 11799386, upload-time = "2026-01-21T15:51:57.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/c8/2ac00d7255252c5e3cf61b35ca92ca25704b0188f7454ca4aec08a33cece/pandas-3.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b86d113b6c109df3ce0ad5abbc259fe86a1bd4adfd4a31a89da42f84f65509bb", size = 10873041, upload-time = "2026-01-21T15:52:00.034Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/a80ac00acbc6b35166b42850e98a4f466e2c0d9c64054161ba9620f95680/pandas-3.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1c39eab3ad38f2d7a249095f0a3d8f8c22cc0f847e98ccf5bbe732b272e2d9fa", size = 9441003, upload-time = "2026-01-21T15:52:02.281Z" }, ] [[package]] @@ -1459,7 +1548,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -1610,11 +1699,11 @@ wheels = [ [[package]] name = "pycparser" -version = "2.23" +version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] @@ -1772,11 +1861,11 @@ wheels = [ [[package]] name = "pyparsing" -version = "3.3.1" +version = "3.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/c1/1d9de9aeaa1b89b0186e5fe23294ff6517fce1bc69149185577cd31016b2/pyparsing-3.3.1.tar.gz", hash = "sha256:47fad0f17ac1e2cad3de3b458570fbc9b03560aa029ed5e16ee5554da9a2251c", size = 1550512, upload-time = "2025-12-23T03:14:04.391Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793, upload-time = "2025-12-23T03:14:02.103Z" }, + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] [[package]] @@ -1822,6 +1911,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1867,15 +1970,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/b4/afd75551a3b910abd1d922dbd45e49e5deeb4d47dc50209ce489ba9844dd/pytimeparse-1.1.8-py2.py3-none-any.whl", hash = "sha256:04b7be6cc8bd9f5647a6325444926c3ac34ee6bc7e69da4367ba282f076036bd", size = 9969, upload-time = "2018-05-18T17:40:41.28Z" }, ] -[[package]] -name = "pytz" -version = "2025.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, -] - [[package]] name = "pyyaml" version = "6.0.3" @@ -2102,28 +2196,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.14.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/0a/1914efb7903174b381ee2ffeebb4253e729de57f114e63595114c8ca451f/ruff-0.14.13.tar.gz", hash = "sha256:83cd6c0763190784b99650a20fec7633c59f6ebe41c5cc9d45ee42749563ad47", size = 6059504, upload-time = "2026-01-15T20:15:16.918Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/ae/0deefbc65ca74b0ab1fd3917f94dc3b398233346a74b8bbb0a916a1a6bf6/ruff-0.14.13-py3-none-linux_armv6l.whl", hash = "sha256:76f62c62cd37c276cb03a275b198c7c15bd1d60c989f944db08a8c1c2dbec18b", size = 13062418, upload-time = "2026-01-15T20:14:50.779Z" }, - { url = "https://files.pythonhosted.org/packages/47/df/5916604faa530a97a3c154c62a81cb6b735c0cb05d1e26d5ad0f0c8ac48a/ruff-0.14.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914a8023ece0528d5cc33f5a684f5f38199bbb566a04815c2c211d8f40b5d0ed", size = 13442344, upload-time = "2026-01-15T20:15:07.94Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f3/e0e694dd69163c3a1671e102aa574a50357536f18a33375050334d5cd517/ruff-0.14.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d24899478c35ebfa730597a4a775d430ad0d5631b8647a3ab368c29b7e7bd063", size = 12354720, upload-time = "2026-01-15T20:15:09.854Z" }, - { url = "https://files.pythonhosted.org/packages/c3/e8/67f5fcbbaee25e8fc3b56cc33e9892eca7ffe09f773c8e5907757a7e3bdb/ruff-0.14.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9aaf3870f14d925bbaf18b8a2347ee0ae7d95a2e490e4d4aea6813ed15ebc80e", size = 12774493, upload-time = "2026-01-15T20:15:20.908Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ce/d2e9cb510870b52a9565d885c0d7668cc050e30fa2c8ac3fb1fda15c083d/ruff-0.14.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac5b7f63dd3b27cc811850f5ffd8fff845b00ad70e60b043aabf8d6ecc304e09", size = 12815174, upload-time = "2026-01-15T20:15:05.74Z" }, - { url = "https://files.pythonhosted.org/packages/88/00/c38e5da58beebcf4fa32d0ddd993b63dfacefd02ab7922614231330845bf/ruff-0.14.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d2b1097750d90ba82ce4ba676e85230a0ed694178ca5e61aa9b459970b3eb9", size = 13680909, upload-time = "2026-01-15T20:15:14.537Z" }, - { url = "https://files.pythonhosted.org/packages/61/61/cd37c9dd5bd0a3099ba79b2a5899ad417d8f3b04038810b0501a80814fd7/ruff-0.14.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d0bf87705acbbcb8d4c24b2d77fbb73d40210a95c3903b443cd9e30824a5032", size = 15144215, upload-time = "2026-01-15T20:15:22.886Z" }, - { url = "https://files.pythonhosted.org/packages/56/8a/85502d7edbf98c2df7b8876f316c0157359165e16cdf98507c65c8d07d3d/ruff-0.14.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3eb5da8e2c9e9f13431032fdcbe7681de9ceda5835efee3269417c13f1fed5c", size = 14706067, upload-time = "2026-01-15T20:14:48.271Z" }, - { url = "https://files.pythonhosted.org/packages/7e/2f/de0df127feb2ee8c1e54354dc1179b4a23798f0866019528c938ba439aca/ruff-0.14.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:642442b42957093811cd8d2140dfadd19c7417030a7a68cf8d51fcdd5f217427", size = 14133916, upload-time = "2026-01-15T20:14:57.357Z" }, - { url = "https://files.pythonhosted.org/packages/0d/77/9b99686bb9fe07a757c82f6f95e555c7a47801a9305576a9c67e0a31d280/ruff-0.14.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4acdf009f32b46f6e8864af19cbf6841eaaed8638e65c8dac845aea0d703c841", size = 13859207, upload-time = "2026-01-15T20:14:55.111Z" }, - { url = "https://files.pythonhosted.org/packages/7d/46/2bdcb34a87a179a4d23022d818c1c236cb40e477faf0d7c9afb6813e5876/ruff-0.14.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:591a7f68860ea4e003917d19b5c4f5ac39ff558f162dc753a2c5de897fd5502c", size = 14043686, upload-time = "2026-01-15T20:14:52.841Z" }, - { url = "https://files.pythonhosted.org/packages/1a/a9/5c6a4f56a0512c691cf143371bcf60505ed0f0860f24a85da8bd123b2bf1/ruff-0.14.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:774c77e841cc6e046fc3e91623ce0903d1cd07e3a36b1a9fe79b81dab3de506b", size = 12663837, upload-time = "2026-01-15T20:15:18.921Z" }, - { url = "https://files.pythonhosted.org/packages/fe/bb/b920016ece7651fa7fcd335d9d199306665486694d4361547ccb19394c44/ruff-0.14.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:61f4e40077a1248436772bb6512db5fc4457fe4c49e7a94ea7c5088655dd21ae", size = 12805867, upload-time = "2026-01-15T20:14:59.272Z" }, - { url = "https://files.pythonhosted.org/packages/7d/b3/0bd909851e5696cd21e32a8fc25727e5f58f1934b3596975503e6e85415c/ruff-0.14.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6d02f1428357fae9e98ac7aa94b7e966fd24151088510d32cf6f902d6c09235e", size = 13208528, upload-time = "2026-01-15T20:15:03.732Z" }, - { url = "https://files.pythonhosted.org/packages/3b/3b/e2d94cb613f6bbd5155a75cbe072813756363eba46a3f2177a1fcd0cd670/ruff-0.14.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e399341472ce15237be0c0ae5fbceca4b04cd9bebab1a2b2c979e015455d8f0c", size = 13929242, upload-time = "2026-01-15T20:15:11.918Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c5/abd840d4132fd51a12f594934af5eba1d5d27298a6f5b5d6c3be45301caf/ruff-0.14.13-py3-none-win32.whl", hash = "sha256:ef720f529aec113968b45dfdb838ac8934e519711da53a0456038a0efecbd680", size = 12919024, upload-time = "2026-01-15T20:14:43.647Z" }, - { url = "https://files.pythonhosted.org/packages/c2/55/6384b0b8ce731b6e2ade2b5449bf07c0e4c31e8a2e68ea65b3bafadcecc5/ruff-0.14.13-py3-none-win_amd64.whl", hash = "sha256:6070bd026e409734b9257e03e3ef18c6e1a216f0435c6751d7a8ec69cb59abef", size = 14097887, upload-time = "2026-01-15T20:15:01.48Z" }, - { url = "https://files.pythonhosted.org/packages/4d/e1/7348090988095e4e39560cfc2f7555b1b2a7357deba19167b600fdf5215d/ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247", size = 13080224, upload-time = "2026-01-15T20:14:45.853Z" }, +version = "0.14.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" }, + { url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" }, + { url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" }, + { url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" }, + { url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" }, + { url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, ] [[package]] @@ -2299,27 +2393,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/78/ba1a4ad403c748fbba8be63b7e774a90e80b67192f6443d624c64fe4aaab/ty-0.0.12.tar.gz", hash = "sha256:cd01810e106c3b652a01b8f784dd21741de9fdc47bd595d02c122a7d5cefeee7", size = 4981303, upload-time = "2026-01-14T22:30:48.537Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/8f/c21314d074dda5fb13d3300fa6733fd0d8ff23ea83a721818740665b6314/ty-0.0.12-py3-none-linux_armv6l.whl", hash = "sha256:eb9da1e2c68bd754e090eab39ed65edf95168d36cbeb43ff2bd9f86b4edd56d1", size = 9614164, upload-time = "2026-01-14T22:30:44.016Z" }, - { url = "https://files.pythonhosted.org/packages/09/28/f8a4d944d13519d70c486e8f96d6fa95647ac2aa94432e97d5cfec1f42f6/ty-0.0.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c181f42aa19b0ed7f1b0c2d559980b1f1d77cc09419f51c8321c7ddf67758853", size = 9542337, upload-time = "2026-01-14T22:30:05.687Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9c/f576e360441de7a8201daa6dc4ebc362853bc5305e059cceeb02ebdd9a48/ty-0.0.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1f829e1eecd39c3e1b032149db7ae6a3284f72fc36b42436e65243a9ed1173db", size = 8909582, upload-time = "2026-01-14T22:30:46.089Z" }, - { url = "https://files.pythonhosted.org/packages/d6/13/0898e494032a5d8af3060733d12929e3e7716db6c75eac63fa125730a3e7/ty-0.0.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f45162e7826e1789cf3374627883cdeb0d56b82473a0771923e4572928e90be3", size = 9384932, upload-time = "2026-01-14T22:30:13.769Z" }, - { url = "https://files.pythonhosted.org/packages/e4/1a/b35b6c697008a11d4cedfd34d9672db2f0a0621ec80ece109e13fca4dfef/ty-0.0.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d11fec40b269bec01e751b2337d1c7ffa959a2c2090a950d7e21c2792442cccd", size = 9453140, upload-time = "2026-01-14T22:30:11.131Z" }, - { url = "https://files.pythonhosted.org/packages/dd/1e/71c9edbc79a3c88a0711324458f29c7dbf6c23452c6e760dc25725483064/ty-0.0.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09d99e37e761a4d2651ad9d5a610d11235fbcbf35dc6d4bc04abf54e7cf894f1", size = 9960680, upload-time = "2026-01-14T22:30:33.621Z" }, - { url = "https://files.pythonhosted.org/packages/0e/75/39375129f62dd22f6ad5a99cd2a42fd27d8b91b235ce2db86875cdad397d/ty-0.0.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d9ca0cdb17bd37397da7b16a7cd23423fc65c3f9691e453ad46c723d121225a1", size = 10904518, upload-time = "2026-01-14T22:30:08.464Z" }, - { url = "https://files.pythonhosted.org/packages/32/5e/26c6d88fafa11a9d31ca9f4d12989f57782ec61e7291d4802d685b5be118/ty-0.0.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcf2757b905e7eddb7e456140066335b18eb68b634a9f72d6f54a427ab042c64", size = 10525001, upload-time = "2026-01-14T22:30:16.454Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a5/2f0b91894af13187110f9ad7ee926d86e4e6efa755c9c88a820ed7f84c85/ty-0.0.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:00cf34c1ebe1147efeda3021a1064baa222c18cdac114b7b050bbe42deb4ca80", size = 10307103, upload-time = "2026-01-14T22:30:41.221Z" }, - { url = "https://files.pythonhosted.org/packages/4b/77/13d0410827e4bc713ebb7fdaf6b3590b37dcb1b82e0a81717b65548f2442/ty-0.0.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb3a655bd869352e9a22938d707631ac9fbca1016242b1f6d132d78f347c851", size = 10072737, upload-time = "2026-01-14T22:30:51.783Z" }, - { url = "https://files.pythonhosted.org/packages/e1/dd/fc36d8bac806c74cf04b4ca735bca14d19967ca84d88f31e121767880df1/ty-0.0.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4658e282c7cb82be304052f8f64f9925f23c3c4f90eeeb32663c74c4b095d7ba", size = 9368726, upload-time = "2026-01-14T22:30:18.683Z" }, - { url = "https://files.pythonhosted.org/packages/54/70/9e8e461647550f83e2fe54bc632ccbdc17a4909644783cdbdd17f7296059/ty-0.0.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c167d838eaaa06e03bb66a517f75296b643d950fbd93c1d1686a187e5a8dbd1f", size = 9454704, upload-time = "2026-01-14T22:30:22.759Z" }, - { url = "https://files.pythonhosted.org/packages/04/9b/6292cf7c14a0efeca0539cf7d78f453beff0475cb039fbea0eb5d07d343d/ty-0.0.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2956e0c9ab7023533b461d8a0e6b2ea7b78e01a8dde0688e8234d0fce10c4c1c", size = 9649829, upload-time = "2026-01-14T22:30:31.234Z" }, - { url = "https://files.pythonhosted.org/packages/49/bd/472a5d2013371e4870886cff791c94abdf0b92d43d305dd0f8e06b6ff719/ty-0.0.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c6a3fd7479580009f21002f3828320621d8a82d53b7ba36993234e3ccad58c8", size = 10162814, upload-time = "2026-01-14T22:30:36.174Z" }, - { url = "https://files.pythonhosted.org/packages/31/e9/2ecbe56826759845a7c21d80aa28187865ea62bc9757b056f6cbc06f78ed/ty-0.0.12-py3-none-win32.whl", hash = "sha256:a91c24fd75c0f1796d8ede9083e2c0ec96f106dbda73a09fe3135e075d31f742", size = 9140115, upload-time = "2026-01-14T22:30:38.903Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6d/d9531eff35a5c0ec9dbc10231fac21f9dd6504814048e81d6ce1c84dc566/ty-0.0.12-py3-none-win_amd64.whl", hash = "sha256:df151894be55c22d47068b0f3b484aff9e638761e2267e115d515fcc9c5b4a4b", size = 9884532, upload-time = "2026-01-14T22:30:25.112Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f3/20b49e75967023b123a221134548ad7000f9429f13fdcdda115b4c26305f/ty-0.0.12-py3-none-win_arm64.whl", hash = "sha256:cea99d334b05629de937ce52f43278acf155d3a316ad6a35356635f886be20ea", size = 9313974, upload-time = "2026-01-14T22:30:27.44Z" }, +version = "0.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/dc/b607f00916f5a7c52860b84a66dc17bc6988e8445e96b1d6e175a3837397/ty-0.0.13.tar.gz", hash = "sha256:7a1d135a400ca076407ea30012d1f75419634160ed3b9cad96607bf2956b23b3", size = 4999183, upload-time = "2026-01-21T13:21:16.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/df/3632f1918f4c0a33184f107efc5d436ab6da147fd3d3b94b3af6461efbf4/ty-0.0.13-py3-none-linux_armv6l.whl", hash = "sha256:1b2b8e02697c3a94c722957d712a0615bcc317c9b9497be116ef746615d892f2", size = 9993501, upload-time = "2026-01-21T13:21:26.628Z" }, + { url = "https://files.pythonhosted.org/packages/92/87/6a473ced5ac280c6ce5b1627c71a8a695c64481b99aabc798718376a441e/ty-0.0.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f15cdb8e233e2b5adfce673bb21f4c5e8eaf3334842f7eea3c70ac6fda8c1de5", size = 9860986, upload-time = "2026-01-21T13:21:24.425Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9b/d89ae375cf0a7cd9360e1164ce017f8c753759be63b6a11ed4c944abe8c6/ty-0.0.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0819e89ac9f0d8af7a062837ce197f0461fee2fc14fd07e2c368780d3a397b73", size = 9350748, upload-time = "2026-01-21T13:21:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a6/9ad58518056fab344b20c0bb2c1911936ebe195318e8acc3bc45ac1c6b6b/ty-0.0.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1de79f481084b7cc7a202ba0d7a75e10970d10ffa4f025b23f2e6b7324b74886", size = 9849884, upload-time = "2026-01-21T13:21:21.886Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c3/8add69095fa179f523d9e9afcc15a00818af0a37f2b237a9b59bc0046c34/ty-0.0.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4fb2154cff7c6e95d46bfaba283c60642616f20d73e5f96d0c89c269f3e1bcec", size = 9822975, upload-time = "2026-01-21T13:21:14.292Z" }, + { url = "https://files.pythonhosted.org/packages/a4/05/4c0927c68a0a6d43fb02f3f0b6c19c64e3461dc8ed6c404dde0efb8058f7/ty-0.0.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00be58d89337c27968a20d58ca553458608c5b634170e2bec82824c2e4cf4d96", size = 10294045, upload-time = "2026-01-21T13:21:30.505Z" }, + { url = "https://files.pythonhosted.org/packages/b4/86/6dc190838aba967557fe0bfd494c595d00b5081315a98aaf60c0e632aaeb/ty-0.0.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72435eade1fa58c6218abb4340f43a6c3ff856ae2dc5722a247d3a6dd32e9737", size = 10916460, upload-time = "2026-01-21T13:21:07.788Z" }, + { url = "https://files.pythonhosted.org/packages/04/40/9ead96b7c122e1109dfcd11671184c3506996bf6a649306ec427e81d9544/ty-0.0.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:77a548742ee8f621d718159e7027c3b555051d096a49bb580249a6c5fc86c271", size = 10597154, upload-time = "2026-01-21T13:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7d/e832a2c081d2be845dc6972d0c7998914d168ccbc0b9c86794419ab7376e/ty-0.0.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da067c57c289b7cf914669704b552b6207c2cc7f50da4118c3e12388642e6b3f", size = 10410710, upload-time = "2026-01-21T13:21:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/31/e3/898be3a96237a32f05c4c29b43594dc3b46e0eedfe8243058e46153b324f/ty-0.0.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d1b50a01fffa140417fca5a24b658fbe0734074a095d5b6f0552484724474343", size = 9826299, upload-time = "2026-01-21T13:21:00.845Z" }, + { url = "https://files.pythonhosted.org/packages/bb/eb/db2d852ce0ed742505ff18ee10d7d252f3acfd6fc60eca7e9c7a0288a6d8/ty-0.0.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0f33c46f52e5e9378378eca0d8059f026f3c8073ace02f7f2e8d079ddfe5207e", size = 9831610, upload-time = "2026-01-21T13:21:05.842Z" }, + { url = "https://files.pythonhosted.org/packages/9e/61/149f59c8abaddcbcbb0bd13b89c7741ae1c637823c5cf92ed2c644fcadef/ty-0.0.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:168eda24d9a0b202cf3758c2962cc295878842042b7eca9ed2965259f59ce9f2", size = 9978885, upload-time = "2026-01-21T13:21:10.306Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/026d4e4af60a80918a8d73d2c42b8262dd43ab2fa7b28d9743004cb88d57/ty-0.0.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d4917678b95dc8cb399cc459fab568ba8d5f0f33b7a94bf840d9733043c43f29", size = 10506453, upload-time = "2026-01-21T13:20:56.633Z" }, + { url = "https://files.pythonhosted.org/packages/63/06/8932833a4eca2df49c997a29afb26721612de8078ae79074c8fe87e17516/ty-0.0.13-py3-none-win32.whl", hash = "sha256:c1f2ec40daa405508b053e5b8e440fbae5fdb85c69c9ab0ee078f8bc00eeec3d", size = 9433482, upload-time = "2026-01-21T13:20:58.717Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fd/e8d972d1a69df25c2cecb20ea50e49ad5f27a06f55f1f5f399a563e71645/ty-0.0.13-py3-none-win_amd64.whl", hash = "sha256:8b7b1ab9f187affbceff89d51076038363b14113be29bda2ddfa17116de1d476", size = 10319156, upload-time = "2026-01-21T13:21:03.266Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c2/05fdd64ac003a560d4fbd1faa7d9a31d75df8f901675e5bed1ee2ceeff87/ty-0.0.13-py3-none-win_arm64.whl", hash = "sha256:1c9630333497c77bb9bcabba42971b96ee1f36c601dd3dcac66b4134f9fa38f0", size = 9808316, upload-time = "2026-01-21T13:20:54.053Z" }, ] [[package]] @@ -2349,11 +2442,11 @@ wheels = [ [[package]] name = "types-python-dateutil" -version = "2.9.0.20251115" +version = "2.9.0.20260124" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/36/06d01fb52c0d57e9ad0c237654990920fa41195e4b3d640830dabf9eeb2f/types_python_dateutil-2.9.0.20251115.tar.gz", hash = "sha256:8a47f2c3920f52a994056b8786309b43143faa5a64d4cbb2722d6addabdf1a58", size = 16363, upload-time = "2025-11-15T03:00:13.717Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/41/4f8eb1ce08688a9e3e23709ed07089ccdeaf95b93745bfb768c6da71197d/types_python_dateutil-2.9.0.20260124.tar.gz", hash = "sha256:7d2db9f860820c30e5b8152bfe78dbdf795f7d1c6176057424e8b3fdd1f581af", size = 16596, upload-time = "2026-01-24T03:18:42.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/0b/56961d3ba517ed0df9b3a27bfda6514f3d01b28d499d1bce9068cfe4edd1/types_python_dateutil-2.9.0.20251115-py3-none-any.whl", hash = "sha256:9cf9c1c582019753b8639a081deefd7e044b9fa36bd8217f565c6c4e36ee0624", size = 18251, upload-time = "2025-11-15T03:00:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/aa5e3f4103cc8b1dcf92432415dde75d70021d634ecfd95b2e913cf43e17/types_python_dateutil-2.9.0.20260124-py3-none-any.whl", hash = "sha256:f802977ae08bf2260142e7ca1ab9d4403772a254409f7bbdf652229997124951", size = 18266, upload-time = "2026-01-24T03:18:42.155Z" }, ] [[package]] @@ -2392,11 +2485,11 @@ wheels = [ [[package]] name = "types-setuptools" -version = "80.9.0.20251223" +version = "80.10.0.20260124" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/00/07/d1b605230730990de20477150191d6dccf6aecc037da94c9960a5d563bc8/types_setuptools-80.9.0.20251223.tar.gz", hash = "sha256:d3411059ae2f5f03985217d86ac6084efea2c9e9cacd5f0869ef950f308169b2", size = 42420, upload-time = "2025-12-23T03:18:26.752Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/7e/116539b9610585e34771611e33c88a4c706491fa3565500f5a63139f8731/types_setuptools-80.10.0.20260124.tar.gz", hash = "sha256:1b86d9f0368858663276a0cbe5fe5a9722caf94b5acde8aba0399a6e90680f20", size = 43299, upload-time = "2026-01-24T03:18:39.527Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/5c/b8877da94012dbc6643e4eeca22bca9b99b295be05d161f8a403ae9387c0/types_setuptools-80.9.0.20251223-py3-none-any.whl", hash = "sha256:1b36db79d724c2287d83dc052cf887b47c0da6a2fff044378be0b019545f56e6", size = 64318, upload-time = "2025-12-23T03:18:25.868Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7f/016dc5cc718ec6ccaa84fb73ed409ef1c261793fd5e637cdfaa18beb40a9/types_setuptools-80.10.0.20260124-py3-none-any.whl", hash = "sha256:efed7e044f01adb9c2806c7a8e1b6aa3656b8e382379b53d5f26ee3db24d4c01", size = 64333, upload-time = "2026-01-24T03:18:38.344Z" }, ] [[package]] @@ -2488,11 +2581,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/42/68/f723c30e9fa0a7932 [[package]] name = "wcwidth" -version = "0.2.14" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" } +sdist = { url = "https://files.pythonhosted.org/packages/98/0a/dc5110cc99c39df65bac29229c4b637a8304e0914850348d98974c8ecfff/wcwidth-0.4.0.tar.gz", hash = "sha256:46478e02cf7149ba150fb93c39880623ee7e5181c64eda167b6a1de51b7a7ba1", size = 237625, upload-time = "2026-01-26T02:35:58.844Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f6/da704c5e77281d71723bffbd926b754c0efd57cbcd02e74c2ca374c14cef/wcwidth-0.4.0-py3-none-any.whl", hash = "sha256:8af2c81174b3aa17adf05058c543c267e4e5b6767a28e31a673a658c1d766783", size = 88216, upload-time = "2026-01-26T02:35:57.461Z" }, ] [[package]] From 32ce887b4baffb11b7414df0467d5a2b0928b859 Mon Sep 17 00:00:00 2001 From: Roman Pszonka Date: Mon, 26 Jan 2026 23:14:07 +0000 Subject: [PATCH 5/6] update scenario with AMQP --- .../scenarios/test_traffic_and_telemetry.py | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/openutm_verification/scenarios/test_traffic_and_telemetry.py b/src/openutm_verification/scenarios/test_traffic_and_telemetry.py index f2acbea..4bbc97f 100644 --- a/src/openutm_verification/scenarios/test_traffic_and_telemetry.py +++ b/src/openutm_verification/scenarios/test_traffic_and_telemetry.py @@ -3,6 +3,7 @@ from loguru import logger from openutm_verification.core.clients.air_traffic.blue_sky_client import BlueSkyClient +from openutm_verification.core.clients.amqp import AMQPClient from openutm_verification.core.clients.flight_blender.flight_blender_client import ( FlightBlenderClient, ) @@ -14,15 +15,37 @@ async def traffic_and_telemetry_sim( fb_client: FlightBlenderClient, blue_sky_client: BlueSkyClient, + amqp_client: AMQPClient, ) -> None: - """Runs a scenario with simulated air traffic and drone telemetry concurrently.""" + """Runs a scenario with simulated air traffic and drone telemetry concurrently. + + This scenario also monitors AMQP events for operational messages + related to the flight declaration. + """ logger.info("Starting Traffic and Telemetry simulation scenario") + # Check AMQP connection (optional, will log warning if not configured) + connection_result = await amqp_client.check_connection() + connection_status = connection_result.result or {} + if connection_status.get("connected"): + logger.info(f"AMQP connected to {connection_status.get('url_host')}") + else: + logger.warning(f"AMQP not available: {connection_status.get('error')}") + # Explicit cleanup before starting await fb_client.cleanup_flight_declarations() # Setup Flight Declaration async with fb_client.create_flight_declaration(): + # Get the flight declaration ID for AMQP routing key + flight_declaration_id = fb_client.latest_flight_declaration_id + + # Start AMQP monitoring for flight events (background) + amqp_task = None + if connection_status.get("connected") and flight_declaration_id: + amqp_task = asyncio.create_task(amqp_client.start_queue_monitor(routing_key=flight_declaration_id, duration=60)) + logger.info(f"AMQP queue monitor started for flight declaration {flight_declaration_id}") + # Activate Operation await fb_client.update_operation_state(OperationState.ACTIVATED) @@ -50,4 +73,13 @@ async def traffic_and_telemetry_sim( # End Operation await fb_client.update_operation_state(OperationState.ENDED) + # Stop AMQP monitoring and get collected messages + if amqp_task: + await amqp_client.stop_queue_monitor() + messages_result = await amqp_client.get_received_messages(routing_key_filter=flight_declaration_id) + messages = messages_result.result or [] + logger.info(f"Collected {len(messages)} AMQP messages for flight declaration") + for msg in messages: + logger.debug(f"AMQP message: {msg}") + await fb_client.teardown_flight_declaration() From c4f0f731c702b3db14cba93958428f47086ff2b3 Mon Sep 17 00:00:00 2001 From: Roman Pszonka Date: Tue, 27 Jan 2026 00:21:57 +0000 Subject: [PATCH 6/6] layout fix --- .../ScenarioEditor/ConfigEditor.tsx | 2 +- .../ScenarioEditor/ScenarioInfoPanel.tsx | 68 +++++++++---------- .../src/components/ScenarioEditor/Toolbox.tsx | 14 ++-- 3 files changed, 42 insertions(+), 42 deletions(-) diff --git a/web-editor/src/components/ScenarioEditor/ConfigEditor.tsx b/web-editor/src/components/ScenarioEditor/ConfigEditor.tsx index d9b85af..469a0b8 100644 --- a/web-editor/src/components/ScenarioEditor/ConfigEditor.tsx +++ b/web-editor/src/components/ScenarioEditor/ConfigEditor.tsx @@ -9,7 +9,7 @@ interface ConfigEditorProps { } export const ConfigEditor: React.FC = ({ config, onUpdateConfig }) => { - const [expandedSections, setExpandedSections] = useState>(new Set(['flight_blender'])); + const [expandedSections, setExpandedSections] = useState>(new Set()); const toggleSection = (section: string) => { setExpandedSections(prev => { diff --git a/web-editor/src/components/ScenarioEditor/ScenarioInfoPanel.tsx b/web-editor/src/components/ScenarioEditor/ScenarioInfoPanel.tsx index 9d2ca1e..6075fa8 100644 --- a/web-editor/src/components/ScenarioEditor/ScenarioInfoPanel.tsx +++ b/web-editor/src/components/ScenarioEditor/ScenarioInfoPanel.tsx @@ -55,40 +55,6 @@ export const ScenarioInfoPanel = ({ name, description, config, onUpdateName, onU )}
-
- - onUpdateName(e.target.value)} - placeholder="e.g. valid_flight_auth" - /> -
- Currently saving as: {name || "new_scenario"}.yaml -
-
- -
- -