From 6a5be99653a5fe986d653c258c1be0f4d7f1a416 Mon Sep 17 00:00:00 2001 From: Roman Pszonka Date: Sun, 23 Nov 2025 14:48:16 +0000 Subject: [PATCH 01/20] initial gui workflow editor --- .pre-commit-config.yaml | 14 + pyproject.toml | 4 +- scripts/generate_editor_metadata.py | 220 + src/openutm_verification/server/main.py | 39 + src/openutm_verification/server/runner.py | 329 ++ web-editor/.gitignore | 24 + web-editor/README.md | 47 + web-editor/eslint.config.js | 23 + web-editor/index.html | 16 + web-editor/package-lock.json | 4927 +++++++++++++++++ web-editor/package.json | 39 + web-editor/public/vite.svg | 1 + web-editor/src/App.tsx | 11 + web-editor/src/components/ScenarioEditor.tsx | 329 ++ .../components/ScenarioEditor/CustomNode.tsx | 37 + .../src/components/ScenarioEditor/Header.tsx | 71 + .../ScenarioEditor/PropertiesPanel.tsx | 194 + .../components/ScenarioEditor/ResultPanel.tsx | 63 + .../src/components/ScenarioEditor/Toolbox.tsx | 81 + .../__tests__/CustomNode.test.tsx | 78 + .../ScenarioEditor/__tests__/Header.test.tsx | 72 + .../__tests__/PropertiesPanel.test.tsx | 55 + .../__tests__/ResultPanel.test.tsx | 60 + .../ScenarioEditor/__tests__/Toolbox.test.tsx | 78 + .../__tests__/ScenarioEditor.test.tsx | 78 + web-editor/src/data/operations.json | 445 ++ .../hooks/__tests__/useScenarioFile.test.ts | 96 + .../hooks/__tests__/useScenarioGraph.test.tsx | 43 + .../hooks/__tests__/useScenarioRunner.test.ts | 77 + .../hooks/__tests__/useSidebarResize.test.ts | 85 + web-editor/src/hooks/useScenarioFile.ts | 87 + web-editor/src/hooks/useScenarioGraph.ts | 136 + web-editor/src/hooks/useScenarioRunner.ts | 95 + web-editor/src/hooks/useSidebarResize.ts | 34 + web-editor/src/main.tsx | 11 + web-editor/src/styles/Button.module.css | 58 + web-editor/src/styles/EditorLayout.module.css | 60 + web-editor/src/styles/Header.module.css | 47 + web-editor/src/styles/Node.module.css | 66 + .../src/styles/PropertiesPanel.module.css | 148 + web-editor/src/styles/ResultPanel.module.css | 55 + web-editor/src/styles/SidebarPanel.module.css | 148 + web-editor/src/styles/Toolbox.module.css | 61 + web-editor/src/styles/global.css | 58 + web-editor/src/styles/theme.css | 63 + web-editor/src/test/setup.ts | 1 + web-editor/src/types/scenario.ts | 48 + web-editor/tsconfig.app.json | 28 + web-editor/tsconfig.json | 7 + web-editor/tsconfig.node.json | 26 + web-editor/vite.config.ts | 13 + 51 files changed, 8885 insertions(+), 1 deletion(-) create mode 100644 scripts/generate_editor_metadata.py create mode 100644 src/openutm_verification/server/main.py create mode 100644 src/openutm_verification/server/runner.py create mode 100644 web-editor/.gitignore create mode 100644 web-editor/README.md create mode 100644 web-editor/eslint.config.js create mode 100644 web-editor/index.html create mode 100644 web-editor/package-lock.json create mode 100644 web-editor/package.json create mode 100644 web-editor/public/vite.svg create mode 100644 web-editor/src/App.tsx create mode 100644 web-editor/src/components/ScenarioEditor.tsx create mode 100644 web-editor/src/components/ScenarioEditor/CustomNode.tsx create mode 100644 web-editor/src/components/ScenarioEditor/Header.tsx create mode 100644 web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx create mode 100644 web-editor/src/components/ScenarioEditor/ResultPanel.tsx create mode 100644 web-editor/src/components/ScenarioEditor/Toolbox.tsx create mode 100644 web-editor/src/components/ScenarioEditor/__tests__/CustomNode.test.tsx create mode 100644 web-editor/src/components/ScenarioEditor/__tests__/Header.test.tsx create mode 100644 web-editor/src/components/ScenarioEditor/__tests__/PropertiesPanel.test.tsx create mode 100644 web-editor/src/components/ScenarioEditor/__tests__/ResultPanel.test.tsx create mode 100644 web-editor/src/components/ScenarioEditor/__tests__/Toolbox.test.tsx create mode 100644 web-editor/src/components/__tests__/ScenarioEditor.test.tsx create mode 100644 web-editor/src/data/operations.json create mode 100644 web-editor/src/hooks/__tests__/useScenarioFile.test.ts create mode 100644 web-editor/src/hooks/__tests__/useScenarioGraph.test.tsx create mode 100644 web-editor/src/hooks/__tests__/useScenarioRunner.test.ts create mode 100644 web-editor/src/hooks/__tests__/useSidebarResize.test.ts create mode 100644 web-editor/src/hooks/useScenarioFile.ts create mode 100644 web-editor/src/hooks/useScenarioGraph.ts create mode 100644 web-editor/src/hooks/useScenarioRunner.ts create mode 100644 web-editor/src/hooks/useSidebarResize.ts create mode 100644 web-editor/src/main.tsx create mode 100644 web-editor/src/styles/Button.module.css create mode 100644 web-editor/src/styles/EditorLayout.module.css create mode 100644 web-editor/src/styles/Header.module.css create mode 100644 web-editor/src/styles/Node.module.css create mode 100644 web-editor/src/styles/PropertiesPanel.module.css create mode 100644 web-editor/src/styles/ResultPanel.module.css create mode 100644 web-editor/src/styles/SidebarPanel.module.css create mode 100644 web-editor/src/styles/Toolbox.module.css create mode 100644 web-editor/src/styles/global.css create mode 100644 web-editor/src/styles/theme.css create mode 100644 web-editor/src/test/setup.ts create mode 100644 web-editor/src/types/scenario.ts create mode 100644 web-editor/tsconfig.app.json create mode 100644 web-editor/tsconfig.json create mode 100644 web-editor/tsconfig.node.json create mode 100644 web-editor/vite.config.ts diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 53c5224..6c2cae6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -48,3 +48,17 @@ repos: language: system types: [python] pass_filenames: false + - id: eslint-web-editor + name: eslint web-editor + entry: bash -c 'cd web-editor && npm run lint' + language: system + files: ^web-editor/ + pass_filenames: false + types: [file] + - id: tsc-web-editor + name: tsc web-editor + entry: bash -c 'cd web-editor && npx tsc --noEmit' + language: system + files: ^web-editor/ + pass_filenames: false + types: [file] diff --git a/pyproject.toml b/pyproject.toml index ede75b8..6e869f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,8 @@ dependencies = [ "websocket-client==1.9.0", "markdown>=3.10", "uas-standards==4.2.0", + "fastapi>=0.121.3", + "uvicorn>=0.38.0", ] [project.scripts] @@ -141,7 +143,7 @@ docstring-code-line-length = "dynamic" [tool.codespell] ignore-words = ".codespellignore" quiet-level = 2 -skip = "uv.lock" +skip = ["uv.lock", "*/package-lock.json"] [tool.pylint] load-plugins = "pylint_pytest" diff --git a/scripts/generate_editor_metadata.py b/scripts/generate_editor_metadata.py new file mode 100644 index 0000000..e1a749e --- /dev/null +++ b/scripts/generate_editor_metadata.py @@ -0,0 +1,220 @@ +import ast +import json +import os +from pathlib import Path +from typing import Any, Dict, List, Optional + + +def parse_type_annotation(annotation: Any) -> str: + """Helper to convert AST type annotation to string representation.""" + if annotation is None: + return "Any" + if isinstance(annotation, ast.Name): + return annotation.id + elif isinstance(annotation, ast.Subscript): + value = parse_type_annotation(annotation.value) + slice_val = parse_type_annotation(annotation.slice) + return f"{value}[{slice_val}]" + elif isinstance(annotation, ast.Constant): + return str(annotation.value) + elif isinstance(annotation, ast.Attribute): + return annotation.attr + elif isinstance(annotation, ast.BinOp): + # Handle Union types like str | int + left = parse_type_annotation(annotation.left) + right = parse_type_annotation(annotation.right) + return f"{left} | {right}" + return "Any" + + +def parse_default_value(node: Any) -> Any: + """Helper to convert AST default value to python object.""" + if node is None: + return None + if isinstance(node, ast.Constant): + return node.value + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + return f"{parse_default_value(node.value)}.{node.attr}" + # Handle other types if necessary + return str(node) + + +def get_step_name_from_decorator(decorator: Any) -> Optional[str]: + if isinstance(decorator, ast.Call): + func = decorator.func + is_scenario_step = False + if isinstance(func, ast.Name) and func.id == "scenario_step": + is_scenario_step = True + elif isinstance(func, ast.Attribute) and func.attr == "scenario_step": + is_scenario_step = True + + if is_scenario_step and decorator.args: + arg = decorator.args[0] + if isinstance(arg, ast.Constant): + return str(arg.value) + # Handle older python versions or string literals + elif isinstance(arg, ast.Str): + return str(arg.s) + elif isinstance(decorator, ast.Name) and decorator.id == "scenario_step": + return "Scenario Step" + return None + + +def extract_enums(file_path: Path) -> Dict[str, List[Dict[str, Any]]]: + """Extract Enum definitions from a file.""" + enums = {} + if not file_path.exists(): + return enums + + with open(file_path, "r", encoding="utf-8") as f: + try: + tree = ast.parse(f.read(), filename=str(file_path)) + except SyntaxError: + return enums + + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + is_enum = False + for base in node.bases: + if isinstance(base, ast.Name) and base.id == "Enum": + is_enum = True + elif isinstance(base, ast.Attribute) and base.attr == "Enum": + is_enum = True + + if is_enum: + values = [] + for item in node.body: + if isinstance(item, ast.Assign): + for target in item.targets: + if isinstance(target, ast.Name): + # We found an enum member + name = target.id + value = None + if isinstance(item.value, ast.Constant): + value = item.value.value + elif isinstance(item.value, ast.Str): # Python < 3.8 + value = item.value.s + elif isinstance(item.value, ast.Num): # Python < 3.8 + value = item.value.n + + if value is not None: + values.append({"name": name, "value": value}) + enums[node.name] = values + return enums + + +def extract_args(function_node: ast.FunctionDef, known_enums: Dict[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]: + args = [] + + # Calculate defaults mapping + defaults = function_node.args.defaults + # defaults correspond to the last n arguments + args_with_defaults = function_node.args.args[-len(defaults) :] if defaults else [] + default_map = {} + for arg, default in zip(args_with_defaults, defaults): + default_map[arg.arg] = parse_default_value(default) + + for arg in function_node.args.args: + if arg.arg == "self": + continue + + arg_type = parse_type_annotation(arg.annotation) + arg_data = {"name": arg.arg, "type": arg_type} + + if arg.arg in default_map: + arg_data["default"] = default_map[arg.arg] + + if arg_type in known_enums: + arg_data["options"] = known_enums[arg_type] + arg_data["isEnum"] = True + + args.append(arg_data) + return args + + +def process_class_node(class_node: ast.ClassDef, file_path_str: str, known_enums: Dict[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]: + steps = [] + class_name = class_node.name + for item in class_node.body: + if isinstance(item, ast.FunctionDef): + step_name = None + # Check decorators + for decorator in item.decorator_list: + step_name = get_step_name_from_decorator(decorator) + if step_name: + break + + if step_name: + args = extract_args(item, known_enums) + docstring = ast.get_docstring(item) + + steps.append( + { + "id": f"{class_name}.{item.name}", + "name": step_name, + "functionName": item.name, + "className": class_name, + "description": docstring, + "parameters": args, + "filePath": file_path_str, + } + ) + return steps + + +def extract_scenario_steps(file_path: Path, project_root: Path, known_enums: Dict[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]: + with open(file_path, "r", encoding="utf-8") as f: + try: + tree = ast.parse(f.read(), filename=str(file_path)) + except SyntaxError: + print(f"Syntax error in {file_path}") + return [] + + try: + relative_path = file_path.relative_to(project_root) + except ValueError: + relative_path = file_path + + steps = [] + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + steps.extend(process_class_node(node, str(relative_path), known_enums)) + return steps + + +def main(): + # Resolve paths relative to the script location + script_dir = Path(__file__).parent + project_root = script_dir.parent + + base_dir = project_root / "src/openutm_verification/core/clients" + models_file = project_root / "src/openutm_verification/models.py" + output_file = project_root / "web-editor/src/data/operations.json" + + # Extract Enums first + print(f"Extracting enums from {models_file}...") + known_enums = extract_enums(models_file) + print(f"Found enums: {list(known_enums.keys())}") + + all_steps = [] + + for root, _, files in os.walk(base_dir): + for file in files: + if file.endswith(".py") and not file.startswith("__"): + file_path = Path(root) / file + print(f"Processing {file_path}...") + steps = extract_scenario_steps(file_path, project_root, known_enums) + all_steps.extend(steps) + + print(f"Found {len(all_steps)} scenario steps.") + + with open(output_file, "w", encoding="utf-8") as f: + json.dump(all_steps, f, indent=2) + + print(f"Written metadata to {output_file}") + + +if __name__ == "__main__": + main() diff --git a/src/openutm_verification/server/main.py b/src/openutm_verification/server/main.py new file mode 100644 index 0000000..9208924 --- /dev/null +++ b/src/openutm_verification/server/main.py @@ -0,0 +1,39 @@ +from typing import Any, Dict, List + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +from openutm_verification.server.runner import DynamicRunner, ScenarioDefinition + +app = FastAPI() + +# Configure CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Allow all origins for development + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/") +async def root(): + return {"message": "OpenUTM Verification API is running"} + + +@app.post("/run-scenario") +async def run_scenario(scenario: ScenarioDefinition): + runner = DynamicRunner() + try: + results = runner.run_scenario(scenario) + return {"status": "completed", "results": results} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8989) diff --git a/src/openutm_verification/server/runner.py b/src/openutm_verification/server/runner.py new file mode 100644 index 0000000..bd1bc6f --- /dev/null +++ b/src/openutm_verification/server/runner.py @@ -0,0 +1,329 @@ +import inspect +import json +from contextlib import ExitStack +from pathlib import Path +from typing import Any, Dict, List, Type, cast + +import yaml +from loguru import logger +from pydantic import BaseModel + +# Import dependencies to ensure decorators run +import openutm_verification.core.execution.dependencies # noqa: F401 +from openutm_verification.core.clients.air_traffic.air_traffic_client import AirTrafficClient +from openutm_verification.core.clients.flight_blender.flight_blender_client import FlightBlenderClient +from openutm_verification.core.clients.opensky.opensky_client import OpenSkyClient +from openutm_verification.core.execution.config_models import AppConfig, ConfigProxy +from openutm_verification.core.execution.dependency_resolution import DependencyResolver +from openutm_verification.core.reporting.reporting_models import Status, StepResult +from openutm_verification.models import OperationState +from openutm_verification.simulator.flight_declaration import FlightDeclarationGenerator +from openutm_verification.simulator.geo_json_telemetry import GeoJSONFlightsSimulator +from openutm_verification.simulator.models.flight_data_types import GeoJSONFlightsSimulatorConfiguration + + +class StepDefinition(BaseModel): + id: str | None = None + className: str + functionName: str + parameters: Dict[str, Any] + + +class ScenarioDefinition(BaseModel): + steps: List[StepDefinition] + + +class DynamicRunner: + def __init__(self, config_path: str = "config/default.yaml"): + self.config_path = Path(config_path) + self.config = self._load_config() + self.client_map: Dict[str, Type] = { + "FlightBlenderClient": FlightBlenderClient, + "OpenSkyClient": OpenSkyClient, + "AirTrafficClient": AirTrafficClient, + } + + def _load_config(self) -> AppConfig: + if not self.config_path.exists(): + # Try to find it relative to project root if we are in src/... + pass + + # Fallback to absolute path if needed, but for now assume running from root + with open(self.config_path, "r", encoding="utf-8") as f: + config_data = yaml.safe_load(f) + + config = AppConfig.model_validate(config_data) + project_root = self.config_path.parent.parent + config.resolve_paths(project_root) + + # Only initialize ConfigProxy if it hasn't been initialized yet + try: + ConfigProxy.initialize(config) + except TypeError: + # If already initialized, we can optionally override it or just ignore + # For now, let's override to ensure we have the latest config + ConfigProxy.override(config) + + return config + + def _generate_data(self): + # Hardcoded for now, could be parameterized + config_path = Path("config/bern/flight_declaration.json") + + # Generate Flight Declaration + generator = FlightDeclarationGenerator(bounds_path=config_path) + flight_declaration = generator.generate() + + # Generate Telemetry + with open(config_path, "r", encoding="utf-8") as f: + bounds = json.load(f) + + # Create a simple LineString feature from min/min to max/max + flight_path_geojson = { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": {"type": "LineString", "coordinates": [[bounds["minx"], bounds["miny"]], [bounds["maxx"], bounds["maxy"]]]}, + } + ], + } + + simulator_config = GeoJSONFlightsSimulatorConfiguration(geojson=flight_path_geojson) + simulator = GeoJSONFlightsSimulator(simulator_config) + simulator.generate_flight_grid_and_path_points(altitude_of_ground_level_wgs_84=570) + telemetry_states = simulator.generate_states(duration=30) + + return flight_declaration, telemetry_states + + def _resolve_ref(self, ref: str, context: Dict[str, Any]) -> Any: + # ref format: "step_id.field.subfield" or just "step_id" + parts = ref.split(".") + step_id = parts[0] + + if "step_results" not in context: + raise ValueError("No step results available for reference resolution") + + if step_id not in context["step_results"]: + raise ValueError(f"Referenced step '{step_id}' not found in results") + + current_value = context["step_results"][step_id] + + # Traverse the rest of the path + for part in parts[1:]: + if not part: + continue + if isinstance(current_value, dict): + current_value = current_value.get(part) + elif hasattr(current_value, part): + current_value = getattr(current_value, part) + else: + # If we can't find it, maybe the user meant to access a property of the result object + # but the result object was serialized to a dict. + raise ValueError( + f"Could not resolve '{part}' in '{ref}'." + f"Available keys: {list(current_value.keys()) if isinstance(current_value, dict) else 'Not a dict'}" + ) + + return current_value + + def _prepare_params(self, step: StepDefinition, context: Dict[str, Any], method: Any) -> Dict[str, Any]: + params = step.parameters.copy() + + # Resolve references + for key, value in params.items(): + if isinstance(value, dict) and "$ref" in value: + try: + params[key] = self._resolve_ref(value["$ref"], context) + logger.info(f"Resolved reference {value['$ref']} to {params[key]}") + except Exception as e: + logger.error(f"Failed to resolve reference {value['$ref']}: {e}") + raise + + # Inject operation_id if missing and available, AND if the method accepts it + if "operation_id" not in params and context["operation_id"]: + sig = inspect.signature(method) + if "operation_id" in sig.parameters: + params["operation_id"] = context["operation_id"] + + # Special handling for upload_flight_declaration + if step.functionName == "upload_flight_declaration": + if "declaration" not in params and "filename" not in params: + params["declaration"] = context["flight_declaration"] + + # Special handling for submit_telemetry + if step.functionName == "submit_telemetry" and "states" not in params: + params["states"] = context["telemetry_states"] + + # # Special handling for submit_simulated_air_traffic + # if step.functionName == "submit_simulated_air_traffic" and "observations" not in params: + # if "air_traffic_observations" in context: + # params["observations"] = context["air_traffic_observations"] + + # # Special handling for submit_air_traffic + # if step.functionName == "submit_air_traffic" and "observations" not in params: + # if "air_traffic_observations" in context: + # params["observations"] = context["air_traffic_observations"] + + # Special handling for update_operation_state + if step.functionName == "update_operation_state" and "new_state" in params: + if isinstance(params["new_state"], int): + try: + params["new_state"] = OperationState(params["new_state"]) + except ValueError: + pass + + return params + + def _execute_step(self, step: StepDefinition, resolver: DependencyResolver, context: Dict[str, Any]) -> Dict[str, Any]: + logger.info(f"Executing step: {step.className}.{step.functionName}") + + if step.className not in self.client_map: + raise ValueError(f"Unknown client class: {step.className}") + + client_type = self.client_map[step.className] + client = resolver.resolve(client_type) + + method = getattr(client, step.functionName) + params = self._prepare_params(step, context, method) + + result = method(**params) + + # Capture air traffic observations + if step.functionName in ["generate_simulated_air_traffic_data", "fetch_data"]: + observations = None + if hasattr(result, "details"): + observations = result.details + else: + observations = result + + context["air_traffic_observations"] = observations + if observations is not None: + try: + logger.info(f"Captured {len(observations)} air traffic observations") + except TypeError: + logger.warning(f"Captured observations of type {type(observations)} which has no len()") + else: + logger.warning("Captured None observations") + + # Capture operation_id from result if available + if isinstance(result, dict) and "id" in result: + # Only update if it looks like an operation ID (UUID-ish) or if we just uploaded a declaration + if step.functionName == "upload_flight_declaration": + context["operation_id"] = result["id"] + logger.info(f"Captured operation_id: {context['operation_id']}") + elif hasattr(result, "details") and isinstance(result.details, dict): + # Handle StepResult + op_id = result.details.get("id") + if op_id and step.functionName == "upload_flight_declaration": + context["operation_id"] = op_id + logger.info(f"Captured operation_id: {context['operation_id']}") + + # Serialize result if it's an object + if hasattr(result, "to_dict"): + # Use getattr to avoid type checking errors on dynamic objects + result_data = getattr(result, "to_dict")() + elif hasattr(result, "model_dump"): + result_data = getattr(result, "model_dump")() + else: + result_data = str(result) # Fallback + + # Store result for linking + if step.id: + if "step_results" not in context: + context["step_results"] = {} + # Store the raw result object to allow attribute access during resolution + context["step_results"][step.id] = result + + # Determine overall status based on result content + status_str = "success" + if hasattr(result, "status"): + if result.status == Status.FAIL: + status_str = "failure" + elif result.status == Status.PASS: + status_str = "success" + + return {"id": step.id, "step": f"{step.className}.{step.functionName}", "status": status_str, "result": result_data} + + def _run_implicit_setup(self, resolver: DependencyResolver, context: Dict[str, Any]) -> Dict[str, Any]: + logger.info("Implicit Setup: Uploading Flight Declaration") + fb_client = cast(FlightBlenderClient, resolver.resolve(FlightBlenderClient)) + upload_result = fb_client.upload_flight_declaration(declaration=context["flight_declaration"]) + + # Handle StepResult + if hasattr(upload_result, "details") and isinstance(upload_result.details, dict): + op_id = upload_result.details.get("id") + if op_id: + context["operation_id"] = op_id + logger.info(f"Setup complete. Operation ID: {op_id}") + + result_data = getattr(upload_result, "model_dump")() if hasattr(upload_result, "model_dump") else str(upload_result) + return {"step": "Setup: Upload Flight Declaration", "status": "success", "result": result_data} + else: + raise ValueError("Failed to get operation ID from upload result") + else: + # Check if it failed + if hasattr(upload_result, "status") and upload_result.status == Status.FAIL: + raise ValueError(f"Setup failed: {upload_result.error_message}") + raise ValueError(f"Unexpected return from upload: {upload_result}") + + def _run_implicit_teardown(self, resolver: DependencyResolver, context: Dict[str, Any]) -> Dict[str, Any]: + logger.info(f"Implicit Teardown: Deleting Operation {context['operation_id']}") + fb_client = cast(FlightBlenderClient, resolver.resolve(FlightBlenderClient)) + teardown_result = fb_client.delete_flight_declaration(context["operation_id"]) + + result_data = getattr(teardown_result, "model_dump")() if hasattr(teardown_result, "model_dump") else str(teardown_result) + return {"step": "Teardown: Delete Flight Declaration", "status": "success", "result": result_data} + + def run_scenario(self, scenario: ScenarioDefinition) -> List[Dict[str, Any]]: + results = [] + + # Pre-generate data + try: + flight_declaration, telemetry_states = self._generate_data() + except Exception as e: + logger.error(f"Data generation failed: {e}") + return [{"step": "Data Generation", "status": "error", "error": str(e)}] + + context = {"operation_id": None, "flight_declaration": flight_declaration, "telemetry_states": telemetry_states, "step_results": {}} + + # Check if user provided setup + user_has_setup = len(scenario.steps) > 0 and scenario.steps[0].functionName == "upload_flight_declaration" + + with ExitStack() as stack: + resolver = DependencyResolver(stack) + + try: + # Implicit Setup + if not user_has_setup: + try: + setup_result = self._run_implicit_setup(resolver, context) + results.append(setup_result) + except Exception as setup_error: + logger.error(f"Implicit setup failed: {setup_error}") + return [{"step": "Implicit Setup", "status": "error", "error": str(setup_error)}] + + for step in scenario.steps: + try: + step_result = self._execute_step(step, resolver, context) + results.append(step_result) + except Exception as e: + logger.error(f"Error in step {step.functionName}: {e}") + results.append({"step": f"{step.className}.{step.functionName}", "status": "error", "error": str(e)}) + # Stop on error? + break + except Exception as e: + logger.error(f"Scenario execution failed: {e}") + results.append({"step": "Scenario Setup", "status": "error", "error": str(e)}) + finally: + # Implicit Teardown + if not user_has_setup and context["operation_id"]: + try: + teardown_result = self._run_implicit_teardown(resolver, context) + results.append(teardown_result) + except Exception as teardown_error: + logger.error(f"Teardown failed: {teardown_error}") + results.append({"step": "Teardown", "status": "error", "error": str(teardown_error)}) + + return results diff --git a/web-editor/.gitignore b/web-editor/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web-editor/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web-editor/README.md b/web-editor/README.md new file mode 100644 index 0000000..8eef3f1 --- /dev/null +++ b/web-editor/README.md @@ -0,0 +1,47 @@ +# OpenUTM Scenario Editor + +A browser-based editor to design verification scenarios as a Directed Acyclic Graph (DAG). + +## Features + +- **Visual DAG Editor**: Design scenarios using a node-based interface powered by @xyflow/react. +- **Drag and Drop**: Easily add steps from the toolbox to the canvas. +- **Parameter Configuration**: Edit node parameters using a dedicated properties panel. +- **Scenario Execution**: Run scenarios directly in the editor and view results. +- **Import/Export**: Save and load scenarios as JSON files. +- **Dark Mode**: "Premium" developer-focused UI with theme toggling. +- **Responsive Layout**: Resizable panels and responsive design. + +## Project Structure + +The project follows a modular architecture separating concerns into components, hooks, and types: + +- **`src/components/`**: UI components including the main `ScenarioEditor` and sub-components (`CustomNode`, `Toolbox`, `PropertiesPanel`, etc.). +- **`src/hooks/`**: Custom React hooks for logic isolation (`useScenarioGraph`, `useScenarioRunner`, `useScenarioFile`). +- **`src/types/`**: TypeScript definitions for strict type safety. +- **`src/styles/`**: CSS Modules for scoped styling. + +## Getting Started + +1. Install dependencies: + + ```bash + npm install + ``` + +2. Start the development server: + + ```bash + npm run dev + ``` + +3. Open your browser at `http://localhost:5173`. + +## Tech Stack + +- **React 19**: UI library. +- **TypeScript**: Static typing. +- **Vite**: Fast build tool. +- **@xyflow/react**: Graph visualization and interaction. +- **Lucide React**: Iconography. +- **CSS Modules**: Scoped styling. diff --git a/web-editor/eslint.config.js b/web-editor/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/web-editor/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/web-editor/index.html b/web-editor/index.html new file mode 100644 index 0000000..bba9739 --- /dev/null +++ b/web-editor/index.html @@ -0,0 +1,16 @@ + + + + + + + web-editor + + + + + +
+ + + diff --git a/web-editor/package-lock.json b/web-editor/package-lock.json new file mode 100644 index 0000000..ece395e --- /dev/null +++ b/web-editor/package-lock.json @@ -0,0 +1,4927 @@ +{ + "name": "web-editor", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web-editor", + "version": "0.0.0", + "dependencies": { + "@dagrejs/dagre": "^1.1.8", + "@xyflow/react": "^12.9.3", + "lucide-react": "^0.554.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@types/node": "^24.10.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^5.1.0", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "jsdom": "^27.2.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.3", + "vite": "^7.2.2", + "vitest": "^4.0.10" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.23", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.23.tgz", + "integrity": "sha512-2kJ1HxBKzPLbmhZpxBiTZggjtgCwKg1ma5RHShxvd6zgqhDEdEkzpiwe7jLkI2p2BrZvFCXIihdoMkl1H39VnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.5.tgz", + "integrity": "sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.1" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.4.tgz", + "integrity": "sha512-buQDjkm+wDPXd6c13534URWZqbz0RP5PAhXZ+LIoa5LgwInT9HVJvGIJivg75vi8I13CxDGdTnz+aY5YUJlIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.2" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.16.tgz", + "integrity": "sha512-2SpS4/UaWQaGpBINyG5ZuCHnUDeVByOhvbkARwfmnfxDvTaj80yOI1cD8Tw93ICV5Fx4fnyDKWQZI1CDtcWyUg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@dagrejs/dagre": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.8.tgz", + "integrity": "sha512-5SEDlndt4W/LaVzPYJW+bSmSEZc9EzTf8rJ20WCKvjS5EAZAN0b+x0Yww7VMT4R3Wootkg+X9bUfUxazYw6Blw==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "2.2.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz", + "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==", + "license": "MIT", + "engines": { + "node": ">17.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.47", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", + "integrity": "sha512-8QagwMH3kNCuzD8EWL8R2YPW5e4OrHNSAHRFDdmFqEwEaD/KcNKjVoumo+gP2vW5eKB2UPbM6vTYiGZX0ixLnw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz", + "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz", + "integrity": "sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.47.0", + "@typescript-eslint/type-utils": "8.47.0", + "@typescript-eslint/utils": "8.47.0", + "@typescript-eslint/visitor-keys": "8.47.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.47.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.47.0.tgz", + "integrity": "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.47.0", + "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/typescript-estree": "8.47.0", + "@typescript-eslint/visitor-keys": "8.47.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.47.0.tgz", + "integrity": "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.47.0", + "@typescript-eslint/types": "^8.47.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz", + "integrity": "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/visitor-keys": "8.47.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz", + "integrity": "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.47.0.tgz", + "integrity": "sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/typescript-estree": "8.47.0", + "@typescript-eslint/utils": "8.47.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.47.0.tgz", + "integrity": "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz", + "integrity": "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.47.0", + "@typescript-eslint/tsconfig-utils": "8.47.0", + "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/visitor-keys": "8.47.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.47.0.tgz", + "integrity": "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.47.0", + "@typescript-eslint/types": "8.47.0", + "@typescript-eslint/typescript-estree": "8.47.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz", + "integrity": "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.47.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz", + "integrity": "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.47", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.10.tgz", + "integrity": "sha512-3QkTX/lK39FBNwARCQRSQr0TP9+ywSdxSX+LgbJ2M1WmveXP72anTbnp2yl5fH+dU6SUmBzNMrDHs80G8G2DZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.10", + "@vitest/utils": "4.0.10", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.10.tgz", + "integrity": "sha512-e2OfdexYkjkg8Hh3L9NVEfbwGXq5IZbDovkf30qW2tOh7Rh9sVtmSr2ztEXOFbymNxS4qjzLXUQIvATvN4B+lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.10.tgz", + "integrity": "sha512-99EQbpa/zuDnvVjthwz5bH9o8iPefoQZ63WV8+bsRJZNw3qQSvSltfut8yu1Jc9mqOYi7pEbsKxYTi/rjaq6PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.10.tgz", + "integrity": "sha512-EXU2iSkKvNwtlL8L8doCpkyclw0mc/t4t9SeOnfOFPyqLmQwuceMPA4zJBa6jw0MKsZYbw7kAn+gl7HxrlB8UQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.10.tgz", + "integrity": "sha512-2N4X2ZZl7kZw0qeGdQ41H0KND96L3qX1RgwuCfy6oUsF2ISGD/HpSbmms+CkIOsQmg2kulwfhJ4CI0asnZlvkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.10.tgz", + "integrity": "sha512-AsY6sVS8OLb96GV5RoG8B6I35GAbNrC49AO+jNRF9YVGb/g9t+hzNm1H6kD0NDp8tt7VJLs6hb7YMkDXqu03iw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.10.tgz", + "integrity": "sha512-kOuqWnEwZNtQxMKg3WmPK1vmhZu9WcoX69iwWjVz+jvKTsF1emzsv3eoPcDr6ykA3qP2bsCQE7CwqfNtAVzsmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.10", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xyflow/react": { + "version": "12.9.3", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.3.tgz", + "integrity": "sha512-PSWoJ8vHiEqSIkLIkge+0eiHWiw4C6dyFDA03VKWJkqbU4A13VlDIVwKqf/Znuysn2GQw/zA61zpHE4rGgax7Q==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.73", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.73", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.73.tgz", + "integrity": "sha512-C2ymH2V4mYDkdVSiRx0D7R0s3dvfXiupVBcko6tXP5K4tVdSBMo22/e3V9yRNdn+2HQFv44RFKzwOyCcUUDAVQ==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.29.tgz", + "integrity": "sha512-sXdt2elaVnhpDNRDz+1BDx1JQoJRuNk7oVlAlbGiFkLikHCAQiccexF/9e91zVi6RCgqspl04aP+6Cnl9zRLrA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001756", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz", + "integrity": "sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", + "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.3.tgz", + "integrity": "sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.0.3", + "@csstools/css-syntax-patches-for-csstree": "^1.0.14", + "css-tree": "^3.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.256", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.256.tgz", + "integrity": "sha512-uqYq1IQhpXXLX+HgiXdyOZml7spy4xfy42yPxcCCRjswp0fYM2X+JwCON07lqnpLEGVCj739B7Yr+FngmHBMEQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "27.2.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.2.0.tgz", + "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@acemir/cssom": "^0.9.23", + "@asamuzakjp/dom-selector": "^6.7.4", + "cssstyle": "^5.3.3", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.554.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.554.0.tgz", + "integrity": "sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.18.tgz", + "integrity": "sha512-lCcgTAgMxQ1JKOWrVGo6E69Ukbnx4Gc1wiYLRf6J5NN4HRYJtCby1rPF8rkQ4a6qqoFBK5dvjJ1zJ0F7VfDSvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.18" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.18.tgz", + "integrity": "sha512-jqJC13oP4FFAahv4JT/0WTDrCF9Okv7lpKtOZUGPLiAnNbACcSg8Y8T+Z9xthOmRBqi/Sob4yi0TE0miRCvF7Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.47.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.47.0.tgz", + "integrity": "sha512-Lwe8i2XQ3WoMjua/r1PHrCTpkubPYJCAfOurtn+mtTzqB6jNd+14n9UN1bJ4s3F49x9ixAm0FLflB/JzQ57M8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.47.0", + "@typescript-eslint/parser": "8.47.0", + "@typescript-eslint/typescript-estree": "8.47.0", + "@typescript-eslint/utils": "8.47.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", + "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.10.tgz", + "integrity": "sha512-2Fqty3MM9CDwOVet/jaQalYlbcjATZwPYGcqpiYQqgQ/dLC7GuHdISKgTYIVF/kaishKxLzleKWWfbSDklyIKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.10", + "@vitest/mocker": "4.0.10", + "@vitest/pretty-format": "4.0.10", + "@vitest/runner": "4.0.10", + "@vitest/snapshot": "4.0.10", + "@vitest/spy": "4.0.10", + "@vitest/utils": "4.0.10", + "debug": "^4.4.3", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.10", + "@vitest/browser-preview": "4.0.10", + "@vitest/browser-webdriverio": "4.0.10", + "@vitest/ui": "4.0.10", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/web-editor/package.json b/web-editor/package.json new file mode 100644 index 0000000..bc75dcc --- /dev/null +++ b/web-editor/package.json @@ -0,0 +1,39 @@ +{ + "name": "web-editor", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "test": "vitest", + "preview": "vite preview" + }, + "dependencies": { + "@dagrejs/dagre": "^1.1.8", + "@xyflow/react": "^12.9.3", + "lucide-react": "^0.554.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@types/node": "^24.10.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^5.1.0", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "jsdom": "^27.2.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.3", + "vite": "^7.2.2", + "vitest": "^4.0.10" + } +} diff --git a/web-editor/public/vite.svg b/web-editor/public/vite.svg new file mode 100644 index 0000000..ee9fada --- /dev/null +++ b/web-editor/public/vite.svg @@ -0,0 +1 @@ + diff --git a/web-editor/src/App.tsx b/web-editor/src/App.tsx new file mode 100644 index 0000000..c4e547d --- /dev/null +++ b/web-editor/src/App.tsx @@ -0,0 +1,11 @@ +import ScenarioEditor from './components/ScenarioEditor' + +function App() { + return ( +
+ +
+ ) +} + +export default App diff --git a/web-editor/src/components/ScenarioEditor.tsx b/web-editor/src/components/ScenarioEditor.tsx new file mode 100644 index 0000000..dc4bbf5 --- /dev/null +++ b/web-editor/src/components/ScenarioEditor.tsx @@ -0,0 +1,329 @@ +import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react'; +import { + ReactFlow, + Controls, + Background, + BackgroundVariant, + Panel, + ReactFlowProvider, + type Node, + type Edge, + type NodeTypes, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import layoutStyles from '../styles/EditorLayout.module.css'; +import operationsData from '../data/operations.json'; +import type { Operation, OperationParam, NodeData } from '../types/scenario'; + +import { CustomNode } from './ScenarioEditor/CustomNode'; +import { Toolbox } from './ScenarioEditor/Toolbox'; +import { PropertiesPanel } from './ScenarioEditor/PropertiesPanel'; +import { ResultPanel } from './ScenarioEditor/ResultPanel'; +import { Header } from './ScenarioEditor/Header'; + +import { useScenarioGraph } from '../hooks/useScenarioGraph'; +import { useScenarioRunner } from '../hooks/useScenarioRunner'; +import { useScenarioFile } from '../hooks/useScenarioFile'; + +const nodeTypes: NodeTypes = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + custom: CustomNode as any, +}; + +// Helper function moved outside component to avoid recreation +const updateParameterInList = (params: OperationParam[], paramName: string, value: unknown) => { + return params.map((param) => + param.name === paramName ? { ...param, default: value } : param + ); +}; + +// Memoize child components to prevent unnecessary re-renders +const MemoizedToolbox = React.memo(Toolbox); +const MemoizedPropertiesPanel = React.memo(PropertiesPanel); +const MemoizedResultPanel = React.memo(ResultPanel); +const MemoizedHeader = React.memo(Header); + +const ScenarioEditorContent = () => { + const reactFlowWrapper = useRef(null); + const [theme, setTheme] = useState<'light' | 'dark'>('light'); + const [selectedNode, setSelectedNode] = useState | null>(null); + const [selectedEdgeId, setSelectedEdgeId] = useState(null); + const [resultToDisplay, setResultToDisplay] = useState(null); + + const { + nodes, + edges, + setNodes, + setEdges, + onNodesChange, + onEdgesChange, + onConnect, + onDrop, + onLayout, + clearGraph, + setReactFlowInstance, + reactFlowInstance + } = useScenarioGraph(); + + // Refs to keep track of latest nodes/edges without triggering re-renders in callbacks + const nodesRef = useRef(nodes); + const edgesRef = useRef(edges); + + useEffect(() => { + nodesRef.current = nodes; + edgesRef.current = edges; + }, [nodes, edges]); + + const { isRunning, runScenario } = useScenarioRunner(); + const { fileInputRef, handleExportJSON, handleLoadJSON, handleFileChange } = useScenarioFile( + nodes, + edges, + setNodes, + setEdges, + reactFlowInstance + ); + + useEffect(() => { + document.documentElement.dataset.theme = theme; + }, [theme]); + + // Update edge styles when selection changes + useEffect(() => { + setEdges(eds => eds.map(edge => ({ + ...edge, + style: { + stroke: edge.id === selectedEdgeId ? 'var(--success)' : 'var(--accent-primary)', + strokeWidth: edge.id === selectedEdgeId ? 2 : 1 + } + }))); + }, [selectedEdgeId, setEdges]); + + const toggleTheme = useCallback(() => { + setTheme(prev => prev === 'light' ? 'dark' : 'light'); + }, []); + + const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => { + setSelectedNode(node as Node); + setSelectedEdgeId(null); + setResultToDisplay(null); + }, []); + + const onNodeDragStart = useCallback((_event: React.MouseEvent, node: Node) => { + setSelectedNode(node as Node); + setSelectedEdgeId(null); + setResultToDisplay(null); + }, []); + + const onPaneClick = useCallback(() => { + setSelectedNode(null); + setSelectedEdgeId(null); + setResultToDisplay(null); + }, []); + + const onEdgeClick = useCallback((_event: React.MouseEvent, edge: Edge) => { + setSelectedEdgeId(edge.id); + setSelectedNode(null); + }, []); + + const onDragOver = useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }, []); + + const handleDrop = useCallback((event: React.DragEvent) => { + onDrop(event, operationsData as Operation[]); + }, [onDrop]); + + const handleClear = useCallback(() => { + if (globalThis.confirm('Are you sure you want to clear the current scenario? All unsaved changes will be lost.')) { + clearGraph(); + setSelectedNode(null); + setSelectedEdgeId(null); + setResultToDisplay(null); + } + }, [clearGraph]); + + const handleShowResult = useCallback((res: unknown) => { + setResultToDisplay(res); + }, []); + + const updateNodesWithResults = useCallback((currentNodes: Node[], results: { id: string; status: 'success' | 'failure' | 'error'; result?: unknown }[]) => { + return currentNodes.map(node => { + const stepResult = results.find((r) => r.id === node.id); + if (stepResult) { + return { + ...node, + data: { + ...node.data, + status: stepResult.status, + result: stepResult.result, + onShowResult: handleShowResult + } + }; + } + return node; + }); + }, [handleShowResult]); + + const handleRun = useCallback(async () => { + // Clear previous results/errors from the UI immediately + setResultToDisplay(null); + setNodes((nds) => nds.map(node => ({ + ...node, + data: { + ...node.data, + status: undefined, + result: undefined + } + }))); + + // Use getNodes/getEdges from instance to ensure we have the latest state + // Fallback to refs if instance not ready + const currentNodes = reactFlowInstance ? reactFlowInstance.getNodes() : nodesRef.current; + const currentEdges = reactFlowInstance ? reactFlowInstance.getEdges() : edgesRef.current; + + const result = await runScenario(currentNodes, currentEdges); + if (result?.results) { + setNodes((nds) => updateNodesWithResults(nds, result.results)); + } + }, [runScenario, setNodes, updateNodesWithResults, reactFlowInstance, setResultToDisplay]); + + const updateNodeParameter = useCallback((nodeId: string, paramName: string, value: unknown) => { + setNodes((nds) => + nds.map((node) => { + if (node.id === nodeId) { + const updatedParameters = updateParameterInList( + (node.data.parameters || []), + paramName, + value + ); + return { + ...node, + data: { ...node.data, parameters: updatedParameters }, + }; + } + return node; + }) + ); + + setSelectedNode((prev) => { + if (!prev || prev.id !== nodeId) return prev; + const updatedParameters = updateParameterInList( + (prev.data.parameters || []), + paramName, + value + ); + return { + ...prev, + data: { ...prev.data, parameters: updatedParameters }, + }; + }); + }, [setNodes]); + + const getConnectedSourceNodes = useCallback((targetNodeId: string) => { + const sourceNodeIds = new Set(edges + .filter(edge => edge.target === targetNodeId) + .map(edge => edge.source)); + return nodes.filter(node => sourceNodeIds.has(node.id)); + }, [edges, nodes]); + + const connectedNodes = useMemo(() => + selectedNode ? getConnectedSourceNodes(selectedNode.id) : [], + [selectedNode, getConnectedSourceNodes] + ); + + // Inject onShowResult handler into nodes whenever they change or are loaded + // This is a bit of a hack to ensure the callback is present after serialization/deserialization + // Ideally, we wouldn't store functions in node data. + useEffect(() => { + setNodes(nds => nds.map(node => { + if (!node.data.onShowResult) { + return { + ...node, + data: { + ...node.data, + onShowResult: handleShowResult + } + }; + } + return node; + })); + }, [setNodes, nodes.length, handleShowResult]); // Only run when node count changes (added/loaded) + + return ( +
+ } + onFileChange={handleFileChange} + /> + +
+ + +
+ , Edge> + nodes={nodes} + edges={edges} + nodeTypes={nodeTypes} + onNodesChange={onNodesChange} + onEdgesChange={onEdgesChange} + onConnect={onConnect} + onInit={setReactFlowInstance} + onDrop={handleDrop} + onDragOver={onDragOver} + onNodeClick={onNodeClick} + onNodeDragStart={onNodeDragStart} + onPaneClick={onPaneClick} + onEdgeClick={onEdgeClick} + fitView + className={theme === 'dark' ? "dark-flow" : ""} + colorMode={theme} + > + + + +
+
+
+ Connected +
+
+
+ +
+ + {resultToDisplay !== null && resultToDisplay !== undefined && ( + setResultToDisplay(null)} + /> + )} + + {selectedNode && !resultToDisplay && ( + setSelectedNode(null)} + onUpdateParameter={updateNodeParameter} + /> + )} +
+
+ ); +}; + +export default function ScenarioEditor() { + return ( + + + + ); +} diff --git a/web-editor/src/components/ScenarioEditor/CustomNode.tsx b/web-editor/src/components/ScenarioEditor/CustomNode.tsx new file mode 100644 index 0000000..e516d42 --- /dev/null +++ b/web-editor/src/components/ScenarioEditor/CustomNode.tsx @@ -0,0 +1,37 @@ + +import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'; +import { Box, CheckCircle, XCircle, AlertTriangle } from 'lucide-react'; +import styles from '../../styles/Node.module.css'; +import type { NodeData } from '../../types/scenario'; + +export const CustomNode = ({ data, selected }: NodeProps>) => { + const statusClass = data.status === 'success' ? styles.statusSuccess : + (data.status === 'failure' || data.status === 'error') ? styles.statusError : ''; + const selectedClass = selected ? styles.selected : ''; + + return ( +
+ +
+ + {data.label} + {data.status && ( + + )} +
+ +
+ ); +}; diff --git a/web-editor/src/components/ScenarioEditor/Header.tsx b/web-editor/src/components/ScenarioEditor/Header.tsx new file mode 100644 index 0000000..d83ad6a --- /dev/null +++ b/web-editor/src/components/ScenarioEditor/Header.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { Activity, Moon, Sun, Layout, Trash2, Upload, Save, Play, Loader2 } from 'lucide-react'; +import styles from '../../styles/Header.module.css'; +import btnStyles from '../../styles/Button.module.css'; + +interface HeaderProps { + theme: 'light' | 'dark'; + toggleTheme: () => void; + onLayout: () => void; + onClear: () => void; + onLoad: () => void; + onExport: () => void; + onRun: () => void; + isRunning: boolean; + fileInputRef: React.RefObject; + onFileChange: (event: React.ChangeEvent) => void; +} + +export const Header = ({ + theme, + toggleTheme, + onLayout, + onClear, + onLoad, + onExport, + onRun, + isRunning, + fileInputRef, + onFileChange +}: HeaderProps) => { + return ( +
+
+ + OpenUTM Scenario Designer +
+
+ + + + + + + +
+
+ ); +}; diff --git a/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx b/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx new file mode 100644 index 0000000..4ca9fff --- /dev/null +++ b/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx @@ -0,0 +1,194 @@ +import React from 'react'; +import { X, Link as LinkIcon, Unlink } from 'lucide-react'; +import type { Node } from '@xyflow/react'; +import layoutStyles from '../../styles/EditorLayout.module.css'; +import styles from '../../styles/SidebarPanel.module.css'; +import type { NodeData } from '../../types/scenario'; + +const DocstringViewer = ({ text }: { text: string }) => { + if (!text) return
No description available.
; + + const sectionRegex = /(Args:|Returns:|Raises:)/; + const parts = text.split(sectionRegex); + + const mainDesc = parts[0].trim(); + const sections: React.ReactNode[] = []; + + for (let i = 1; i < parts.length; i += 2) { + const title = parts[i]; + const content = parts[i + 1]; + sections.push( +
+ {title} +
{content.trim()}
+
+ ); + } + + return ( +
+
{mainDesc}
+ {sections} +
+ ); +}; + +interface PropertiesPanelProps { + selectedNode: Node; + connectedNodes: Node[]; + onClose: () => void; + onUpdateParameter: (nodeId: string, paramName: string, value: unknown) => void; +} + +export const PropertiesPanel = ({ selectedNode, connectedNodes, onClose, onUpdateParameter }: PropertiesPanelProps) => { + const formatParamValue = (value: unknown): string => { + if (value === null || value === undefined) { + return ''; + } + if (typeof value === 'object' && value !== null && '$ref' in value) { + return (value as { $ref: string }).$ref; + } + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (typeof value === 'symbol') { + return value.toString(); + } + return JSON.stringify(value); + }; + + return ( + + ); +}; diff --git a/web-editor/src/components/ScenarioEditor/ResultPanel.tsx b/web-editor/src/components/ScenarioEditor/ResultPanel.tsx new file mode 100644 index 0000000..d9c743b --- /dev/null +++ b/web-editor/src/components/ScenarioEditor/ResultPanel.tsx @@ -0,0 +1,63 @@ +import { X } from 'lucide-react'; +import layoutStyles from '../../styles/EditorLayout.module.css'; +import panelStyles from '../../styles/SidebarPanel.module.css'; +import styles from '../../styles/ResultPanel.module.css'; +import { useSidebarResize } from '../../hooks/useSidebarResize'; + +const JsonViewer = ({ data }: { data: unknown }) => { + const jsonString = JSON.stringify(data, null, 2); + + const html = jsonString.replace(/("[^"]*":?|\btrue\b|\bfalse\b|\bnull\b|-?\d+(?:\.\d+)?)/g, (match) => { + let cls = styles.jsonNumber; + if (match.startsWith('"')) { + if (match.endsWith(':')) { + cls = styles.jsonKey; + } else { + cls = styles.jsonString; + } + } else if (/true|false/.test(match)) { + cls = styles.jsonBoolean; + } else if (/null/.test(match)) { + cls = styles.jsonNull; + } + return `${match}`; + }); + + return
; +}; + +interface ResultPanelProps { + result: unknown; + onClose: () => void; +} + +export const ResultPanel = ({ result, onClose }: ResultPanelProps) => { + const { sidebarWidth, isResizing, startResizing } = useSidebarResize(); + + return ( +
+
+ +
+ + + ); +}; diff --git a/web-editor/src/components/ScenarioEditor/Toolbox.tsx b/web-editor/src/components/ScenarioEditor/Toolbox.tsx new file mode 100644 index 0000000..417a8d0 --- /dev/null +++ b/web-editor/src/components/ScenarioEditor/Toolbox.tsx @@ -0,0 +1,81 @@ +import { useState, useMemo } from 'react'; +import { ChevronDown, ChevronRight, Box } from 'lucide-react'; +import styles from '../../styles/Toolbox.module.css'; +import layoutStyles from '../../styles/EditorLayout.module.css'; +import operationsData from '../../data/operations.json'; +import type { Operation } from '../../types/scenario'; + +const ToolboxGroup = ({ title, ops }: { title: string, ops: Operation[] }) => { + const [isExpanded, setIsExpanded] = useState(true); + + return ( +
+ + {isExpanded && ( +
+ {ops.map((op) => ( +
{ + event.dataTransfer.setData('application/reactflow', op.name); + event.dataTransfer.setData('application/reactflow/id', op.id); + }} + draggable + role="button" + tabIndex={0} + > + + {op.name} +
+ ))} +
+ )} +
+ ); +}; + +export const Toolbox = () => { + const operations = operationsData as Operation[]; + + const groupedOperations = useMemo(() => { + const grouped = operations.reduce((acc, op) => { + if (!acc[op.className]) { + acc[op.className] = []; + } + acc[op.className].push(op); + return acc; + }, {} as Record); + + const sortedKeys = Object.keys(grouped).sort((a, b) => a.localeCompare(b)); + for (const key of sortedKeys) { + grouped[key].sort((a, b) => a.name.localeCompare(b.name)); + } + return { grouped, sortedKeys }; + }, [operations]); + + return ( + + ); +}; diff --git a/web-editor/src/components/ScenarioEditor/__tests__/CustomNode.test.tsx b/web-editor/src/components/ScenarioEditor/__tests__/CustomNode.test.tsx new file mode 100644 index 0000000..eabe885 --- /dev/null +++ b/web-editor/src/components/ScenarioEditor/__tests__/CustomNode.test.tsx @@ -0,0 +1,78 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { CustomNode } from '../CustomNode'; +import { ReactFlowProvider } from '@xyflow/react'; +import type { NodeData } from '../../../types/scenario'; + +describe('CustomNode', () => { + const mockData: NodeData = { + label: 'Test Node', + status: 'success', + result: { success: true }, + onShowResult: vi.fn(), + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const defaultProps: any = { + id: '1', + data: mockData, + type: 'custom', + selected: false, + zIndex: 0, + isConnectable: true, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + dragging: false, + dragHandle: undefined, + parentId: undefined, + width: 100, + height: 50, + sourcePosition: undefined, + targetPosition: undefined, + }; + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + it('renders label', () => { + render(, { wrapper }); + expect(screen.getByText('Test Node')).toBeInTheDocument(); + }); + + it('renders success status icon', () => { + render(, { wrapper }); + // Check for CheckCircle icon (or button containing it) + const button = screen.getByTitle('Click to view results'); + expect(button).toBeInTheDocument(); + }); + + it('calls onShowResult when status icon is clicked', () => { + render(, { wrapper }); + const button = screen.getByTitle('Click to view results'); + fireEvent.click(button); + expect(mockData.onShowResult).toHaveBeenCalledWith(mockData.result); + }); + + it('renders failure status', () => { + const failureProps = { + ...defaultProps, + data: { ...mockData, status: 'failure' as const } + }; + render(, { wrapper }); + const button = screen.getByTitle('Click to view results'); + expect(button).toBeInTheDocument(); + // We could check for color or specific icon if we could query by icon + }); + + it('applies selected style', () => { + const selectedProps = { + ...defaultProps, + selected: true + }; + const { container } = render(, { wrapper }); + const nodeElement = container.firstChild as HTMLElement; + expect(nodeElement.style.borderColor).toBe('var(--accent-primary)'); + expect(nodeElement.style.boxShadow).toBe('0 0 0 1px var(--accent-primary)'); + }); +}); diff --git a/web-editor/src/components/ScenarioEditor/__tests__/Header.test.tsx b/web-editor/src/components/ScenarioEditor/__tests__/Header.test.tsx new file mode 100644 index 0000000..9b55d8e --- /dev/null +++ b/web-editor/src/components/ScenarioEditor/__tests__/Header.test.tsx @@ -0,0 +1,72 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { Header } from '../Header'; +import React from 'react'; + +describe('Header', () => { + const defaultProps = { + theme: 'light' as const, + toggleTheme: vi.fn(), + onLayout: vi.fn(), + onClear: vi.fn(), + onLoad: vi.fn(), + onExport: vi.fn(), + onRun: vi.fn(), + isRunning: false, + fileInputRef: React.createRef() as React.RefObject, + onFileChange: vi.fn(), + }; + + it('renders correctly', () => { + render(
); + expect(screen.getByText('OpenUTM Scenario Designer')).toBeInTheDocument(); + }); + + it('calls toggleTheme when theme button is clicked', () => { + render(
); + const themeButton = screen.getByTitle('Switch to dark mode'); + fireEvent.click(themeButton); + expect(defaultProps.toggleTheme).toHaveBeenCalled(); + }); + + it('calls onLayout when Auto Layout button is clicked', () => { + render(
); + const layoutButton = screen.getByText('Auto Layout'); + fireEvent.click(layoutButton); + expect(defaultProps.onLayout).toHaveBeenCalled(); + }); + + it('calls onClear when Clear button is clicked', () => { + render(
); + const clearButton = screen.getByText('Clear'); + fireEvent.click(clearButton); + expect(defaultProps.onClear).toHaveBeenCalled(); + }); + + it('calls onLoad when Load Scenario button is clicked', () => { + render(
); + const loadButton = screen.getByText('Load Scenario'); + fireEvent.click(loadButton); + expect(defaultProps.onLoad).toHaveBeenCalled(); + }); + + it('calls onExport when Save Scenario button is clicked', () => { + render(
); + const exportButton = screen.getByText('Export'); + fireEvent.click(exportButton); + expect(defaultProps.onExport).toHaveBeenCalled(); + }); + + it('calls onRun when Run button is clicked', () => { + render(
); + const runButton = screen.getByText('Run Scenario'); + fireEvent.click(runButton); + expect(defaultProps.onRun).toHaveBeenCalled(); + }); + + it('shows loading state when isRunning is true', () => { + render(
); + const runButton = screen.getByText('Run Scenario'); + expect(runButton).toBeDisabled(); + }); +}); diff --git a/web-editor/src/components/ScenarioEditor/__tests__/PropertiesPanel.test.tsx b/web-editor/src/components/ScenarioEditor/__tests__/PropertiesPanel.test.tsx new file mode 100644 index 0000000..90747b9 --- /dev/null +++ b/web-editor/src/components/ScenarioEditor/__tests__/PropertiesPanel.test.tsx @@ -0,0 +1,55 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { PropertiesPanel } from '../PropertiesPanel'; +import type { Node } from '@xyflow/react'; +import type { NodeData } from '../../../types/scenario'; + +describe('PropertiesPanel', () => { + const mockNode: Node = { + id: '1', + position: { x: 0, y: 0 }, + data: { + label: 'Test Node', + description: 'Test docstring', + parameters: [ + { name: 'arg1', type: 'string', default: 'value1' }, + { name: 'arg2', type: 'int', default: 123 } + ] + } + }; + + const defaultProps = { + selectedNode: mockNode, + connectedNodes: [], + onClose: vi.fn(), + onUpdateParameter: vi.fn(), + }; + + it('renders correctly', () => { + render(); + expect(screen.getByText('Test Node')).toBeInTheDocument(); + expect(screen.getByText('Test docstring')).toBeInTheDocument(); + }); + + it('calls onClose when close button is clicked', () => { + render(); + const buttons = screen.getAllByRole('button'); + fireEvent.click(buttons[0]); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('displays parameters', () => { + render(); + expect(screen.getByText('arg1')).toBeInTheDocument(); + expect(screen.getByDisplayValue('value1')).toBeInTheDocument(); + expect(screen.getByText('arg2')).toBeInTheDocument(); + expect(screen.getByDisplayValue('123')).toBeInTheDocument(); + }); + + it('calls onUpdateParameter when input changes', () => { + render(); + const input = screen.getByDisplayValue('value1'); + fireEvent.change(input, { target: { value: 'newValue' } }); + expect(defaultProps.onUpdateParameter).toHaveBeenCalledWith('1', 'arg1', 'newValue'); + }); +}); diff --git a/web-editor/src/components/ScenarioEditor/__tests__/ResultPanel.test.tsx b/web-editor/src/components/ScenarioEditor/__tests__/ResultPanel.test.tsx new file mode 100644 index 0000000..5d83027 --- /dev/null +++ b/web-editor/src/components/ScenarioEditor/__tests__/ResultPanel.test.tsx @@ -0,0 +1,60 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { ResultPanel } from '../ResultPanel'; + +// Mock useSidebarResize +vi.mock('../../hooks/useSidebarResize', () => ({ + useSidebarResize: () => ({ + sidebarWidth: 300, + isResizing: false, + startResizing: vi.fn(), + }) +})); + +describe('ResultPanel', () => { + const defaultProps = { + result: { key: 'value', number: 123, boolean: true }, + onClose: vi.fn(), + }; + + it('renders correctly', () => { + render(); + expect(screen.getByText('Step Result')).toBeInTheDocument(); + }); + + it('displays JSON result', () => { + render(); + // Since JsonViewer uses dangerouslySetInnerHTML and splits strings, we might need to check for parts + expect(screen.getByText('"key":')).toBeInTheDocument(); + expect(screen.getByText('"value"')).toBeInTheDocument(); + expect(screen.getByText('123')).toBeInTheDocument(); + expect(screen.getByText('true')).toBeInTheDocument(); + }); + + it('calls onClose when close button is clicked', () => { + render(); + // The close button is in the header. + // Let's find it by role or class. + // It has className={propStyles.closeButton} + // But we can't query by class name easily with testing-library. + // It has an X icon. + // Let's try to find the button in the header. + + const buttons = screen.getAllByRole('button'); + // The first button is the resize handle (it has type="button" and aria-label="Resize sidebar") + // The second button should be the close button. + + // Let's find by aria-label if possible, but it doesn't have one in the code I read. + // I should add aria-label to the close button in ResultPanel.tsx as well. + + // For now, let's assume it's the button that is NOT the resize handle. + const closeButton = buttons.find(b => b.getAttribute('aria-label') !== 'Resize sidebar'); + + if (closeButton) { + fireEvent.click(closeButton); + expect(defaultProps.onClose).toHaveBeenCalled(); + } else { + throw new Error('Close button not found'); + } + }); +}); diff --git a/web-editor/src/components/ScenarioEditor/__tests__/Toolbox.test.tsx b/web-editor/src/components/ScenarioEditor/__tests__/Toolbox.test.tsx new file mode 100644 index 0000000..d4de40a --- /dev/null +++ b/web-editor/src/components/ScenarioEditor/__tests__/Toolbox.test.tsx @@ -0,0 +1,78 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { Toolbox } from '../Toolbox'; + +// Mock the operations data +vi.mock('../../../data/operations.json', () => ({ + default: [ + { + id: 'op1', + name: 'Operation 1', + className: 'ClassA', + functionName: 'op1', + description: 'Docstring A', + parameters: [] + }, + { + id: 'op2', + name: 'Operation 2', + className: 'ClassA', + functionName: 'op2', + description: 'Docstring B', + parameters: [] + }, + { + id: 'op3', + name: 'Operation 3', + className: 'ClassB', + functionName: 'op3', + description: 'Docstring C', + parameters: [] + } + ] +})); + +describe('Toolbox', () => { + it('renders correctly', () => { + render(); + expect(screen.getByText('ClassA')).toBeInTheDocument(); + expect(screen.getByText('ClassB')).toBeInTheDocument(); + }); + + it('displays operations under groups', () => { + render(); + expect(screen.getByText('Operation 1')).toBeInTheDocument(); + expect(screen.getByText('Operation 2')).toBeInTheDocument(); + expect(screen.getByText('Operation 3')).toBeInTheDocument(); + }); + + it('collapses and expands groups', () => { + render(); + const groupHeader = screen.getByText('ClassA'); + + // Initially expanded + expect(screen.getByText('Operation 1')).toBeVisible(); + + // Click to collapse + fireEvent.click(groupHeader); + expect(screen.queryByText('Operation 1')).not.toBeInTheDocument(); + + // Click to expand + fireEvent.click(groupHeader); + expect(screen.getByText('Operation 1')).toBeVisible(); + }); + + it('sets data transfer on drag start', () => { + render(); + const operationItem = screen.getByText('Operation 1'); + + const dataTransfer = { + setData: vi.fn(), + }; + + fireEvent.dragStart(operationItem, { dataTransfer }); + + expect(dataTransfer.setData).toHaveBeenCalledWith('application/reactflow', 'Operation 1'); + expect(dataTransfer.setData).toHaveBeenCalledWith('application/reactflow/id', 'op1'); + }); +}); diff --git a/web-editor/src/components/__tests__/ScenarioEditor.test.tsx b/web-editor/src/components/__tests__/ScenarioEditor.test.tsx new file mode 100644 index 0000000..15a0af7 --- /dev/null +++ b/web-editor/src/components/__tests__/ScenarioEditor.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import ScenarioEditor from '../ScenarioEditor'; +import { ReactFlowProvider } from '@xyflow/react'; + +// Mock child components to simplify testing +vi.mock('../ScenarioEditor/Header', () => ({ + Header: () =>
Header
+})); +vi.mock('../ScenarioEditor/Toolbox', () => ({ + Toolbox: () =>
Toolbox
+})); +vi.mock('../ScenarioEditor/PropertiesPanel', () => ({ + PropertiesPanel: () =>
PropertiesPanel
+})); +vi.mock('../ScenarioEditor/ResultPanel', () => ({ + ResultPanel: () =>
ResultPanel
+})); + +// Mock hooks +vi.mock('../hooks/useScenarioGraph', () => ({ + useScenarioGraph: () => ({ + nodes: [], + edges: [], + setNodes: vi.fn(), + setEdges: vi.fn(), + onNodesChange: vi.fn(), + onEdgesChange: vi.fn(), + onConnect: vi.fn(), + onDrop: vi.fn(), + onLayout: vi.fn(), + clearGraph: vi.fn(), + reactFlowInstance: {}, + setReactFlowInstance: vi.fn(), + }) +})); + +vi.mock('../hooks/useScenarioRunner', () => ({ + useScenarioRunner: () => ({ + isRunning: false, + runScenario: vi.fn(), + }) +})); + +vi.mock('../hooks/useScenarioFile', () => ({ + useScenarioFile: () => ({ + handleExportJSON: vi.fn(), + handleLoadJSON: vi.fn(), + handleFileChange: vi.fn(), + fileInputRef: { current: null }, + }) +})); + +// Mock ReactFlow +vi.mock('@xyflow/react', async () => { + const actual = await vi.importActual('@xyflow/react'); + return { + ...actual, + ReactFlow: ({ children }: { children: React.ReactNode }) =>
{children}
, + Controls: () =>
Controls
, + Background: () =>
Background
, + Panel: ({ children }: { children: React.ReactNode }) =>
{children}
, + }; +}); + +describe('ScenarioEditor', () => { + it('renders correctly', () => { + render( + + + + ); + + expect(screen.getByTestId('header')).toBeInTheDocument(); + expect(screen.getByTestId('toolbox')).toBeInTheDocument(); + expect(screen.getByTestId('react-flow')).toBeInTheDocument(); + }); +}); diff --git a/web-editor/src/data/operations.json b/web-editor/src/data/operations.json new file mode 100644 index 0000000..2fac5d8 --- /dev/null +++ b/web-editor/src/data/operations.json @@ -0,0 +1,445 @@ +[ + { + "id": "FlightBlenderClient.upload_geo_fence", + "name": "Upload Geo Fence", + "functionName": "upload_geo_fence", + "className": "FlightBlenderClient", + "description": "Upload an Area-of-Interest (Geo Fence) to Flight Blender.\n\nArgs:\n filename: Path to the GeoJSON file containing the geo-fence definition.\n\nReturns:\n The JSON response from the API, including the geo-fence ID if successful.\n\nRaises:\n FlightBlenderError: If the upload request fails.\n json.JSONDecodeError: If the file content is invalid JSON.", + "parameters": [ + { + "name": "filename", + "type": "Optional[str]", + "default": null + } + ], + "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" + }, + { + "id": "FlightBlenderClient.get_geo_fence", + "name": "Get Geo Fence", + "functionName": "get_geo_fence", + "className": "FlightBlenderClient", + "description": "Retrieve the details of the most recently uploaded geo-fence.\n\nReturns:\n The JSON response from the API containing geo-fence details, or a dict\n indicating skip if no geo-fence ID is available.", + "parameters": [], + "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" + }, + { + "id": "FlightBlenderClient.delete_geo_fence", + "name": "Delete Geo Fence", + "functionName": "delete_geo_fence", + "className": "FlightBlenderClient", + "description": "Delete a geo-fence by ID.\n\nArgs:\n geo_fence_id: Optional ID of the geo-fence to delete. If not provided,\n uses the latest uploaded geo-fence ID.\n\nReturns:\n A dictionary with deletion status, including whether it was successful.\n\nNote:\n According to the schema, DELETE returns 204 on success. This method\n normalizes the response to a JSON dict for reporting.", + "parameters": [ + { + "name": "geo_fence_id", + "type": "Optional[str]", + "default": null + } + ], + "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" + }, + { + "id": "FlightBlenderClient.upload_flight_declaration", + "name": "Upload Flight Declaration", + "functionName": "upload_flight_declaration", + "className": "FlightBlenderClient", + "description": "Upload a flight declaration to the Flight Blender API.\n\nAccepts either a filename (str) containing JSON declaration data, or a\nFlightDeclaration model instance. Adjusts datetimes to current time + offsets,\nand posts it. Raises an error if the declaration is not approved.\n\nArgs:\n declaration: Either a path to the JSON flight declaration file (str),\n or a FlightDeclaration model instance.\n\nReturns:\n The JSON response from the API.\n\nRaises:\n FlightBlenderError: If the declaration is not approved or the request fails.\n json.JSONDecodeError: If the file content is invalid JSON (when using filename).", + "parameters": [ + { + "name": "declaration", + "type": "str | Any" + } + ], + "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" + }, + { + "id": "FlightBlenderClient.update_operation_state", + "name": "Update Operation State", + "functionName": "update_operation_state", + "className": "FlightBlenderClient", + "description": "Update the state of a flight operation.\n\nPosts the new state and optionally waits for the specified duration.\n\nArgs:\n new_state: The new OperationState to set.\n duration_seconds: Optional seconds to sleep after update (default 0).\n\nReturns:\n The JSON response from the API.\n\nRaises:\n FlightBlenderError: If the update request fails.", + "parameters": [ + { + "name": "new_state", + "type": "OperationState", + "options": [ + { + "name": "PROCESSING", + "value": 0 + }, + { + "name": "ACCEPTED", + "value": 1 + }, + { + "name": "ACTIVATED", + "value": 2 + }, + { + "name": "NONCONFORMING", + "value": 3 + }, + { + "name": "CONTINGENT", + "value": 4 + }, + { + "name": "ENDED", + "value": 5 + }, + { + "name": "WITHDRAWN", + "value": 6 + }, + { + "name": "CANCELLED", + "value": 7 + }, + { + "name": "REJECTED", + "value": 8 + } + ], + "isEnum": true + }, + { + "name": "duration_seconds", + "type": "int", + "default": 0 + } + ], + "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" + }, + { + "id": "FlightBlenderClient.submit_telemetry_from_file", + "name": "Submit Telemetry (from file)", + "functionName": "submit_telemetry_from_file", + "className": "FlightBlenderClient", + "description": "Submit telemetry data for a flight operation.\n\nLoads telemetry states from file and submits them sequentially, with optional\nduration limiting and error handling for rate limits.\n\nArgs:\n filename: Path to the JSON file containing telemetry data.\n duration_seconds: Optional maximum duration in seconds to submit telemetry (default 0 for unlimited).\n\nReturns:\n The JSON response from the last telemetry submission, or None if no submissions occurred.\n\nRaises:\n FlightBlenderError: If maximum waiting time is exceeded due to rate limits.", + "parameters": [ + { + "name": "filename", + "type": "str" + }, + { + "name": "duration_seconds", + "type": "int", + "default": 0 + } + ], + "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" + }, + { + "id": "FlightBlenderClient.wait_x_seconds", + "name": "Wait X seconds", + "functionName": "wait_x_seconds", + "className": "FlightBlenderClient", + "description": "Wait for a specified number of seconds.", + "parameters": [ + { + "name": "wait_time_seconds", + "type": "int", + "default": 5 + } + ], + "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" + }, + { + "id": "FlightBlenderClient.submit_telemetry", + "name": "Submit Telemetry", + "functionName": "submit_telemetry", + "className": "FlightBlenderClient", + "description": "Submit telemetry data for a flight operation from in-memory states.\n\nSubmits telemetry states sequentially from the provided list, with optional\nduration limiting and error handling for rate limits.\n\nArgs:\n states: List of telemetry state dictionaries. If None, uses the generated telemetry states from context.\n duration_seconds: Optional maximum duration in seconds to submit telemetry (default 0 for unlimited).\n\nReturns:\n The JSON response from the last telemetry submission, or None if no submissions occurred.\n\nRaises:\n FlightBlenderError: If maximum waiting time is exceeded due to rate limits.", + "parameters": [ + { + "name": "states", + "type": "Optional[List[Dict[Any]]]", + "default": null + }, + { + "name": "duration_seconds", + "type": "int", + "default": 0 + } + ], + "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" + }, + { + "id": "FlightBlenderClient.check_operation_state", + "name": "Check Operation State", + "functionName": "check_operation_state", + "className": "FlightBlenderClient", + "description": "Check the operation state (simulated).\n\nThis is a placeholder method for state checking; it simulates waiting\nand returns a success status.\n\nArgs:\n expected_state: The expected OperationState.\n duration_seconds: Seconds to wait for processing.\n\nReturns:\n A dictionary with the check result.", + "parameters": [ + { + "name": "expected_state", + "type": "OperationState", + "options": [ + { + "name": "PROCESSING", + "value": 0 + }, + { + "name": "ACCEPTED", + "value": 1 + }, + { + "name": "ACTIVATED", + "value": 2 + }, + { + "name": "NONCONFORMING", + "value": 3 + }, + { + "name": "CONTINGENT", + "value": 4 + }, + { + "name": "ENDED", + "value": 5 + }, + { + "name": "WITHDRAWN", + "value": 6 + }, + { + "name": "CANCELLED", + "value": 7 + }, + { + "name": "REJECTED", + "value": 8 + } + ], + "isEnum": true + }, + { + "name": "duration_seconds", + "type": "int", + "default": 0 + } + ], + "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" + }, + { + "id": "FlightBlenderClient.check_operation_state_connected", + "name": "Check Operation State Connected", + "functionName": "check_operation_state_connected", + "className": "FlightBlenderClient", + "description": "Check the operation state by polling the API until the expected state is reached.\n\nArgs:\n expected_state: The expected OperationState.\n duration_seconds: Maximum seconds to poll for the state.\n\nReturns:\n The JSON response from the API when the state is reached.\n\nRaises:\n FlightBlenderError: If the expected state is not reached within the timeout.", + "parameters": [ + { + "name": "expected_state", + "type": "OperationState", + "options": [ + { + "name": "PROCESSING", + "value": 0 + }, + { + "name": "ACCEPTED", + "value": 1 + }, + { + "name": "ACTIVATED", + "value": 2 + }, + { + "name": "NONCONFORMING", + "value": 3 + }, + { + "name": "CONTINGENT", + "value": 4 + }, + { + "name": "ENDED", + "value": 5 + }, + { + "name": "WITHDRAWN", + "value": 6 + }, + { + "name": "CANCELLED", + "value": 7 + }, + { + "name": "REJECTED", + "value": 8 + } + ], + "isEnum": true + }, + { + "name": "duration_seconds", + "type": "int", + "default": 0 + } + ], + "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" + }, + { + "id": "FlightBlenderClient.delete_flight_declaration", + "name": "Delete Flight Declaration", + "functionName": "delete_flight_declaration", + "className": "FlightBlenderClient", + "description": "Delete a flight declaration by ID.\n\nReturns:\n A dictionary with deletion status, including whether it was successful.", + "parameters": [], + "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" + }, + { + "id": "FlightBlenderClient.submit_simulated_air_traffic", + "name": "Submit Simulated Air Traffic", + "functionName": "submit_simulated_air_traffic", + "className": "FlightBlenderClient", + "description": null, + "parameters": [ + { + "name": "observations", + "type": "List[List[Dict[Any]]]" + }, + { + "name": "single_or_multiple_sensors", + "type": "str", + "default": "single" + } + ], + "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" + }, + { + "id": "FlightBlenderClient.submit_air_traffic", + "name": "Submit Air Traffic", + "functionName": "submit_air_traffic", + "className": "FlightBlenderClient", + "description": "Submit air traffic observations to the Flight Blender API.\n\nArgs:\n observations: List of observation dictionaries containing flight data.\n\nReturns:\n The JSON response from the API.\n\nRaises:\n FlightBlenderError: If the submission request fails.", + "parameters": [ + { + "name": "observations", + "type": "List[Dict[Any]]" + } + ], + "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" + }, + { + "id": "FlightBlenderClient.start_stop_sdsp_session", + "name": "Start / Stop SDSP Session", + "functionName": "start_stop_sdsp_session", + "className": "FlightBlenderClient", + "description": "Starts or stops an SDSP (Strategic Deconfliction Service Provider) session based on the specified action.\nThis method interacts with the Flight Blender service to manage the lifecycle of an SDSP session.\nIt can be used to initiate a new session or terminate an existing one.\nArgs:\n session_id (str): The unique identifier of the SDSP session to start or stop.\n action (SDSPSessionAction): The action to perform on the session, such as START or STOP.\nReturns:\n bool: True if the action was successfully performed, False otherwise.\nRaises:\n ValueError: If the session_id is invalid or the action is not supported.\n ConnectionError: If there is an issue communicating with the Flight Blender service.\n FlightBlenderError: If the action fails due to service errors.", + "parameters": [ + { + "name": "session_id", + "type": "str" + }, + { + "name": "action", + "type": "SDSPSessionAction", + "options": [ + { + "name": "START", + "value": "start" + }, + { + "name": "STOP", + "value": "stop" + } + ], + "isEnum": true + } + ], + "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" + }, + { + "id": "FlightBlenderClient.initialize_verify_sdsp_track", + "name": "Verify SDSP Track", + "functionName": "initialize_verify_sdsp_track", + "className": "FlightBlenderClient", + "description": null, + "parameters": [ + { + "name": "expected_heartbeat_interval_seconds", + "type": "int" + }, + { + "name": "expected_heartbeat_count", + "type": "int" + }, + { + "name": "session_id", + "type": "str" + } + ], + "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" + }, + { + "id": "FlightBlenderClient.initialize_verify_sdsp_heartbeat", + "name": "Verify SDSP Heartbeat", + "functionName": "initialize_verify_sdsp_heartbeat", + "className": "FlightBlenderClient", + "description": null, + "parameters": [ + { + "name": "expected_heartbeat_interval_seconds", + "type": "int" + }, + { + "name": "expected_heartbeat_count", + "type": "int" + }, + { + "name": "session_id", + "type": "str" + } + ], + "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" + }, + { + "id": "FlightBlenderClient.setup_flight_declaration", + "name": "Setup Flight Declaration", + "functionName": "setup_flight_declaration", + "className": "FlightBlenderClient", + "description": "Generates data and uploads flight declaration.", + "parameters": [ + { + "name": "flight_declaration_path", + "type": "str" + }, + { + "name": "telemetry_path", + "type": "str" + } + ], + "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" + }, + { + "id": "AirTrafficClient.generate_simulated_air_traffic_data", + "name": "Generate Simulated Air Traffic Data", + "functionName": "generate_simulated_air_traffic_data", + "className": "AirTrafficClient", + "description": "Generate simulated air traffic data from GeoJSON configuration.\n\nLoads GeoJSON data from the specified config path and uses it to generate\nsimulated flight observations for the given duration. If no config path\nor duration is provided, uses the default settings from the client configuration.\n\nArgs:\n config_path: Path to the GeoJSON configuration file. Defaults to settings value.\n duration: Duration in seconds for which to generate data. Defaults to settings value.\n\nReturns:\n List of simulated flight observation dictionaries, or None if generation fails.", + "parameters": [ + { + "name": "config_path", + "type": "Optional[str]", + "default": null + }, + { + "name": "duration", + "type": "Optional[int]", + "default": null + } + ], + "filePath": "src/openutm_verification/core/clients/air_traffic/air_traffic_client.py" + }, + { + "id": "OpenSkyClient.fetch_data", + "name": "Fetch OpenSky Data", + "functionName": "fetch_data", + "className": "OpenSkyClient", + "description": "Fetch and process live flight data from OpenSky Network.\n\nRetrieves current flight states from the OpenSky API within the configured\nviewport bounds and processes them into standardized observation format.\n\nReturns:\n List of flight observation dictionaries, or None if no data is available.", + "parameters": [], + "filePath": "src/openutm_verification/core/clients/opensky/opensky_client.py" + } +] diff --git a/web-editor/src/hooks/__tests__/useScenarioFile.test.ts b/web-editor/src/hooks/__tests__/useScenarioFile.test.ts new file mode 100644 index 0000000..5ffcae7 --- /dev/null +++ b/web-editor/src/hooks/__tests__/useScenarioFile.test.ts @@ -0,0 +1,96 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useScenarioFile } from '../useScenarioFile'; +import type { Node, Edge, ReactFlowInstance } from '@xyflow/react'; +import type { NodeData } from '../../types/scenario'; + +describe('useScenarioFile', () => { + const mockNodes: Node[] = [ + { id: '1', position: { x: 0, y: 0 }, data: { label: 'Node 1' } } + ]; + const mockEdges: Edge[] = []; + const setNodes = vi.fn(); + const setEdges = vi.fn(); + const mockReactFlowInstance = { + getViewport: vi.fn().mockReturnValue({ x: 0, y: 0, zoom: 1 }), + } as unknown as ReactFlowInstance, Edge>; + + beforeEach(() => { + globalThis.URL.createObjectURL = vi.fn(); + globalThis.URL.revokeObjectURL = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('exports JSON correctly', () => { + const { result } = renderHook(() => useScenarioFile(mockNodes, mockEdges, setNodes, setEdges, mockReactFlowInstance)); + + // Mock document.createElement and click + const mockLink = { + href: '', + download: '', + click: vi.fn(), + remove: vi.fn(), + }; + const createElementSpy = vi.spyOn(document, 'createElement').mockReturnValue(mockLink as unknown as HTMLAnchorElement); + const appendChildSpy = vi.spyOn(document.body, 'appendChild').mockImplementation(() => mockLink as unknown as HTMLAnchorElement); + + act(() => { + result.current.handleExportJSON(); + }); + + expect(createElementSpy).toHaveBeenCalledWith('a'); + expect(appendChildSpy).toHaveBeenCalled(); + expect(mockLink.click).toHaveBeenCalled(); + expect(mockLink.remove).toHaveBeenCalled(); + expect(globalThis.URL.createObjectURL).toHaveBeenCalled(); + }); + + it('loads JSON correctly', async () => { + const { result } = renderHook(() => useScenarioFile(mockNodes, mockEdges, setNodes, setEdges, mockReactFlowInstance)); + + const mockInput = { + click: vi.fn(), + }; + + const fileInputRef = result.current.fileInputRef; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + fileInputRef.current = mockInput; + + act(() => { + result.current.handleLoadJSON(); + }); + + expect(mockInput.click).toHaveBeenCalled(); + }); + + it('handles file change', async () => { + const { result } = renderHook(() => useScenarioFile(mockNodes, mockEdges, setNodes, setEdges, mockReactFlowInstance)); + + const fileContent = JSON.stringify({ + nodes: [{ id: '2', position: { x: 10, y: 10 }, data: { label: 'Node 2' } }], + edges: [] + }); + const file = new File([fileContent], 'scenario.json', { type: 'application/json' }); + // Mock text() method which might be missing in jsdom/node File implementation + file.text = vi.fn().mockResolvedValue(fileContent); + + const event = { + target: { + files: [file] + } + } as unknown as React.ChangeEvent; + + await act(async () => { + await result.current.handleFileChange(event); + }); + + expect(setNodes).toHaveBeenCalledWith(expect.arrayContaining([ + expect.objectContaining({ id: '2' }) + ])); + expect(setEdges).toHaveBeenCalledWith([]); + }); +}); diff --git a/web-editor/src/hooks/__tests__/useScenarioGraph.test.tsx b/web-editor/src/hooks/__tests__/useScenarioGraph.test.tsx new file mode 100644 index 0000000..d655e99 --- /dev/null +++ b/web-editor/src/hooks/__tests__/useScenarioGraph.test.tsx @@ -0,0 +1,43 @@ +import { renderHook } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { useScenarioGraph } from '../useScenarioGraph'; +import { ReactFlowProvider } from '@xyflow/react'; +import React from 'react'; + +// Mock dagre +vi.mock('@dagrejs/dagre', () => ({ + default: { + graphlib: { + Graph: class { + setDefaultEdgeLabel() { return; } + setGraph() { return; } + setNode() { return; } + setEdge() { return; } + node() { return { x: 0, y: 0 }; } + } + }, + layout: vi.fn() + } +})); + +describe('useScenarioGraph', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + it('initializes with empty nodes and edges', () => { + const { result } = renderHook(() => useScenarioGraph(), { wrapper }); + expect(result.current.nodes).toEqual([]); + expect(result.current.edges).toEqual([]); + }); + + it('returns required functions', () => { + const { result } = renderHook(() => useScenarioGraph(), { wrapper }); + expect(result.current.onNodesChange).toBeDefined(); + expect(result.current.onEdgesChange).toBeDefined(); + expect(result.current.onConnect).toBeDefined(); + expect(result.current.onDrop).toBeDefined(); + expect(result.current.onLayout).toBeDefined(); + expect(result.current.clearGraph).toBeDefined(); + }); +}); diff --git a/web-editor/src/hooks/__tests__/useScenarioRunner.test.ts b/web-editor/src/hooks/__tests__/useScenarioRunner.test.ts new file mode 100644 index 0000000..cabbc4e --- /dev/null +++ b/web-editor/src/hooks/__tests__/useScenarioRunner.test.ts @@ -0,0 +1,77 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import { useScenarioRunner } from '../useScenarioRunner'; +import type { Node, Edge } from '@xyflow/react'; +import type { NodeData } from '../../types/scenario'; + +describe('useScenarioRunner', () => { + beforeEach(() => { + globalThis.fetch = vi.fn(); + globalThis.alert = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('initializes with isRunning false', () => { + const { result } = renderHook(() => useScenarioRunner()); + expect(result.current.isRunning).toBe(false); + }); + + it('runs scenario successfully', async () => { + const mockResult = { success: true, logs: [] }; + (globalThis.fetch as Mock).mockResolvedValue({ + ok: true, + json: async () => mockResult + }); + + const { result } = renderHook(() => useScenarioRunner()); + + const nodes: Node[] = [ + { id: '1', position: { x: 0, y: 0 }, data: { label: 'Node 1', operationId: 'Class.method', className: 'Class', functionName: 'method', parameters: [] } } + ]; + const edges: Edge[] = []; + + let executionResult; + await act(async () => { + executionResult = await result.current.runScenario(nodes, edges); + }); + + expect(executionResult).toEqual(mockResult); + expect(globalThis.fetch).toHaveBeenCalledWith('http://localhost:8989/run-scenario', expect.any(Object)); + expect(result.current.isRunning).toBe(false); + }); + + it('handles fetch error', async () => { + (globalThis.fetch as Mock).mockRejectedValue(new Error('Network error')); + + const { result } = renderHook(() => useScenarioRunner()); + + const nodes: Node[] = [ + { id: '1', position: { x: 0, y: 0 }, data: { label: 'Node 1', operationId: 'Class.method', className: 'Class', functionName: 'method', parameters: [] } } + ]; + const edges: Edge[] = []; + + let executionResult; + await act(async () => { + executionResult = await result.current.runScenario(nodes, edges); + }); + + expect(executionResult).toBeNull(); + expect(globalThis.alert).toHaveBeenCalled(); + expect(result.current.isRunning).toBe(false); + }); + + it('handles empty nodes', async () => { + const { result } = renderHook(() => useScenarioRunner()); + + let executionResult; + await act(async () => { + executionResult = await result.current.runScenario([], []); + }); + + expect(executionResult).toBeNull(); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/web-editor/src/hooks/__tests__/useSidebarResize.test.ts b/web-editor/src/hooks/__tests__/useSidebarResize.test.ts new file mode 100644 index 0000000..f3f1e9c --- /dev/null +++ b/web-editor/src/hooks/__tests__/useSidebarResize.test.ts @@ -0,0 +1,85 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { useSidebarResize } from '../useSidebarResize'; + +describe('useSidebarResize', () => { + it('initializes with default width', () => { + const { result } = renderHook(() => useSidebarResize()); + expect(result.current.sidebarWidth).toBe(400); + expect(result.current.isResizing).toBe(false); + }); + + it('starts resizing', () => { + const { result } = renderHook(() => useSidebarResize()); + act(() => { + result.current.startResizing(); + }); + expect(result.current.isResizing).toBe(true); + }); + + it('resizes on mouse move', () => { + const { result } = renderHook(() => useSidebarResize()); + + act(() => { + result.current.startResizing(); + }); + + // Mock document.body.clientWidth + Object.defineProperty(document.body, 'clientWidth', { value: 1000, configurable: true }); + + act(() => { + const mouseEvent = new MouseEvent('mousemove', { clientX: 600 }); + globalThis.dispatchEvent(mouseEvent); + }); + + // New width = 1000 - 600 = 400. Wait, default is 400. + // Let's try clientX = 500. New width = 500. + + act(() => { + const mouseEvent = new MouseEvent('mousemove', { clientX: 500 }); + globalThis.dispatchEvent(mouseEvent); + }); + + expect(result.current.sidebarWidth).toBe(500); + }); + + it('stops resizing on mouse up', () => { + const { result } = renderHook(() => useSidebarResize()); + + act(() => { + result.current.startResizing(); + }); + expect(result.current.isResizing).toBe(true); + + act(() => { + const mouseEvent = new MouseEvent('mouseup'); + globalThis.dispatchEvent(mouseEvent); + }); + + expect(result.current.isResizing).toBe(false); + }); + + it('respects min and max width', () => { + const { result } = renderHook(() => useSidebarResize(400, 200, 800)); + + act(() => { + result.current.startResizing(); + }); + + Object.defineProperty(document.body, 'clientWidth', { value: 1000, configurable: true }); + + // Try to resize to 100 (below min 200) -> clientX = 900 + act(() => { + const mouseEvent = new MouseEvent('mousemove', { clientX: 900 }); + globalThis.dispatchEvent(mouseEvent); + }); + expect(result.current.sidebarWidth).toBe(400); // Should not change + + // Try to resize to 900 (above max 800) -> clientX = 100 + act(() => { + const mouseEvent = new MouseEvent('mousemove', { clientX: 100 }); + globalThis.dispatchEvent(mouseEvent); + }); + expect(result.current.sidebarWidth).toBe(400); // Should not change + }); +}); diff --git a/web-editor/src/hooks/useScenarioFile.ts b/web-editor/src/hooks/useScenarioFile.ts new file mode 100644 index 0000000..3be01a3 --- /dev/null +++ b/web-editor/src/hooks/useScenarioFile.ts @@ -0,0 +1,87 @@ +import { useCallback, useRef } from 'react'; +import type { Node, Edge, ReactFlowInstance } from '@xyflow/react'; +import type { NodeData } from '../types/scenario'; + +export const useScenarioFile = ( + nodes: Node[], + edges: Edge[], + setNodes: (nodes: Node[]) => void, + setEdges: (edges: Edge[]) => void, + reactFlowInstance: ReactFlowInstance, Edge> | null +) => { + const fileInputRef = useRef(null); + + const handleExportJSON = useCallback(() => { + // Remove style and width from nodes to keep export clean + const cleanNodes = nodes.map((node) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { style, ...rest } = node; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { width: _width, ...data } = rest.data as { width?: number;[key: string]: unknown }; + return { + ...rest, + data, + }; + }); + + const flowData = { + nodes: cleanNodes, + edges, + viewport: reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 }, + }; + + const dataStr = JSON.stringify(flowData, null, 2); + const dataBlob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(dataBlob); + const link = document.createElement('a'); + link.href = url; + link.download = `scenario_${new Date().toISOString().replaceAll(/[:.]/g, '-')}.json`; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + }, [nodes, edges, reactFlowInstance]); + + const handleLoadJSON = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileChange = useCallback(async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + try { + const content = await file.text(); + const flowData = JSON.parse(content); + + if (flowData.nodes) { + // Apply custom node type to imported nodes + const nodesWithStyle = flowData.nodes.map((node: Node) => ({ + ...node, + type: 'custom', + style: undefined, // Remove hardcoded style + data: { + ...node.data, + } + })); + setNodes(nodesWithStyle); + } + if (flowData.edges) { + setEdges(flowData.edges); + } + if (flowData.viewport && reactFlowInstance) { + reactFlowInstance.setViewport(flowData.viewport); + } + } catch (error) { + console.error('Error parsing JSON file:', error); + alert('Error loading file. Please ensure it is a valid JSON scenario file.'); + } + + // Reset file input to allow loading the same file again + if (event.target) { + event.target.value = ''; + } + }, [setNodes, setEdges, reactFlowInstance]); + + return { fileInputRef, handleExportJSON, handleLoadJSON, handleFileChange }; +}; diff --git a/web-editor/src/hooks/useScenarioGraph.ts b/web-editor/src/hooks/useScenarioGraph.ts new file mode 100644 index 0000000..4cf9f54 --- /dev/null +++ b/web-editor/src/hooks/useScenarioGraph.ts @@ -0,0 +1,136 @@ +import { useCallback, useState } from 'react'; +import { + useNodesState, + useEdgesState, + addEdge, + type Connection, + type Edge, + type Node, + type ReactFlowInstance, + MarkerType, +} from '@xyflow/react'; +import dagre from '@dagrejs/dagre'; +import type { Operation, NodeData } from '../types/scenario'; + +const nodeWidth = 180; +const nodeHeight = 80; + +const getLayoutedElements = (nodes: Node[], edges: Edge[], direction = 'TB') => { + const dagreGraph = new dagre.graphlib.Graph(); + dagreGraph.setDefaultEdgeLabel(() => ({})); + dagreGraph.setGraph({ rankdir: direction, nodesep: 50, ranksep: 100 }); + + for (const node of nodes) { + dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight }); + } + + for (const edge of edges) { + dagreGraph.setEdge(edge.source, edge.target); + } + + dagre.layout(dagreGraph); + + const layoutedNodes = nodes.map((node) => { + const nodeWithPosition = dagreGraph.node(node.id); + return { + ...node, + position: { + x: nodeWithPosition.x - nodeWidth / 2, + y: nodeWithPosition.y - nodeHeight / 2, + }, + }; + }); + + return { nodes: layoutedNodes, edges }; +}; + +let id = 0; +const getId = () => `dndnode_${id++}`; + +export const useScenarioGraph = () => { + const [nodes, setNodes, onNodesChange] = useNodesState>([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [reactFlowInstance, setReactFlowInstance] = useState, Edge> | null>(null); + + const onConnect = useCallback( + (params: Connection) => setEdges((eds) => addEdge({ + ...params, + animated: true, + style: { stroke: 'var(--accent-primary)', strokeWidth: 1 }, + markerEnd: { type: MarkerType.ArrowClosed, color: 'var(--accent-primary)' } + }, eds)), + [setEdges], + ); + + const onDrop = useCallback( + (event: React.DragEvent, operations: Operation[]) => { + event.preventDefault(); + + const type = event.dataTransfer.getData('application/reactflow'); + const opId = event.dataTransfer.getData('application/reactflow/id'); + + if (!type || !reactFlowInstance) { + return; + } + + const position = reactFlowInstance.screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + + const operation = operations.find(op => op.id === opId); + + const newNode: Node = { + id: getId(), + type: 'custom', + position, + data: { + label: type, + operationId: opId, + className: operation?.className, + functionName: operation?.functionName, + description: operation?.description, + parameters: operation?.parameters ? JSON.parse(JSON.stringify(operation.parameters)) : [], // Deep copy parameters + }, + }; + + setNodes((nds) => nds.concat(newNode)); + }, + [reactFlowInstance, setNodes], + ); + + const onLayout = useCallback(() => { + const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements( + nodes, + edges, + 'TB' + ); + + setNodes([...layoutedNodes]); + setEdges([...layoutedEdges]); + + globalThis.requestAnimationFrame(() => { + reactFlowInstance?.fitView({ padding: 0.2, duration: 400 }); + }); + }, [nodes, edges, setNodes, setEdges, reactFlowInstance]); + + const clearGraph = useCallback(() => { + setNodes([]); + setEdges([]); + }, [setNodes, setEdges]); + + return { + nodes, + edges, + setNodes, + setEdges, + onNodesChange, + onEdgesChange, + onConnect, + onDrop, + onLayout, + clearGraph, + reactFlowInstance, + setReactFlowInstance, + }; +}; diff --git a/web-editor/src/hooks/useScenarioRunner.ts b/web-editor/src/hooks/useScenarioRunner.ts new file mode 100644 index 0000000..ffcd746 --- /dev/null +++ b/web-editor/src/hooks/useScenarioRunner.ts @@ -0,0 +1,95 @@ +import { useState, useCallback } from 'react'; +import type { Node, Edge } from '@xyflow/react'; +import type { NodeData, ScenarioStep, ScenarioExecutionResult } from '../types/scenario'; + +export const useScenarioRunner = () => { + const [isRunning, setIsRunning] = useState(false); + + const runScenario = useCallback(async (nodes: Node[], edges: Edge[]) => { + if (nodes.length === 0) return null; + + // Simple topological sort / path following + const incomingEdges = new Set(edges.map(e => e.target)); + const startNodes = nodes.filter(n => !incomingEdges.has(n.id)); + + if (startNodes.length === 0) { + alert("Cycle detected or no start node found."); + return null; + } + + setIsRunning(true); + + const sortedNodes: Node[] = []; + const queue = [...startNodes]; + const visited = new Set(); + + while (queue.length > 0) { + const node = queue.shift()!; + if (visited.has(node.id)) continue; + visited.add(node.id); + sortedNodes.push(node); + + const outgoing = edges.filter(e => e.source === node.id); + for (const edge of outgoing) { + const targetNode = nodes.find(n => n.id === edge.target); + if (targetNode) { + queue.push(targetNode); + } + } + } + + const steps: ScenarioStep[] = sortedNodes + .filter(node => node.data.operationId) // Filter out nodes without operationId (like Start node) + .map(node => { + const params = (node.data.parameters || []).reduce((acc, param) => { + if (param.default !== undefined && param.default !== null && param.default !== '') { + acc[param.name] = param.default; + } + return acc; + }, {} as Record); + + let className = node.data.className as string; + let functionName = node.data.functionName as string; + + // Fallback to parsing operationId if className/functionName are missing + if ((!className || !functionName) && typeof node.data.operationId === 'string') { + const parts = node.data.operationId.split('.'); + if (parts.length === 2) { + [className, functionName] = parts; + } + } + + return { + id: node.id, + className, + functionName, + parameters: params + }; + }); + + try { + console.log('Sending scenario steps:', steps); + const response = await fetch('http://localhost:8989/run-scenario', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ steps }) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const result: ScenarioExecutionResult = await response.json(); + return result; + + } catch (error) { + console.error('Error running scenario:', error); + alert('Failed to run scenario. Is the backend server running?'); + return null; + } finally { + setIsRunning(false); + } + }, []); + + return { isRunning, runScenario }; +}; diff --git a/web-editor/src/hooks/useSidebarResize.ts b/web-editor/src/hooks/useSidebarResize.ts new file mode 100644 index 0000000..f2f93f7 --- /dev/null +++ b/web-editor/src/hooks/useSidebarResize.ts @@ -0,0 +1,34 @@ +import { useState, useCallback, useEffect } from 'react'; + +export const useSidebarResize = (initialWidth = 400, minWidth = 200, maxWidth = 800) => { + const [sidebarWidth, setSidebarWidth] = useState(initialWidth); + const [isResizing, setIsResizing] = useState(false); + + const startResizing = useCallback(() => { + setIsResizing(true); + }, []); + + const stopResizing = useCallback(() => { + setIsResizing(false); + }, []); + + const resize = useCallback((mouseMoveEvent: MouseEvent) => { + if (isResizing) { + const newWidth = document.body.clientWidth - mouseMoveEvent.clientX; + if (newWidth > minWidth && newWidth < maxWidth) { + setSidebarWidth(newWidth); + } + } + }, [isResizing, minWidth, maxWidth]); + + useEffect(() => { + globalThis.addEventListener("mousemove", resize); + globalThis.addEventListener("mouseup", stopResizing); + return () => { + globalThis.removeEventListener("mousemove", resize); + globalThis.removeEventListener("mouseup", stopResizing); + }; + }, [resize, stopResizing]); + + return { sidebarWidth, isResizing, startResizing }; +}; diff --git a/web-editor/src/main.tsx b/web-editor/src/main.tsx new file mode 100644 index 0000000..24d17b6 --- /dev/null +++ b/web-editor/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './styles/theme.css' +import './styles/global.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/web-editor/src/styles/Button.module.css b/web-editor/src/styles/Button.module.css new file mode 100644 index 0000000..8c379dc --- /dev/null +++ b/web-editor/src/styles/Button.module.css @@ -0,0 +1,58 @@ +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 16px; + border-radius: var(--radius-md); + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + background-color: transparent; + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.button:hover { + background-color: var(--bg-tertiary); + border-color: var(--text-secondary); +} + +.button:active { + transform: translateY(1px); +} + +.primary { + background-color: var(--accent-primary); + color: white; + border: 1px solid transparent; + box-shadow: var(--shadow-sm); +} + +.primary:hover { + background-color: var(--accent-hover); + box-shadow: var(--shadow-md); + border-color: transparent; +} + +.iconButton { + padding: 8px; + border-radius: var(--radius-md); + color: var(--text-secondary); + border: 1px solid transparent; +} + +.iconButton:hover { + background-color: var(--bg-tertiary); + color: var(--text-primary); +} + +.danger { + color: var(--danger); + border-color: var(--danger); +} + +.danger:hover { + background-color: var(--danger); + color: white; +} diff --git a/web-editor/src/styles/EditorLayout.module.css b/web-editor/src/styles/EditorLayout.module.css new file mode 100644 index 0000000..d450274 --- /dev/null +++ b/web-editor/src/styles/EditorLayout.module.css @@ -0,0 +1,60 @@ +.editorContainer { + width: 100%; + height: 100vh; + display: flex; + flex-direction: column; + background-color: var(--bg-primary); +} + +.workspace { + flex: 1; + display: flex; + overflow: hidden; + position: relative; + background-color: var(--bg-secondary); + /* Slightly different bg for contrast */ +} + +.sidebar { + width: 280px; + background-color: var(--bg-primary); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + z-index: 10; + transition: background-color 0.3s ease, border-color 0.3s ease; +} + +.rightSidebar { + width: 320px; + /* Slightly wider for better readability */ + background-color: var(--bg-primary); + border-left: 1px solid var(--border-color); + display: flex; + flex-direction: column; + z-index: 10; + box-shadow: var(--shadow-lg); + /* Add shadow for depth */ + transition: background-color 0.3s ease, border-color 0.3s ease; +} + +.graphContainer { + flex: 1; + height: 100%; + background-color: var(--rf-bg); + transition: background-color 0.3s ease; +} + +.sidebarHeader { + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); + font-weight: 600; + font-size: 13px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + display: flex; + justify-content: space-between; + align-items: center; + background-color: var(--bg-primary); +} diff --git a/web-editor/src/styles/Header.module.css b/web-editor/src/styles/Header.module.css new file mode 100644 index 0000000..7c317f1 --- /dev/null +++ b/web-editor/src/styles/Header.module.css @@ -0,0 +1,47 @@ + .header { + height: 60px; + background-color: var(--bg-primary); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; + z-index: 20; + box-shadow: var(--shadow-sm); + } + + .title { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 12px; + } + + .titleIcon { + color: var(--accent-primary); + } + + .actions { + display: flex; + align-items: center; + gap: 12px; + } + + .divider { + width: 1px; + height: 24px; + background-color: var(--border-color); + margin: 0 8px; + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } + } diff --git a/web-editor/src/styles/Node.module.css b/web-editor/src/styles/Node.module.css new file mode 100644 index 0000000..1cdcc4c --- /dev/null +++ b/web-editor/src/styles/Node.module.css @@ -0,0 +1,66 @@ +.customNode { + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 12px 16px; + min-width: 180px; + box-shadow: var(--shadow-md); + transition: all 0.2s ease; +} + +.customNode:hover { + box-shadow: var(--shadow-lg); + border-color: var(--accent-primary); +} + +.customNodeHeader { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; + font-weight: 600; + font-size: 14px; + color: var(--text-primary); +} + +.customNodeBody { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; +} + +.icon { + color: var(--text-secondary); +} + +.statusButton { + margin-left: auto; + cursor: pointer; + background: none; + border: none; + padding: 0; + display: flex; + align-items: center; + transition: transform 0.2s ease; +} + +.statusButton:hover { + transform: scale(1.1); +} + +/* Status indicators */ +.statusSuccess { + border-color: var(--success); + box-shadow: 0 0 0 1px var(--success); +} + +.statusError { + border-color: var(--danger); + box-shadow: 0 0 0 1px var(--danger); + background-color: rgba(239, 68, 68, 0.05); +} + +.selected { + border-color: var(--accent-primary); + box-shadow: 0 0 0 2px var(--accent-primary); +} diff --git a/web-editor/src/styles/PropertiesPanel.module.css b/web-editor/src/styles/PropertiesPanel.module.css new file mode 100644 index 0000000..b5ea64f --- /dev/null +++ b/web-editor/src/styles/PropertiesPanel.module.css @@ -0,0 +1,148 @@ +.propertiesPanel { + display: flex; + flex-direction: column; + height: 100%; + background-color: var(--bg-primary); +} + +.propertiesContent { + padding: 20px; + overflow-y: auto; + flex: 1; +} + +.propertiesContent h3 { + font-size: 18px; + font-weight: 600; + margin-bottom: 12px; + color: var(--text-primary); + letter-spacing: -0.02em; +} + +.propertiesContent p { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 24px; + line-height: 1.5; +} + +.propertiesContent h4 { + font-size: 12px; + margin-bottom: 12px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; +} + +.paramItem { + margin-bottom: 20px; +} + +.paramItem label { + display: block; + font-size: 13px; + font-weight: 500; + margin-bottom: 8px; + color: var(--text-primary); +} + +.paramType { + color: var(--accent-primary); + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + margin-left: 6px; + opacity: 0.8; +} + +.paramInput { + width: 100%; + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 10px 12px; + border-radius: var(--radius-md); + font-size: 14px; + font-family: inherit; + transition: all 0.2s ease; +} + +.paramInput:focus { + border-color: var(--accent-primary); + background-color: var(--bg-primary); + outline: none; + box-shadow: 0 0 0 2px var(--accent-light); +} + +.closeButton { + float: right; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + border-radius: var(--radius-sm); + transition: all 0.2s ease; +} + +.closeButton:hover { + color: var(--text-primary); + background-color: var(--bg-tertiary); +} + +.docstring { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 24px; + padding: 16px; + background-color: var(--bg-secondary); + border-radius: var(--radius-md); + border: 1px solid var(--border-color); +} + +.docSummary { + margin-bottom: 16px; + white-space: pre-wrap; + line-height: 1.6; +} + +.docSection { + margin-top: 16px; +} + +.docSection strong { + display: block; + color: var(--text-primary); + font-size: 12px; + font-weight: 600; + margin-bottom: 4px; +} + +.docContent { + white-space: pre-wrap; + margin-top: 4px; + font-family: 'JetBrains Mono', monospace; + background: var(--bg-primary); + padding: 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); + font-size: 13px; +} + +.iconButton { + background: none; + border: none; + cursor: pointer; + color: var(--text-secondary); + padding: 6px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.iconButton:hover { + background-color: var(--bg-tertiary); + color: var(--text-primary); +} diff --git a/web-editor/src/styles/ResultPanel.module.css b/web-editor/src/styles/ResultPanel.module.css new file mode 100644 index 0000000..5d45bce --- /dev/null +++ b/web-editor/src/styles/ResultPanel.module.css @@ -0,0 +1,55 @@ +.resizeHandle { + width: 4px; + cursor: col-resize; + background-color: transparent; + transition: background-color 0.2s; + position: absolute; + left: 0; + top: 0; + bottom: 0; + z-index: 20; + border: none; + padding: 0; +} + +.resizeHandle:hover, +.resizeHandleActive { + background-color: var(--accent-primary); +} + +.jsonContainer { + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + white-space: pre-wrap; + word-break: break-word; + background-color: var(--bg-primary); + padding: 16px; + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + overflow: auto; + height: 100%; + line-height: 1.5; + color: var(--text-primary); +} + +.jsonKey { + color: var(--text-primary); + font-weight: 600; +} + +.jsonString { + color: var(--success); +} + +.jsonNumber { + color: var(--accent-primary); +} + +.jsonBoolean { + color: var(--danger); +} + +.jsonNull { + color: var(--text-secondary); + font-style: italic; +} diff --git a/web-editor/src/styles/SidebarPanel.module.css b/web-editor/src/styles/SidebarPanel.module.css new file mode 100644 index 0000000..d3b6517 --- /dev/null +++ b/web-editor/src/styles/SidebarPanel.module.css @@ -0,0 +1,148 @@ +.panel { + display: flex; + flex-direction: column; + height: 100%; + background-color: var(--bg-primary); +} + +.content { + padding: 20px; + overflow-y: auto; + flex: 1; +} + +.content h3 { + font-size: 18px; + font-weight: 600; + margin-bottom: 12px; + color: var(--text-primary); + letter-spacing: -0.02em; +} + +.content p { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 24px; + line-height: 1.5; +} + +.content h4 { + font-size: 12px; + margin-bottom: 12px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; +} + +.paramItem { + margin-bottom: 20px; +} + +.paramItem label { + display: block; + font-size: 13px; + font-weight: 500; + margin-bottom: 8px; + color: var(--text-primary); +} + +.paramType { + color: var(--accent-primary); + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + margin-left: 6px; + opacity: 0.8; +} + +.paramInput { + width: 100%; + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 10px 12px; + border-radius: var(--radius-md); + font-size: 14px; + font-family: inherit; + transition: all 0.2s ease; +} + +.paramInput:focus { + border-color: var(--accent-primary); + background-color: var(--bg-primary); + outline: none; + box-shadow: 0 0 0 2px var(--accent-light); +} + +.closeButton { + float: right; + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + border-radius: var(--radius-sm); + transition: all 0.2s ease; +} + +.closeButton:hover { + color: var(--text-primary); + background-color: var(--bg-tertiary); +} + +.docstring { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 24px; + padding: 16px; + background-color: var(--bg-secondary); + border-radius: var(--radius-md); + border: 1px solid var(--border-color); +} + +.docSummary { + margin-bottom: 16px; + white-space: pre-wrap; + line-height: 1.6; +} + +.docSection { + margin-top: 16px; +} + +.docSection strong { + display: block; + color: var(--text-primary); + font-size: 12px; + font-weight: 600; + margin-bottom: 4px; +} + +.docContent { + white-space: pre-wrap; + margin-top: 4px; + font-family: 'JetBrains Mono', monospace; + background: var(--bg-primary); + padding: 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); + font-size: 13px; +} + +.iconButton { + background: none; + border: none; + cursor: pointer; + color: var(--text-secondary); + padding: 6px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.iconButton:hover { + background-color: var(--bg-tertiary); + color: var(--text-primary); +} diff --git a/web-editor/src/styles/Toolbox.module.css b/web-editor/src/styles/Toolbox.module.css new file mode 100644 index 0000000..5c9f204 --- /dev/null +++ b/web-editor/src/styles/Toolbox.module.css @@ -0,0 +1,61 @@ +.nodeList { + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; + overflow-y: auto; + flex: 1; +} + +.groupHeader { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + margin-top: 8px; + margin-bottom: 8px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + user-select: none; + text-transform: uppercase; + letter-spacing: 0.05em; + transition: color 0.2s ease; +} + +.groupHeader:hover { + color: var(--text-primary); +} + +.groupContent { + display: flex; + flex-direction: column; + gap: 8px; + padding-left: 0; +} + +.nodeItem { + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + padding: 12px; + border-radius: var(--radius-md); + cursor: grab; + display: flex; + align-items: center; + gap: 12px; + transition: all 0.2s ease; + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + box-shadow: var(--shadow-sm); +} + +.nodeItem:hover { + border-color: var(--accent-primary); + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.nodeItem:active { + cursor: grabbing; +} diff --git a/web-editor/src/styles/global.css b/web-editor/src/styles/global.css new file mode 100644 index 0000000..5ac633f --- /dev/null +++ b/web-editor/src/styles/global.css @@ -0,0 +1,58 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-family); + background-color: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + overflow: hidden; + width: 100vw; + height: 100vh; + transition: background-color 0.3s ease, color 0.3s ease; +} + +button { + font-family: inherit; + cursor: pointer; + border: none; + outline: none; + transition: all 0.2s ease; +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Utilities */ +.glass-panel { + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); +} + +[data-theme='dark'] .glass-panel { + background: rgba(30, 41, 59, 0.7); + border: 1px solid rgba(255, 255, 255, 0.05); +} diff --git a/web-editor/src/styles/theme.css b/web-editor/src/styles/theme.css new file mode 100644 index 0000000..fbdb558 --- /dev/null +++ b/web-editor/src/styles/theme.css @@ -0,0 +1,63 @@ +:root { + /* Base Colors - Light */ + --bg-primary: #ffffff; + --bg-secondary: #f8fafc; /* Slate 50 */ + --bg-tertiary: #f1f5f9; /* Slate 100 */ + --text-primary: #0f172a; /* Slate 900 */ + --text-secondary: #64748b; /* Slate 500 */ + + /* Accents */ + --accent-primary: #3b82f6; /* Blue 500 */ + --accent-hover: #2563eb; /* Blue 600 */ + --accent-light: #eff6ff; /* Blue 50 */ + + /* Status */ + --success: #10b981; /* Emerald 500 */ + --danger: #ef4444; /* Red 500 */ + --warning: #f59e0b; /* Amber 500 */ + + /* Borders & UI */ + --border-color: #e2e8f0; /* Slate 200 */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + + --font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + + /* React Flow overrides */ + --rf-bg: #f8fafc; + --rf-node-bg: #ffffff; + --rf-node-color: #0f172a; + --rf-node-border: #e2e8f0; + --rf-handle: #64748b; +} + +[data-theme='dark'] { + /* Base Colors - Dark */ + --bg-primary: #0f172a; /* Slate 900 */ + --bg-secondary: #1e293b; /* Slate 800 */ + --bg-tertiary: #334155; /* Slate 700 */ + --text-primary: #f8fafc; /* Slate 50 */ + --text-secondary: #94a3b8; /* Slate 400 */ + + /* Accents */ + --accent-primary: #60a5fa; /* Blue 400 */ + --accent-hover: #93c5fd; /* Blue 300 */ + --accent-light: rgba(59, 130, 246, 0.1); + + /* Borders & UI */ + --border-color: #334155; /* Slate 700 */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3), 0 4px 6px -4px rgb(0 0 0 / 0.3); + + /* React Flow overrides */ + --rf-bg: #0f172a; + --rf-node-bg: #1e293b; + --rf-node-color: #f8fafc; + --rf-node-border: #334155; + --rf-handle: #94a3b8; +} diff --git a/web-editor/src/test/setup.ts b/web-editor/src/test/setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/web-editor/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/web-editor/src/types/scenario.ts b/web-editor/src/types/scenario.ts new file mode 100644 index 0000000..933326e --- /dev/null +++ b/web-editor/src/types/scenario.ts @@ -0,0 +1,48 @@ +export interface OperationParam { + name: string; + type: string; + default?: unknown; + options?: { name: string; value: unknown }[]; + isEnum?: boolean; +} + +export interface Operation { + id: string; + name: string; + functionName: string; + className: string; + description: string; + parameters: OperationParam[]; +} + +export interface NodeData extends Record { + label: string; + operationId?: string; + className?: string; + functionName?: string; + description?: string; + parameters?: OperationParam[]; + status?: 'success' | 'failure' | 'error'; + result?: unknown; + onShowResult?: (result: unknown) => void; +} + +export interface ScenarioStep { + id: string; + className: string; + functionName: string; + parameters: Record; +} + +export interface StepResult { + id: string; + status: 'success' | 'failure' | 'error'; + result: unknown; + error?: string; +} + +export interface ScenarioExecutionResult { + results: StepResult[]; + status: string; + duration: number; +} diff --git a/web-editor/tsconfig.app.json b/web-editor/tsconfig.app.json new file mode 100644 index 0000000..a9b5a59 --- /dev/null +++ b/web-editor/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/web-editor/tsconfig.json b/web-editor/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web-editor/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web-editor/tsconfig.node.json b/web-editor/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/web-editor/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/web-editor/vite.config.ts b/web-editor/vite.config.ts new file mode 100644 index 0000000..aa458c6 --- /dev/null +++ b/web-editor/vite.config.ts @@ -0,0 +1,13 @@ +/// +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + }, +}) From 9739ed7343b2d0befe0e42fd4482029f5caf74c4 Mon Sep 17 00:00:00 2001 From: Roman Pszonka Date: Fri, 5 Dec 2025 21:53:42 +0000 Subject: [PATCH 02/20] update to match latest verification code --- src/openutm_verification/server/runner.py | 15 ++++++++++++++- web-editor/src/data/operations.json | 8 ++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/openutm_verification/server/runner.py b/src/openutm_verification/server/runner.py index bd1bc6f..d4548ae 100644 --- a/src/openutm_verification/server/runner.py +++ b/src/openutm_verification/server/runner.py @@ -152,6 +152,18 @@ def _prepare_params(self, step: StepDefinition, context: Dict[str, Any], method: if "declaration" not in params and "filename" not in params: params["declaration"] = context["flight_declaration"] + # Handle parameter renaming for initialize_verify_sdsp_track + if step.functionName == "initialize_verify_sdsp_track": + if "expected_heartbeat_interval_seconds" in params: + params["expected_track_interval_seconds"] = params.pop("expected_heartbeat_interval_seconds") + if "expected_heartbeat_count" in params: + params["expected_track_count"] = params.pop("expected_heartbeat_count") + + # Handle parameter renaming for setup_flight_declaration + if step.functionName == "setup_flight_declaration": + if "telemetry_path" in params: + params["trajectory_path"] = params.pop("telemetry_path") + # Special handling for submit_telemetry if step.functionName == "submit_telemetry" and "states" not in params: params["states"] = context["telemetry_states"] @@ -271,7 +283,8 @@ def _run_implicit_setup(self, resolver: DependencyResolver, context: Dict[str, A def _run_implicit_teardown(self, resolver: DependencyResolver, context: Dict[str, Any]) -> Dict[str, Any]: logger.info(f"Implicit Teardown: Deleting Operation {context['operation_id']}") fb_client = cast(FlightBlenderClient, resolver.resolve(FlightBlenderClient)) - teardown_result = fb_client.delete_flight_declaration(context["operation_id"]) + # delete_flight_declaration uses the stored latest_flight_declaration_id in the client instance + teardown_result = fb_client.delete_flight_declaration() result_data = getattr(teardown_result, "model_dump")() if hasattr(teardown_result, "model_dump") else str(teardown_result) return {"step": "Teardown: Delete Flight Declaration", "status": "success", "result": result_data} diff --git a/web-editor/src/data/operations.json b/web-editor/src/data/operations.json index 2fac5d8..133820d 100644 --- a/web-editor/src/data/operations.json +++ b/web-editor/src/data/operations.json @@ -359,11 +359,11 @@ "description": null, "parameters": [ { - "name": "expected_heartbeat_interval_seconds", + "name": "expected_track_interval_seconds", "type": "int" }, { - "name": "expected_heartbeat_count", + "name": "expected_track_count", "type": "int" }, { @@ -407,7 +407,7 @@ "type": "str" }, { - "name": "telemetry_path", + "name": "trajectory_path", "type": "str" } ], @@ -442,4 +442,4 @@ "parameters": [], "filePath": "src/openutm_verification/core/clients/opensky/opensky_client.py" } -] +] \ No newline at end of file From 91da1f19de52394ab487b2fe6ca78310b4db53b9 Mon Sep 17 00:00:00 2001 From: Roman Pszonka Date: Tue, 23 Dec 2025 22:51:27 +0000 Subject: [PATCH 03/20] fix async --- scripts/generate_editor_metadata.py | 6 +- .../core/clients/system/system_client.py | 46 + .../core/execution/dependencies.py | 7 + .../core/execution/scenario_runner.py | 4 + src/openutm_verification/server/main.py | 81 +- src/openutm_verification/server/runner.py | 243 +++- uv.lock | 1194 ++++++++++------- web-editor/README.md | 35 +- web-editor/async_implementation_guide.md | 86 ++ .../scenario_2025-12-23T21-38-50-073Z.json | 400 ++++++ web-editor/examples/sdsp_track_async.json | 199 +++ web-editor/src/components/ScenarioEditor.tsx | 89 +- .../components/ScenarioEditor/CustomNode.tsx | 62 +- .../ScenarioEditor/PropertiesPanel.tsx | 22 +- .../src/components/ScenarioEditor/Toolbox.tsx | 5 +- web-editor/src/data/operations.json | 42 +- web-editor/src/hooks/useScenarioRunner.ts | 84 +- web-editor/src/styles/Node.module.css | 45 +- web-editor/src/styles/theme.css | 6 +- web-editor/src/types/scenario.ts | 4 +- 20 files changed, 2014 insertions(+), 646 deletions(-) create mode 100644 src/openutm_verification/core/clients/system/system_client.py create mode 100644 web-editor/async_implementation_guide.md create mode 100644 web-editor/examples/scenario_2025-12-23T21-38-50-073Z.json create mode 100644 web-editor/examples/sdsp_track_async.json diff --git a/scripts/generate_editor_metadata.py b/scripts/generate_editor_metadata.py index e1a749e..10e73e8 100644 --- a/scripts/generate_editor_metadata.py +++ b/scripts/generate_editor_metadata.py @@ -2,7 +2,7 @@ import json import os from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union def parse_type_annotation(annotation: Any) -> str: @@ -105,7 +105,7 @@ def extract_enums(file_path: Path) -> Dict[str, List[Dict[str, Any]]]: return enums -def extract_args(function_node: ast.FunctionDef, known_enums: Dict[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]: +def extract_args(function_node: Union[ast.FunctionDef, ast.AsyncFunctionDef], known_enums: Dict[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]: args = [] # Calculate defaults mapping @@ -138,7 +138,7 @@ def process_class_node(class_node: ast.ClassDef, file_path_str: str, known_enums steps = [] class_name = class_node.name for item in class_node.body: - if isinstance(item, ast.FunctionDef): + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): step_name = None # Check decorators for decorator in item.decorator_list: diff --git a/src/openutm_verification/core/clients/system/system_client.py b/src/openutm_verification/core/clients/system/system_client.py new file mode 100644 index 0000000..c8f9439 --- /dev/null +++ b/src/openutm_verification/core/clients/system/system_client.py @@ -0,0 +1,46 @@ +import asyncio +from typing import Any, Dict + +from loguru import logger + +from openutm_verification.core.execution.scenario_runner import scenario_step + + +class SystemClient: + """A client for system-level operations like task management.""" + + def __init__(self) -> None: + pass + + @scenario_step("Join Background Task") + async def join_task(self, task_id: str | Dict[str, Any]) -> Any: + """Wait for a background task to complete and return its result. + + Args: + task_id: The ID of the background task to join, or the result object from a background step. + + Returns: + The result of the background task. + """ + # This method is a placeholder. The actual implementation logic + # resides in the runner, which intercepts this call or handles + # the task lookup. However, to keep it clean, we can also + # implement it here if we pass the context or runner to the client. + # But since clients are generally stateless regarding the runner's session, + # we'll rely on the runner to inject the result or handle the logic. + # + # actually, the runner executes the step. If we want to await the task here, + # we need access to the task object. + # + # For now, let's assume the runner handles the 'join_task' logic specially + # OR we pass the session context to the client (which is not ideal). + # + # A better approach: The runner sees "SystemClient.join_task" and + # executes special logic. + # + # OR: We make the runner inject the task object into the parameters? + # + # Let's go with the runner handling it for now, but we need this method + # to exist for the decorator and introspection. + logger.info(f"Joining task {task_id}") + return {"status": "joined", "task_id": task_id} diff --git a/src/openutm_verification/core/execution/dependencies.py b/src/openutm_verification/core/execution/dependencies.py index dad22c0..a2ab43a 100644 --- a/src/openutm_verification/core/execution/dependencies.py +++ b/src/openutm_verification/core/execution/dependencies.py @@ -25,6 +25,7 @@ create_opensky_settings, ) from openutm_verification.core.clients.opensky.opensky_client import OpenSkyClient +from openutm_verification.core.clients.system.system_client import SystemClient from openutm_verification.core.execution.config_models import ( AppConfig, DataFiles, @@ -196,3 +197,9 @@ async def air_traffic_client( settings = create_air_traffic_settings() async with AirTrafficClient(settings) as air_traffic_client: yield air_traffic_client + + +@dependency(SystemClient) +async def system_client() -> AsyncGenerator[SystemClient, None]: + """Provides a SystemClient instance for dependency injection.""" + yield SystemClient() diff --git a/src/openutm_verification/core/execution/scenario_runner.py b/src/openutm_verification/core/execution/scenario_runner.py index 5d96c2e..9b958c7 100644 --- a/src/openutm_verification/core/execution/scenario_runner.py +++ b/src/openutm_verification/core/execution/scenario_runner.py @@ -208,6 +208,10 @@ async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> StepResult[Any]: except Exception as e: return handle_exception(e, start_time) + # Attach metadata for introspection + setattr(async_wrapper, "_is_scenario_step", True) + setattr(async_wrapper, "_step_name", step_name) + return async_wrapper return cast(StepDecorator, decorator) diff --git a/src/openutm_verification/server/main.py b/src/openutm_verification/server/main.py index 9208924..bde58e0 100644 --- a/src/openutm_verification/server/main.py +++ b/src/openutm_verification/server/main.py @@ -1,10 +1,11 @@ -from typing import Any, Dict, List +import inspect +from typing import Any, Dict, List, Optional, Union -from fastapi import FastAPI, HTTPException +from fastapi import Body, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, create_model -from openutm_verification.server.runner import DynamicRunner, ScenarioDefinition +from openutm_verification.server.runner import DynamicRunner, ScenarioDefinition, StepDefinition app = FastAPI() @@ -17,22 +18,90 @@ allow_headers=["*"], ) +runner = DynamicRunner() + @app.get("/") async def root(): return {"message": "OpenUTM Verification API is running"} +@app.get("/health") +async def health_check(): + return {"status": "ok"} + + +@app.get("/operations") +async def get_operations(): + return runner.get_available_operations() + + +@app.post("/session/reset") +async def reset_session(): + await runner.close_session() + await runner.initialize_session() + return {"status": "session_reset"} + + @app.post("/run-scenario") async def run_scenario(scenario: ScenarioDefinition): - runner = DynamicRunner() + # For full scenario run, we might want a fresh runner or use the session one? + # The original code created a new runner. Let's keep it that way for now, + # or use the global one but reset session. + # But DynamicRunner() creates a new instance. + local_runner = DynamicRunner() try: - results = runner.run_scenario(scenario) + results = await local_runner.run_scenario(scenario) return {"status": "completed", "results": results} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) +# Dynamic Route Generation +for class_name, client_class in runner.client_map.items(): + for name, method in inspect.getmembers(client_class): + if hasattr(method, "_is_scenario_step"): + step_name = getattr(method, "_step_name") + + # Create Pydantic model for parameters + sig = inspect.signature(method) + fields = {} + for param_name, param in sig.parameters.items(): + if param_name == "self": + continue + + annotation = param.annotation + if annotation == inspect.Parameter.empty: + annotation = Any + else: + # Allow Dict for references (e.g. {"$ref": "..."}) + annotation = Union[annotation, Dict[str, Any]] + + default = param.default + if default == inspect.Parameter.empty: + fields[param_name] = (annotation, ...) + else: + fields[param_name] = (annotation, default) + + # Create the model + RequestModel = create_model(f"{class_name}_{name}_Request", __config__=ConfigDict(arbitrary_types_allowed=True), **fields) + + # Define the route handler + # We need to capture class_name, name and RequestModel in the closure + def create_handler(req_model, c_name, f_name): + async def handler(body: req_model, run_in_background: bool = False, step_id: Optional[str] = None): + step_def = StepDefinition( + id=step_id, className=c_name, functionName=f_name, parameters=body.model_dump(), run_in_background=run_in_background + ) + return await runner.execute_single_step(step_def) + + return handler + + handler = create_handler(RequestModel, class_name, name) + + # Register the route + app.post(f"/api/{class_name}/{name}", response_model=Dict[str, Any], tags=[class_name], summary=step_name)(handler) + if __name__ == "__main__": import uvicorn diff --git a/src/openutm_verification/server/runner.py b/src/openutm_verification/server/runner.py index d4548ae..416b9a0 100644 --- a/src/openutm_verification/server/runner.py +++ b/src/openutm_verification/server/runner.py @@ -1,6 +1,8 @@ import inspect import json -from contextlib import ExitStack +import re +from contextlib import AsyncExitStack +from enum import Enum from pathlib import Path from typing import Any, Dict, List, Type, cast @@ -13,6 +15,7 @@ from openutm_verification.core.clients.air_traffic.air_traffic_client import AirTrafficClient from openutm_verification.core.clients.flight_blender.flight_blender_client import FlightBlenderClient from openutm_verification.core.clients.opensky.opensky_client import OpenSkyClient +from openutm_verification.core.clients.system.system_client import SystemClient from openutm_verification.core.execution.config_models import AppConfig, ConfigProxy from openutm_verification.core.execution.dependency_resolution import DependencyResolver from openutm_verification.core.reporting.reporting_models import Status, StepResult @@ -27,6 +30,7 @@ class StepDefinition(BaseModel): className: str functionName: str parameters: Dict[str, Any] + run_in_background: bool = False class ScenarioDefinition(BaseModel): @@ -41,7 +45,125 @@ def __init__(self, config_path: str = "config/default.yaml"): "FlightBlenderClient": FlightBlenderClient, "OpenSkyClient": OpenSkyClient, "AirTrafficClient": AirTrafficClient, + "SystemClient": SystemClient, } + self.session_stack: AsyncExitStack | None = None + self.session_resolver: DependencyResolver | None = None + self.session_context: Dict[str, Any] = {} + + async def initialize_session(self): + if self.session_stack: + await self.close_session() + + self.session_stack = AsyncExitStack() + self.session_resolver = DependencyResolver(self.session_stack) + + # Pre-generate data + try: + flight_declaration, telemetry_states = self._generate_data() + self.session_context = { + "operation_id": None, + "flight_declaration": flight_declaration, + "telemetry_states": telemetry_states, + "step_results": {}, + } + except Exception as e: + logger.error(f"Data generation failed: {e}") + raise + + async def close_session(self): + if self.session_stack: + await self.session_stack.aclose() + self.session_stack = None + self.session_resolver = None + self.session_context = {} + + async def execute_single_step(self, step: StepDefinition) -> Dict[str, Any]: + if not self.session_resolver: + await self.initialize_session() + + try: + return await self._execute_step(step, self.session_resolver, self.session_context) + except Exception as e: + logger.error(f"Error executing step {step.functionName}: {e}") + return {"step": f"{step.className}.{step.functionName}", "status": "error", "error": str(e)} + + def _process_parameter(self, param_name: str, param: inspect.Parameter) -> Dict[str, Any] | None: + if param_name == "self": + return None + + annotation = param.annotation + default = param.default + + # Handle Type + type_str = "Any" + is_enum = False + options = None + + if annotation != inspect.Parameter.empty: + # Check for Enum + if inspect.isclass(annotation) and issubclass(annotation, Enum): + is_enum = True + type_str = annotation.__name__ + options = [{"name": e.name, "value": e.value} for e in annotation] + else: + # Clean up type string + type_str = str(annotation) + # Use regex to remove module paths (e.g. "list[openutm_verification.models.FlightObservation]" -> "list[FlightObservation]") + type_str = re.sub(r"([a-zA-Z_][a-zA-Z0-9_]*\.)+", "", type_str) + # Remove wrapper if present + if type_str.startswith(""): + type_str = type_str[8:-2] + + # Handle Default + default_val = None + if default != inspect.Parameter.empty: + if default is None: + default_val = None + # If default is an Enum member, get its value + elif isinstance(default, Enum): + default_val = default.value + else: + default_val = str(default) + + param_info = {"name": param_name, "type": type_str, "default": default_val, "required": default == inspect.Parameter.empty} + + if is_enum: + param_info["isEnum"] = True + param_info["options"] = options + + return param_info + + def _process_method(self, class_name: str, client_class: Type, name: str, method: Any) -> Dict[str, Any] | None: + if not hasattr(method, "_is_scenario_step"): + return None + + step_name = getattr(method, "_step_name") + sig = inspect.signature(method) + parameters = [] + for param_name, param in sig.parameters.items(): + param_info = self._process_parameter(param_name, param) + if param_info: + parameters.append(param_info) + + return { + "id": f"{class_name}.{name}", + "name": step_name, + "functionName": name, + "className": class_name, + "description": inspect.getdoc(method) or "", + "parameters": parameters, + "filePath": inspect.getfile(client_class), + } + + def get_available_operations(self) -> List[Dict[str, Any]]: + operations = [] + for class_name, client_class in self.client_map.items(): + for name, method in inspect.getmembers(client_class): + op_info = self._process_method(class_name, client_class, name, method) + if op_info: + operations.append(op_info) + return operations def _load_config(self) -> AppConfig: if not self.config_path.exists(): @@ -188,19 +310,76 @@ def _prepare_params(self, step: StepDefinition, context: Dict[str, Any], method: return params - def _execute_step(self, step: StepDefinition, resolver: DependencyResolver, context: Dict[str, Any]) -> Dict[str, Any]: + async def _execute_step(self, step: StepDefinition, resolver: DependencyResolver, context: Dict[str, Any]) -> Dict[str, Any]: logger.info(f"Executing step: {step.className}.{step.functionName}") if step.className not in self.client_map: raise ValueError(f"Unknown client class: {step.className}") - client_type = self.client_map[step.className] - client = resolver.resolve(client_type) + # Special handling for SystemClient.join_task + if step.className == "SystemClient" and step.functionName == "join_task": + task_id_param = step.parameters.get("task_id") + if not task_id_param: + raise ValueError("task_id is required for join_task") + + # Handle if task_id is passed as a dictionary (from a previous step result) + if isinstance(task_id_param, dict) and "task_id" in task_id_param: + task_id = task_id_param["task_id"] + else: + task_id = str(task_id_param) + + background_tasks = context.get("background_tasks", {}) + if task_id not in background_tasks: + raise ValueError(f"Background task {task_id} not found") + + logger.info(f"Joining background task {task_id}") + task = background_tasks[task_id] + result = await task + # Clean up + del background_tasks[task_id] - method = getattr(client, step.functionName) - params = self._prepare_params(step, context, method) + # Continue to result processing... + else: + client_type = self.client_map[step.className] + client = await resolver.resolve(client_type) + + method = getattr(client, step.functionName) + params = self._prepare_params(step, context, method) + + if step.run_in_background: + import asyncio + import uuid + + task_id = str(uuid.uuid4()) + logger.info(f"Starting background task {task_id} for {step.className}.{step.functionName}") + + # Create a coroutine but don't await it yet + coro = method(**params) + if not inspect.isawaitable(coro): + # If it's not async, wrap it? For now assume async. + pass - result = method(**params) + task = asyncio.create_task(coro) + context.setdefault("background_tasks", {})[task_id] = task + + result = {"task_id": task_id, "status": "running"} + + # Store result for referencing + if step.id: + context.setdefault("step_results", {})[step.id] = result + + return {"id": step.id, "step": f"{step.className}.{step.functionName}", "status": "running", "result": result} + + result = method(**params) + if inspect.isawaitable(result): + result = await result + + # Store result for referencing + if step.id: + context.setdefault("step_results", {})[step.id] = result + logger.info(f"Stored result for step {step.id}") + else: + logger.warning(f"No step ID provided, result not stored for {step.functionName}") # Capture air traffic observations if step.functionName in ["generate_simulated_air_traffic_data", "fetch_data"]: @@ -258,38 +437,16 @@ def _execute_step(self, step: StepDefinition, resolver: DependencyResolver, cont return {"id": step.id, "step": f"{step.className}.{step.functionName}", "status": status_str, "result": result_data} - def _run_implicit_setup(self, resolver: DependencyResolver, context: Dict[str, Any]) -> Dict[str, Any]: - logger.info("Implicit Setup: Uploading Flight Declaration") - fb_client = cast(FlightBlenderClient, resolver.resolve(FlightBlenderClient)) - upload_result = fb_client.upload_flight_declaration(declaration=context["flight_declaration"]) - - # Handle StepResult - if hasattr(upload_result, "details") and isinstance(upload_result.details, dict): - op_id = upload_result.details.get("id") - if op_id: - context["operation_id"] = op_id - logger.info(f"Setup complete. Operation ID: {op_id}") - - result_data = getattr(upload_result, "model_dump")() if hasattr(upload_result, "model_dump") else str(upload_result) - return {"step": "Setup: Upload Flight Declaration", "status": "success", "result": result_data} - else: - raise ValueError("Failed to get operation ID from upload result") - else: - # Check if it failed - if hasattr(upload_result, "status") and upload_result.status == Status.FAIL: - raise ValueError(f"Setup failed: {upload_result.error_message}") - raise ValueError(f"Unexpected return from upload: {upload_result}") - - def _run_implicit_teardown(self, resolver: DependencyResolver, context: Dict[str, Any]) -> Dict[str, Any]: + async def _run_implicit_teardown(self, resolver: DependencyResolver, context: Dict[str, Any]) -> Dict[str, Any]: logger.info(f"Implicit Teardown: Deleting Operation {context['operation_id']}") - fb_client = cast(FlightBlenderClient, resolver.resolve(FlightBlenderClient)) + fb_client = cast(FlightBlenderClient, await resolver.resolve(FlightBlenderClient)) # delete_flight_declaration uses the stored latest_flight_declaration_id in the client instance - teardown_result = fb_client.delete_flight_declaration() + teardown_result = await fb_client.delete_flight_declaration() result_data = getattr(teardown_result, "model_dump")() if hasattr(teardown_result, "model_dump") else str(teardown_result) return {"step": "Teardown: Delete Flight Declaration", "status": "success", "result": result_data} - def run_scenario(self, scenario: ScenarioDefinition) -> List[Dict[str, Any]]: + async def run_scenario(self, scenario: ScenarioDefinition) -> List[Dict[str, Any]]: results = [] # Pre-generate data @@ -301,25 +458,13 @@ def run_scenario(self, scenario: ScenarioDefinition) -> List[Dict[str, Any]]: context = {"operation_id": None, "flight_declaration": flight_declaration, "telemetry_states": telemetry_states, "step_results": {}} - # Check if user provided setup - user_has_setup = len(scenario.steps) > 0 and scenario.steps[0].functionName == "upload_flight_declaration" - - with ExitStack() as stack: + async with AsyncExitStack() as stack: resolver = DependencyResolver(stack) try: - # Implicit Setup - if not user_has_setup: - try: - setup_result = self._run_implicit_setup(resolver, context) - results.append(setup_result) - except Exception as setup_error: - logger.error(f"Implicit setup failed: {setup_error}") - return [{"step": "Implicit Setup", "status": "error", "error": str(setup_error)}] - for step in scenario.steps: try: - step_result = self._execute_step(step, resolver, context) + step_result = await self._execute_step(step, resolver, context) results.append(step_result) except Exception as e: logger.error(f"Error in step {step.functionName}: {e}") @@ -331,9 +476,9 @@ def run_scenario(self, scenario: ScenarioDefinition) -> List[Dict[str, Any]]: results.append({"step": "Scenario Setup", "status": "error", "error": str(e)}) finally: # Implicit Teardown - if not user_has_setup and context["operation_id"]: + if context["operation_id"]: try: - teardown_result = self._run_implicit_teardown(resolver, context) + teardown_result = await self._run_implicit_teardown(resolver, context) results.append(teardown_result) except Exception as teardown_error: logger.error(f"Teardown failed: {teardown_error}") diff --git a/uv.lock b/uv.lock index 587bcb3..7f07d27 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -13,16 +22,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.11.0" +version = "4.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "sniffio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, ] [[package]] @@ -40,50 +48,50 @@ wheels = [ [[package]] name = "astroid" -version = "3.3.11" +version = "4.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/74/dfb75f9ccd592bbedb175d4a32fc643cf569d7c218508bfbd6ea7ef9c091/astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce", size = 400439, upload-time = "2025-07-13T18:04:23.177Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/22/97df040e15d964e592d3a180598ace67e91b7c559d8298bdb3c949dc6e42/astroid-4.0.2.tar.gz", hash = "sha256:ac8fb7ca1c08eb9afec91ccc23edbd8ac73bb22cbdd7da1d488d9fb8d6579070", size = 405714, upload-time = "2025-11-09T21:21:18.373Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/0f/3b8fdc946b4d9cc8cc1e8af42c4e409468c84441b933d037e101b3d72d86/astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec", size = 275612, upload-time = "2025-07-13T18:04:21.07Z" }, + { url = "https://files.pythonhosted.org/packages/93/ac/a85b4bfb4cf53221513e27f33cc37ad158fce02ac291d18bee6b49ab477d/astroid-4.0.2-py3-none-any.whl", hash = "sha256:d7546c00a12efc32650b19a2bb66a153883185d3179ab0d4868086f807338b9b", size = 276354, upload-time = "2025-11-09T21:21:16.54Z" }, ] [[package]] name = "asttokens" -version = "3.0.0" +version = "3.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978, upload-time = "2024-11-30T04:30:14.439Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, ] [[package]] name = "attrs" -version = "25.3.0" +version = "25.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] [[package]] name = "branca" -version = "0.8.1" +version = "0.8.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e8/1d/bec5cb6669b7bf98b632b20bbbb25200bdc44298e7a39d588b0028a78300/branca-0.8.1.tar.gz", hash = "sha256:ac397c2d79bd13af0d04193b26d5ed17031d27609a7f1fab50c438b8ae712390", size = 27743, upload-time = "2024-12-16T20:29:46.853Z" } +sdist = { url = "https://files.pythonhosted.org/packages/32/14/9d409124bda3f4ab7af3802aba07181d1fd56aa96cc4b999faea6a27a0d2/branca-0.8.2.tar.gz", hash = "sha256:e5040f4c286e973658c27de9225c1a5a7356dd0702a7c8d84c0f0dfbde388fe7", size = 27890, upload-time = "2025-10-06T10:28:20.305Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/9d/91cddd38bd00170aad1a4b198c47b4ed716be45c234e09b835af41f4e717/branca-0.8.1-py3-none-any.whl", hash = "sha256:d29c5fab31f7c21a92e34bf3f854234e29fecdcf5d2df306b616f20d816be425", size = 26071, upload-time = "2024-12-16T20:29:43.692Z" }, + { url = "https://files.pythonhosted.org/packages/7e/50/fc9680058e63161f2f63165b84c957a0df1415431104c408e8104a3a18ef/branca-0.8.2-py3-none-any.whl", hash = "sha256:2ebaef3983e3312733c1ae2b793b0a8ba3e1c4edeb7598e10328505280cf2f7c", size = 26193, upload-time = "2025-10-06T10:28:19.255Z" }, ] [[package]] name = "certifi" -version = "2025.8.3" +version = "2025.11.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, ] [[package]] @@ -145,53 +153,80 @@ wheels = [ [[package]] name = "cfgv" -version = "3.4.0" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] [[package]] name = "charset-normalizer" -version = "3.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, - { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, - { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, - { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, - { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, - { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, - { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, - { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, - { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, - { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, - { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, - { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, - { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, - { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, - { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, - { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, - { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, - { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, - { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, - { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, - { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, - { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, - { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, - { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, - { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, - { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, - { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, - { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] [[package]] @@ -314,13 +349,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/01/0a08bb86b33fa0813401d05b5b624bafad0ff93a551008da949b4e016a7b/Faker-9.3.1-py3-none-any.whl", hash = "sha256:429a91d73dbac02609d6b616ef15bd4c3f22ed6532dcfceb46f3b8c28c78257a", size = 1192590, upload-time = "2021-10-11T19:39:03.32Z" }, ] +[[package]] +name = "fastapi" +version = "0.127.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/02/2cbbecf6551e0c1a06f9b9765eb8f7ae126362fbba43babbb11b0e3b7db3/fastapi-0.127.0.tar.gz", hash = "sha256:5a9246e03dcd1fdb19f1396db30894867c1d630f5107dc167dcbc5ed1ea7d259", size = 369269, upload-time = "2025-12-21T16:47:16.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/fa/6a27e2ef789eb03060abb43b952a7f0bd39e6feaa3805362b48785bcedc5/fastapi-0.127.0-py3-none-any.whl", hash = "sha256:725aa2bb904e2eff8031557cf4b9b77459bfedd63cae8427634744fd199f6a49", size = 112055, upload-time = "2025-12-21T16:47:14.757Z" }, +] + [[package]] name = "filelock" -version = "3.19.1" +version = "3.20.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/23/ce7a1126827cedeb958fc043d61745754464eb56c5937c35bbf2b8e26f34/filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c", size = 19476, upload-time = "2025-12-15T23:54:28.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a", size = 16666, upload-time = "2025-12-15T23:54:26.874Z" }, ] [[package]] @@ -412,20 +462,20 @@ wheels = [ [[package]] name = "identify" -version = "2.6.14" +version = "2.6.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/c4/62963f25a678f6a050fb0505a65e9e726996171e6dbe1547f79619eefb15/identify-2.6.14.tar.gz", hash = "sha256:663494103b4f717cb26921c52f8751363dc89db64364cd836a9bf1535f53cd6a", size = 99283, upload-time = "2025-09-06T19:30:52.938Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/ae/2ad30f4652712c82f1c23423d79136fbce338932ad166d70c1efb86a5998/identify-2.6.14-py2.py3-none-any.whl", hash = "sha256:11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e", size = 99172, upload-time = "2025-09-06T19:30:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, ] [[package]] name = "idna" -version = "3.10" +version = "3.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] [[package]] @@ -444,11 +494,11 @@ wheels = [ [[package]] name = "iniconfig" -version = "2.1.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] @@ -467,7 +517,7 @@ wheels = [ [[package]] name = "ipython" -version = "9.5.0" +version = "9.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -481,9 +531,9 @@ dependencies = [ { name = "stack-data" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/71/a86262bf5a68bf211bcc71fe302af7e05f18a2852fdc610a854d20d085e6/ipython-9.5.0.tar.gz", hash = "sha256:129c44b941fe6d9b82d36fc7a7c18127ddb1d6f02f78f867f402e2e3adde3113", size = 4389137, upload-time = "2025-08-29T12:15:21.519Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/51/a703c030f4928646d390b4971af4938a1b10c9dfce694f0d99a0bb073cb2/ipython-9.8.0.tar.gz", hash = "sha256:8e4ce129a627eb9dd221c41b1d2cdaed4ef7c9da8c17c63f6f578fe231141f83", size = 4424940, upload-time = "2025-12-03T10:18:24.353Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/2a/5628a99d04acb2d2f2e749cdf4ea571d2575e898df0528a090948018b726/ipython-9.5.0-py3-none-any.whl", hash = "sha256:88369ffa1d5817d609120daa523a6da06d02518e582347c29f8451732a9c5e72", size = 612426, upload-time = "2025-08-29T12:15:18.866Z" }, + { url = "https://files.pythonhosted.org/packages/f1/df/8ee1c5dd1e3308b5d5b2f2dfea323bb2f3827da8d654abb6642051199049/ipython-9.8.0-py3-none-any.whl", hash = "sha256:ebe6d1d58d7d988fbf23ff8ff6d8e1622cfdb194daf4b7b73b792c4ec3b85385", size = 621374, upload-time = "2025-12-03T10:18:22.335Z" }, ] [[package]] @@ -500,7 +550,7 @@ wheels = [ [[package]] name = "ipywidgets" -version = "8.1.7" +version = "8.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "comm" }, @@ -509,18 +559,18 @@ dependencies = [ { name = "traitlets" }, { name = "widgetsnbextension" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3e/48/d3dbac45c2814cb73812f98dd6b38bbcc957a4e7bb31d6ea9c03bf94ed87/ipywidgets-8.1.7.tar.gz", hash = "sha256:15f1ac050b9ccbefd45dccfbb2ef6bed0029d8278682d569d71b8dd96bee0376", size = 116721, upload-time = "2025-05-05T12:42:03.489Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/6a/9166369a2f092bd286d24e6307de555d63616e8ddb373ebad2b5635ca4cd/ipywidgets-8.1.7-py3-none-any.whl", hash = "sha256:764f2602d25471c213919b8a1997df04bef869251db4ca8efba1b76b1bd9f7bb", size = 139806, upload-time = "2025-05-05T12:41:56.833Z" }, + { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, ] [[package]] name = "isort" -version = "6.0.1" +version = "7.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/21/1e2a441f74a653a144224d7d21afe8f4169e6c7c20bb13aec3a2dc3815e0/isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", size = 821955, upload-time = "2025-02-26T21:13:16.955Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186, upload-time = "2025-02-26T21:13:14.911Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, ] [[package]] @@ -576,11 +626,11 @@ wheels = [ [[package]] name = "jupyterlab-widgets" -version = "3.0.15" +version = "3.0.16" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b9/7d/160595ca88ee87ac6ba95d82177d29ec60aaa63821d3077babb22ce031a5/jupyterlab_widgets-3.0.15.tar.gz", hash = "sha256:2920888a0c2922351a9202817957a68c07d99673504d6cd37345299e971bb08b", size = 213149, upload-time = "2025-05-05T12:32:31.004Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/6a/ca128561b22b60bd5a0c4ea26649e68c8556b82bc70a0c396eebc977fe86/jupyterlab_widgets-3.0.15-py3-none-any.whl", hash = "sha256:d59023d7d7ef71400d51e6fee9a88867f6e65e10a4201605d2d7f3e8f012a31c", size = 216571, upload-time = "2025-05-05T12:32:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, ] [[package]] @@ -607,6 +657,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ad/66/1e792aef36645b96271b4d27c2a8cc9fc7bbbaf06277a849b9e1a6360e6a/jwt-1.3.1-py3-none-any.whl", hash = "sha256:61c9170f92e736b530655e75374681d4fcca9cfa8763ab42be57353b2b203494", size = 18192, upload-time = "2021-10-07T05:19:03.073Z" }, ] +[[package]] +name = "librt" +version = "0.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/e4/b59bdf1197fdf9888452ea4d2048cdad61aef85eb83e99dc52551d7fdc04/librt-0.7.4.tar.gz", hash = "sha256:3871af56c59864d5fd21d1ac001eb2fb3b140d52ba0454720f2e4a19812404ba", size = 145862, upload-time = "2025-12-15T16:52:43.862Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/e7/b805d868d21f425b7e76a0ea71a2700290f2266a4f3c8357fcf73efc36aa/librt-0.7.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7dd3b5c37e0fb6666c27cf4e2c88ae43da904f2155c4cfc1e5a2fdce3b9fcf92", size = 55688, upload-time = "2025-12-15T16:51:31.571Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/69a2b02e62a14cfd5bfd9f1e9adea294d5bcfeea219c7555730e5d068ee4/librt-0.7.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9c5de1928c486201b23ed0cc4ac92e6e07be5cd7f3abc57c88a9cf4f0f32108", size = 57141, upload-time = "2025-12-15T16:51:32.714Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/05dba608aae1272b8ea5ff8ef12c47a4a099a04d1e00e28a94687261d403/librt-0.7.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:078ae52ffb3f036396cc4aed558e5b61faedd504a3c1f62b8ae34bf95ae39d94", size = 165322, upload-time = "2025-12-15T16:51:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/8f/bc/199533d3fc04a4cda8d7776ee0d79955ab0c64c79ca079366fbc2617e680/librt-0.7.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce58420e25097b2fc201aef9b9f6d65df1eb8438e51154e1a7feb8847e4a55ab", size = 174216, upload-time = "2025-12-15T16:51:35.384Z" }, + { url = "https://files.pythonhosted.org/packages/62/ec/09239b912a45a8ed117cb4a6616d9ff508f5d3131bd84329bf2f8d6564f1/librt-0.7.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b719c8730c02a606dc0e8413287e8e94ac2d32a51153b300baf1f62347858fba", size = 189005, upload-time = "2025-12-15T16:51:36.687Z" }, + { url = "https://files.pythonhosted.org/packages/46/2e/e188313d54c02f5b0580dd31476bb4b0177514ff8d2be9f58d4a6dc3a7ba/librt-0.7.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3749ef74c170809e6dee68addec9d2458700a8de703de081c888e92a8b015cf9", size = 183960, upload-time = "2025-12-15T16:51:37.977Z" }, + { url = "https://files.pythonhosted.org/packages/eb/84/f1d568d254518463d879161d3737b784137d236075215e56c7c9be191cee/librt-0.7.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b35c63f557653c05b5b1b6559a074dbabe0afee28ee2a05b6c9ba21ad0d16a74", size = 177609, upload-time = "2025-12-15T16:51:40.584Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/060bbc1c002f0d757c33a1afe6bf6a565f947a04841139508fc7cef6c08b/librt-0.7.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1ef704e01cb6ad39ad7af668d51677557ca7e5d377663286f0ee1b6b27c28e5f", size = 199269, upload-time = "2025-12-15T16:51:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7f/708f8f02d8012ee9f366c07ea6a92882f48bd06cc1ff16a35e13d0fbfb08/librt-0.7.4-cp312-cp312-win32.whl", hash = "sha256:c66c2b245926ec15188aead25d395091cb5c9df008d3b3207268cd65557d6286", size = 43186, upload-time = "2025-12-15T16:51:43.149Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a5/4e051b061c8b2509be31b2c7ad4682090502c0a8b6406edcf8c6b4fe1ef7/librt-0.7.4-cp312-cp312-win_amd64.whl", hash = "sha256:71a56f4671f7ff723451f26a6131754d7c1809e04e22ebfbac1db8c9e6767a20", size = 49455, upload-time = "2025-12-15T16:51:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d2/90d84e9f919224a3c1f393af1636d8638f54925fdc6cd5ee47f1548461e5/librt-0.7.4-cp312-cp312-win_arm64.whl", hash = "sha256:419eea245e7ec0fe664eb7e85e7ff97dcdb2513ca4f6b45a8ec4a3346904f95a", size = 42828, upload-time = "2025-12-15T16:51:45.498Z" }, + { url = "https://files.pythonhosted.org/packages/fe/4d/46a53ccfbb39fd0b493fd4496eb76f3ebc15bb3e45d8c2e695a27587edf5/librt-0.7.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d44a1b1ba44cbd2fc3cb77992bef6d6fdb1028849824e1dd5e4d746e1f7f7f0b", size = 55745, upload-time = "2025-12-15T16:51:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2b/3ac7f5212b1828bf4f979cf87f547db948d3e28421d7a430d4db23346ce4/librt-0.7.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c9cab4b3de1f55e6c30a84c8cee20e4d3b2476f4d547256694a1b0163da4fe32", size = 57166, upload-time = "2025-12-15T16:51:48.219Z" }, + { url = "https://files.pythonhosted.org/packages/e8/99/6523509097cbe25f363795f0c0d1c6a3746e30c2994e25b5aefdab119b21/librt-0.7.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2857c875f1edd1feef3c371fbf830a61b632fb4d1e57160bb1e6a3206e6abe67", size = 165833, upload-time = "2025-12-15T16:51:49.443Z" }, + { url = "https://files.pythonhosted.org/packages/fe/35/323611e59f8fe032649b4fb7e77f746f96eb7588fcbb31af26bae9630571/librt-0.7.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b370a77be0a16e1ad0270822c12c21462dc40496e891d3b0caf1617c8cc57e20", size = 174818, upload-time = "2025-12-15T16:51:51.015Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/40fb2bb21616c6e06b6a64022802228066e9a31618f493e03f6b9661548a/librt-0.7.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d05acd46b9a52087bfc50c59dfdf96a2c480a601e8898a44821c7fd676598f74", size = 189607, upload-time = "2025-12-15T16:51:52.671Z" }, + { url = "https://files.pythonhosted.org/packages/32/48/1b47c7d5d28b775941e739ed2bfe564b091c49201b9503514d69e4ed96d7/librt-0.7.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:70969229cb23d9c1a80e14225838d56e464dc71fa34c8342c954fc50e7516dee", size = 184585, upload-time = "2025-12-15T16:51:54.027Z" }, + { url = "https://files.pythonhosted.org/packages/75/a6/ee135dfb5d3b54d5d9001dbe483806229c6beac3ee2ba1092582b7efeb1b/librt-0.7.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4450c354b89dbb266730893862dbff06006c9ed5b06b6016d529b2bf644fc681", size = 178249, upload-time = "2025-12-15T16:51:55.248Z" }, + { url = "https://files.pythonhosted.org/packages/04/87/d5b84ec997338be26af982bcd6679be0c1db9a32faadab1cf4bb24f9e992/librt-0.7.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:adefe0d48ad35b90b6f361f6ff5a1bd95af80c17d18619c093c60a20e7a5b60c", size = 199851, upload-time = "2025-12-15T16:51:56.933Z" }, + { url = "https://files.pythonhosted.org/packages/86/63/ba1333bf48306fe398e3392a7427ce527f81b0b79d0d91618c4610ce9d15/librt-0.7.4-cp313-cp313-win32.whl", hash = "sha256:21ea710e96c1e050635700695095962a22ea420d4b3755a25e4909f2172b4ff2", size = 43249, upload-time = "2025-12-15T16:51:58.498Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8a/de2c6df06cdfa9308c080e6b060fe192790b6a48a47320b215e860f0e98c/librt-0.7.4-cp313-cp313-win_amd64.whl", hash = "sha256:772e18696cf5a64afee908662fbcb1f907460ddc851336ee3a848ef7684c8e1e", size = 49417, upload-time = "2025-12-15T16:51:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/31/66/8ee0949efc389691381ed686185e43536c20e7ad880c122dd1f31e65c658/librt-0.7.4-cp313-cp313-win_arm64.whl", hash = "sha256:52e34c6af84e12921748c8354aa6acf1912ca98ba60cdaa6920e34793f1a0788", size = 42824, upload-time = "2025-12-15T16:52:00.784Z" }, + { url = "https://files.pythonhosted.org/packages/74/81/6921e65c8708eb6636bbf383aa77e6c7dad33a598ed3b50c313306a2da9d/librt-0.7.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4f1ee004942eaaed6e06c087d93ebc1c67e9a293e5f6b9b5da558df6bf23dc5d", size = 55191, upload-time = "2025-12-15T16:52:01.97Z" }, + { url = "https://files.pythonhosted.org/packages/0d/d6/3eb864af8a8de8b39cc8dd2e9ded1823979a27795d72c4eea0afa8c26c9f/librt-0.7.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d854c6dc0f689bad7ed452d2a3ecff58029d80612d336a45b62c35e917f42d23", size = 56898, upload-time = "2025-12-15T16:52:03.356Z" }, + { url = "https://files.pythonhosted.org/packages/49/bc/b1d4c0711fdf79646225d576faee8747b8528a6ec1ceb6accfd89ade7102/librt-0.7.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4f7339d9e445280f23d63dea842c0c77379c4a47471c538fc8feedab9d8d063", size = 163725, upload-time = "2025-12-15T16:52:04.572Z" }, + { url = "https://files.pythonhosted.org/packages/2c/08/61c41cd8f0a6a41fc99ea78a2205b88187e45ba9800792410ed62f033584/librt-0.7.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39003fc73f925e684f8521b2dbf34f61a5deb8a20a15dcf53e0d823190ce8848", size = 172469, upload-time = "2025-12-15T16:52:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c7/4ee18b4d57f01444230bc18cf59103aeab8f8c0f45e84e0e540094df1df1/librt-0.7.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bb15ee29d95875ad697d449fe6071b67f730f15a6961913a2b0205015ca0843", size = 186804, upload-time = "2025-12-15T16:52:07.192Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/009e8ba3fbf830c936842da048eda1b34b99329f402e49d88fafff6525d1/librt-0.7.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:02a69369862099e37d00765583052a99d6a68af7e19b887e1b78fee0146b755a", size = 181807, upload-time = "2025-12-15T16:52:08.554Z" }, + { url = "https://files.pythonhosted.org/packages/85/26/51ae25f813656a8b117c27a974f25e8c1e90abcd5a791ac685bf5b489a1b/librt-0.7.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ec72342cc4d62f38b25a94e28b9efefce41839aecdecf5e9627473ed04b7be16", size = 175595, upload-time = "2025-12-15T16:52:10.186Z" }, + { url = "https://files.pythonhosted.org/packages/48/93/36d6c71f830305f88996b15c8e017aa8d1e03e2e947b40b55bbf1a34cf24/librt-0.7.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:776dbb9bfa0fc5ce64234b446995d8d9f04badf64f544ca036bd6cff6f0732ce", size = 196504, upload-time = "2025-12-15T16:52:11.472Z" }, + { url = "https://files.pythonhosted.org/packages/08/11/8299e70862bb9d704735bf132c6be09c17b00fbc7cda0429a9df222fdc1b/librt-0.7.4-cp314-cp314-win32.whl", hash = "sha256:0f8cac84196d0ffcadf8469d9ded4d4e3a8b1c666095c2a291e22bf58e1e8a9f", size = 39738, upload-time = "2025-12-15T16:52:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/54/d5/656b0126e4e0f8e2725cd2d2a1ec40f71f37f6f03f135a26b663c0e1a737/librt-0.7.4-cp314-cp314-win_amd64.whl", hash = "sha256:037f5cb6fe5abe23f1dc058054d50e9699fcc90d0677eee4e4f74a8677636a1a", size = 45976, upload-time = "2025-12-15T16:52:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/60/86/465ff07b75c1067da8fa7f02913c4ead096ef106cfac97a977f763783bfb/librt-0.7.4-cp314-cp314-win_arm64.whl", hash = "sha256:a5deebb53d7a4d7e2e758a96befcd8edaaca0633ae71857995a0f16033289e44", size = 39073, upload-time = "2025-12-15T16:52:15.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a0/24941f85960774a80d4b3c2aec651d7d980466da8101cae89e8b032a3e21/librt-0.7.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b4c25312c7f4e6ab35ab16211bdf819e6e4eddcba3b2ea632fb51c9a2a97e105", size = 57369, upload-time = "2025-12-15T16:52:16.782Z" }, + { url = "https://files.pythonhosted.org/packages/77/a0/ddb259cae86ab415786c1547d0fe1b40f04a7b089f564fd5c0242a3fafb2/librt-0.7.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:618b7459bb392bdf373f2327e477597fff8f9e6a1878fffc1b711c013d1b0da4", size = 59230, upload-time = "2025-12-15T16:52:18.259Z" }, + { url = "https://files.pythonhosted.org/packages/31/11/77823cb530ab8a0c6fac848ac65b745be446f6f301753b8990e8809080c9/librt-0.7.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1437c3f72a30c7047f16fd3e972ea58b90172c3c6ca309645c1c68984f05526a", size = 183869, upload-time = "2025-12-15T16:52:19.457Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ce/157db3614cf3034b3f702ae5ba4fefda4686f11eea4b7b96542324a7a0e7/librt-0.7.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c96cb76f055b33308f6858b9b594618f1b46e147a4d03a4d7f0c449e304b9b95", size = 194606, upload-time = "2025-12-15T16:52:20.795Z" }, + { url = "https://files.pythonhosted.org/packages/30/ef/6ec4c7e3d6490f69a4fd2803516fa5334a848a4173eac26d8ee6507bff6e/librt-0.7.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28f990e6821204f516d09dc39966ef8b84556ffd648d5926c9a3f681e8de8906", size = 206776, upload-time = "2025-12-15T16:52:22.229Z" }, + { url = "https://files.pythonhosted.org/packages/ad/22/750b37bf549f60a4782ab80e9d1e9c44981374ab79a7ea68670159905918/librt-0.7.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc4aebecc79781a1b77d7d4e7d9fe080385a439e198d993b557b60f9117addaf", size = 203205, upload-time = "2025-12-15T16:52:23.603Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/2e8a0f584412a93df5faad46c5fa0a6825fdb5eba2ce482074b114877f44/librt-0.7.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:022cc673e69283a42621dd453e2407cf1647e77f8bd857d7ad7499901e62376f", size = 196696, upload-time = "2025-12-15T16:52:24.951Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ca/7bf78fa950e43b564b7de52ceeb477fb211a11f5733227efa1591d05a307/librt-0.7.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2b3ca211ae8ea540569e9c513da052699b7b06928dcda61247cb4f318122bdb5", size = 217191, upload-time = "2025-12-15T16:52:26.194Z" }, + { url = "https://files.pythonhosted.org/packages/d6/49/3732b0e8424ae35ad5c3166d9dd5bcdae43ce98775e0867a716ff5868064/librt-0.7.4-cp314-cp314t-win32.whl", hash = "sha256:8a461f6456981d8c8e971ff5a55f2e34f4e60871e665d2f5fde23ee74dea4eeb", size = 40276, upload-time = "2025-12-15T16:52:27.54Z" }, + { url = "https://files.pythonhosted.org/packages/35/d6/d8823e01bd069934525fddb343189c008b39828a429b473fb20d67d5cd36/librt-0.7.4-cp314-cp314t-win_amd64.whl", hash = "sha256:721a7b125a817d60bf4924e1eec2a7867bfcf64cfc333045de1df7a0629e4481", size = 46772, upload-time = "2025-12-15T16:52:28.653Z" }, + { url = "https://files.pythonhosted.org/packages/36/e9/a0aa60f5322814dd084a89614e9e31139702e342f8459ad8af1984a18168/librt-0.7.4-cp314-cp314t-win_arm64.whl", hash = "sha256:76b2ba71265c0102d11458879b4d53ccd0b32b0164d14deb8d2b598a018e502f", size = 39724, upload-time = "2025-12-15T16:52:29.836Z" }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -631,52 +733,77 @@ wheels = [ [[package]] name = "markupsafe" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] [[package]] name = "matplotlib-inline" -version = "0.1.7" +version = "0.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159, upload-time = "2024-04-15T13:44:44.803Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899, upload-time = "2024-04-15T13:44:43.265Z" }, + { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, ] [[package]] @@ -690,34 +817,35 @@ wheels = [ [[package]] name = "mypy" -version = "1.18.2" +version = "1.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, - { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, - { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, - { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, - { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, - { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, - { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, - { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, - { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, - { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, - { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, - { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, - { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, - { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, ] [[package]] @@ -731,74 +859,72 @@ wheels = [ [[package]] name = "nodeenv" -version = "1.9.1" +version = "1.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] [[package]] name = "numpy" -version = "2.3.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/19/95b3d357407220ed24c139018d2518fab0a61a948e68286a25f1a4d049ff/numpy-2.3.3.tar.gz", hash = "sha256:ddc7c39727ba62b80dfdbedf400d1c10ddfa8eefbd7ec8dcb118be8b56d31029", size = 20576648, upload-time = "2025-09-09T16:54:12.543Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/5d/bb7fc075b762c96329147799e1bcc9176ab07ca6375ea976c475482ad5b3/numpy-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cfdd09f9c84a1a934cde1eec2267f0a43a7cd44b2cca4ff95b7c0d14d144b0bf", size = 20957014, upload-time = "2025-09-09T15:56:29.966Z" }, - { url = "https://files.pythonhosted.org/packages/6b/0e/c6211bb92af26517acd52125a237a92afe9c3124c6a68d3b9f81b62a0568/numpy-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb32e3cf0f762aee47ad1ddc6672988f7f27045b0783c887190545baba73aa25", size = 14185220, upload-time = "2025-09-09T15:56:32.175Z" }, - { url = "https://files.pythonhosted.org/packages/22/f2/07bb754eb2ede9073f4054f7c0286b0d9d2e23982e090a80d478b26d35ca/numpy-2.3.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:396b254daeb0a57b1fe0ecb5e3cff6fa79a380fa97c8f7781a6d08cd429418fe", size = 5113918, upload-time = "2025-09-09T15:56:34.175Z" }, - { url = "https://files.pythonhosted.org/packages/81/0a/afa51697e9fb74642f231ea36aca80fa17c8fb89f7a82abd5174023c3960/numpy-2.3.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:067e3d7159a5d8f8a0b46ee11148fc35ca9b21f61e3c49fbd0a027450e65a33b", size = 6647922, upload-time = "2025-09-09T15:56:36.149Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f5/122d9cdb3f51c520d150fef6e87df9279e33d19a9611a87c0d2cf78a89f4/numpy-2.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c02d0629d25d426585fb2e45a66154081b9fa677bc92a881ff1d216bc9919a8", size = 14281991, upload-time = "2025-09-09T15:56:40.548Z" }, - { url = "https://files.pythonhosted.org/packages/51/64/7de3c91e821a2debf77c92962ea3fe6ac2bc45d0778c1cbe15d4fce2fd94/numpy-2.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9192da52b9745f7f0766531dcfa978b7763916f158bb63bdb8a1eca0068ab20", size = 16641643, upload-time = "2025-09-09T15:56:43.343Z" }, - { url = "https://files.pythonhosted.org/packages/30/e4/961a5fa681502cd0d68907818b69f67542695b74e3ceaa513918103b7e80/numpy-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cd7de500a5b66319db419dc3c345244404a164beae0d0937283b907d8152e6ea", size = 16056787, upload-time = "2025-09-09T15:56:46.141Z" }, - { url = "https://files.pythonhosted.org/packages/99/26/92c912b966e47fbbdf2ad556cb17e3a3088e2e1292b9833be1dfa5361a1a/numpy-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:93d4962d8f82af58f0b2eb85daaf1b3ca23fe0a85d0be8f1f2b7bb46034e56d7", size = 18579598, upload-time = "2025-09-09T15:56:49.844Z" }, - { url = "https://files.pythonhosted.org/packages/17/b6/fc8f82cb3520768718834f310c37d96380d9dc61bfdaf05fe5c0b7653e01/numpy-2.3.3-cp312-cp312-win32.whl", hash = "sha256:5534ed6b92f9b7dca6c0a19d6df12d41c68b991cef051d108f6dbff3babc4ebf", size = 6320800, upload-time = "2025-09-09T15:56:52.499Z" }, - { url = "https://files.pythonhosted.org/packages/32/ee/de999f2625b80d043d6d2d628c07d0d5555a677a3cf78fdf868d409b8766/numpy-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:497d7cad08e7092dba36e3d296fe4c97708c93daf26643a1ae4b03f6294d30eb", size = 12786615, upload-time = "2025-09-09T15:56:54.422Z" }, - { url = "https://files.pythonhosted.org/packages/49/6e/b479032f8a43559c383acb20816644f5f91c88f633d9271ee84f3b3a996c/numpy-2.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:ca0309a18d4dfea6fc6262a66d06c26cfe4640c3926ceec90e57791a82b6eee5", size = 10195936, upload-time = "2025-09-09T15:56:56.541Z" }, - { url = "https://files.pythonhosted.org/packages/7d/b9/984c2b1ee61a8b803bf63582b4ac4242cf76e2dbd663efeafcb620cc0ccb/numpy-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f5415fb78995644253370985342cd03572ef8620b934da27d77377a2285955bf", size = 20949588, upload-time = "2025-09-09T15:56:59.087Z" }, - { url = "https://files.pythonhosted.org/packages/a6/e4/07970e3bed0b1384d22af1e9912527ecbeb47d3b26e9b6a3bced068b3bea/numpy-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d00de139a3324e26ed5b95870ce63be7ec7352171bc69a4cf1f157a48e3eb6b7", size = 14177802, upload-time = "2025-09-09T15:57:01.73Z" }, - { url = "https://files.pythonhosted.org/packages/35/c7/477a83887f9de61f1203bad89cf208b7c19cc9fef0cebef65d5a1a0619f2/numpy-2.3.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9dc13c6a5829610cc07422bc74d3ac083bd8323f14e2827d992f9e52e22cd6a6", size = 5106537, upload-time = "2025-09-09T15:57:03.765Z" }, - { url = "https://files.pythonhosted.org/packages/52/47/93b953bd5866a6f6986344d045a207d3f1cfbad99db29f534ea9cee5108c/numpy-2.3.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d79715d95f1894771eb4e60fb23f065663b2298f7d22945d66877aadf33d00c7", size = 6640743, upload-time = "2025-09-09T15:57:07.921Z" }, - { url = "https://files.pythonhosted.org/packages/23/83/377f84aaeb800b64c0ef4de58b08769e782edcefa4fea712910b6f0afd3c/numpy-2.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:952cfd0748514ea7c3afc729a0fc639e61655ce4c55ab9acfab14bda4f402b4c", size = 14278881, upload-time = "2025-09-09T15:57:11.349Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a5/bf3db6e66c4b160d6ea10b534c381a1955dfab34cb1017ea93aa33c70ed3/numpy-2.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b83648633d46f77039c29078751f80da65aa64d5622a3cd62aaef9d835b6c93", size = 16636301, upload-time = "2025-09-09T15:57:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/a2/59/1287924242eb4fa3f9b3a2c30400f2e17eb2707020d1c5e3086fe7330717/numpy-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b001bae8cea1c7dfdb2ae2b017ed0a6f2102d7a70059df1e338e307a4c78a8ae", size = 16053645, upload-time = "2025-09-09T15:57:16.534Z" }, - { url = "https://files.pythonhosted.org/packages/e6/93/b3d47ed882027c35e94ac2320c37e452a549f582a5e801f2d34b56973c97/numpy-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e9aced64054739037d42fb84c54dd38b81ee238816c948c8f3ed134665dcd86", size = 18578179, upload-time = "2025-09-09T15:57:18.883Z" }, - { url = "https://files.pythonhosted.org/packages/20/d9/487a2bccbf7cc9d4bfc5f0f197761a5ef27ba870f1e3bbb9afc4bbe3fcc2/numpy-2.3.3-cp313-cp313-win32.whl", hash = "sha256:9591e1221db3f37751e6442850429b3aabf7026d3b05542d102944ca7f00c8a8", size = 6312250, upload-time = "2025-09-09T15:57:21.296Z" }, - { url = "https://files.pythonhosted.org/packages/1b/b5/263ebbbbcede85028f30047eab3d58028d7ebe389d6493fc95ae66c636ab/numpy-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f0dadeb302887f07431910f67a14d57209ed91130be0adea2f9793f1a4f817cf", size = 12783269, upload-time = "2025-09-09T15:57:23.034Z" }, - { url = "https://files.pythonhosted.org/packages/fa/75/67b8ca554bbeaaeb3fac2e8bce46967a5a06544c9108ec0cf5cece559b6c/numpy-2.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:3c7cf302ac6e0b76a64c4aecf1a09e51abd9b01fc7feee80f6c43e3ab1b1dbc5", size = 10195314, upload-time = "2025-09-09T15:57:25.045Z" }, - { url = "https://files.pythonhosted.org/packages/11/d0/0d1ddec56b162042ddfafeeb293bac672de9b0cfd688383590090963720a/numpy-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:eda59e44957d272846bb407aad19f89dc6f58fecf3504bd144f4c5cf81a7eacc", size = 21048025, upload-time = "2025-09-09T15:57:27.257Z" }, - { url = "https://files.pythonhosted.org/packages/36/9e/1996ca6b6d00415b6acbdd3c42f7f03ea256e2c3f158f80bd7436a8a19f3/numpy-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:823d04112bc85ef5c4fda73ba24e6096c8f869931405a80aa8b0e604510a26bc", size = 14301053, upload-time = "2025-09-09T15:57:30.077Z" }, - { url = "https://files.pythonhosted.org/packages/05/24/43da09aa764c68694b76e84b3d3f0c44cb7c18cdc1ba80e48b0ac1d2cd39/numpy-2.3.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:40051003e03db4041aa325da2a0971ba41cf65714e65d296397cc0e32de6018b", size = 5229444, upload-time = "2025-09-09T15:57:32.733Z" }, - { url = "https://files.pythonhosted.org/packages/bc/14/50ffb0f22f7218ef8af28dd089f79f68289a7a05a208db9a2c5dcbe123c1/numpy-2.3.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:6ee9086235dd6ab7ae75aba5662f582a81ced49f0f1c6de4260a78d8f2d91a19", size = 6738039, upload-time = "2025-09-09T15:57:34.328Z" }, - { url = "https://files.pythonhosted.org/packages/55/52/af46ac0795e09657d45a7f4db961917314377edecf66db0e39fa7ab5c3d3/numpy-2.3.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94fcaa68757c3e2e668ddadeaa86ab05499a70725811e582b6a9858dd472fb30", size = 14352314, upload-time = "2025-09-09T15:57:36.255Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b1/dc226b4c90eb9f07a3fff95c2f0db3268e2e54e5cce97c4ac91518aee71b/numpy-2.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da1a74b90e7483d6ce5244053399a614b1d6b7bc30a60d2f570e5071f8959d3e", size = 16701722, upload-time = "2025-09-09T15:57:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/9d/9d/9d8d358f2eb5eced14dba99f110d83b5cd9a4460895230f3b396ad19a323/numpy-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2990adf06d1ecee3b3dcbb4977dfab6e9f09807598d647f04d385d29e7a3c3d3", size = 16132755, upload-time = "2025-09-09T15:57:41.16Z" }, - { url = "https://files.pythonhosted.org/packages/b6/27/b3922660c45513f9377b3fb42240bec63f203c71416093476ec9aa0719dc/numpy-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ed635ff692483b8e3f0fcaa8e7eb8a75ee71aa6d975388224f70821421800cea", size = 18651560, upload-time = "2025-09-09T15:57:43.459Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8e/3ab61a730bdbbc201bb245a71102aa609f0008b9ed15255500a99cd7f780/numpy-2.3.3-cp313-cp313t-win32.whl", hash = "sha256:a333b4ed33d8dc2b373cc955ca57babc00cd6f9009991d9edc5ddbc1bac36bcd", size = 6442776, upload-time = "2025-09-09T15:57:45.793Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3a/e22b766b11f6030dc2decdeff5c2fb1610768055603f9f3be88b6d192fb2/numpy-2.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4384a169c4d8f97195980815d6fcad04933a7e1ab3b530921c3fef7a1c63426d", size = 12927281, upload-time = "2025-09-09T15:57:47.492Z" }, - { url = "https://files.pythonhosted.org/packages/7b/42/c2e2bc48c5e9b2a83423f99733950fbefd86f165b468a3d85d52b30bf782/numpy-2.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:75370986cc0bc66f4ce5110ad35aae6d182cc4ce6433c40ad151f53690130bf1", size = 10265275, upload-time = "2025-09-09T15:57:49.647Z" }, - { url = "https://files.pythonhosted.org/packages/6b/01/342ad585ad82419b99bcf7cebe99e61da6bedb89e213c5fd71acc467faee/numpy-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cd052f1fa6a78dee696b58a914b7229ecfa41f0a6d96dc663c1220a55e137593", size = 20951527, upload-time = "2025-09-09T15:57:52.006Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d8/204e0d73fc1b7a9ee80ab1fe1983dd33a4d64a4e30a05364b0208e9a241a/numpy-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:414a97499480067d305fcac9716c29cf4d0d76db6ebf0bf3cbce666677f12652", size = 14186159, upload-time = "2025-09-09T15:57:54.407Z" }, - { url = "https://files.pythonhosted.org/packages/22/af/f11c916d08f3a18fb8ba81ab72b5b74a6e42ead4c2846d270eb19845bf74/numpy-2.3.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:50a5fe69f135f88a2be9b6ca0481a68a136f6febe1916e4920e12f1a34e708a7", size = 5114624, upload-time = "2025-09-09T15:57:56.5Z" }, - { url = "https://files.pythonhosted.org/packages/fb/11/0ed919c8381ac9d2ffacd63fd1f0c34d27e99cab650f0eb6f110e6ae4858/numpy-2.3.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:b912f2ed2b67a129e6a601e9d93d4fa37bef67e54cac442a2f588a54afe5c67a", size = 6642627, upload-time = "2025-09-09T15:57:58.206Z" }, - { url = "https://files.pythonhosted.org/packages/ee/83/deb5f77cb0f7ba6cb52b91ed388b47f8f3c2e9930d4665c600408d9b90b9/numpy-2.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e318ee0596d76d4cb3d78535dc005fa60e5ea348cd131a51e99d0bdbe0b54fe", size = 14296926, upload-time = "2025-09-09T15:58:00.035Z" }, - { url = "https://files.pythonhosted.org/packages/77/cc/70e59dcb84f2b005d4f306310ff0a892518cc0c8000a33d0e6faf7ca8d80/numpy-2.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce020080e4a52426202bdb6f7691c65bb55e49f261f31a8f506c9f6bc7450421", size = 16638958, upload-time = "2025-09-09T15:58:02.738Z" }, - { url = "https://files.pythonhosted.org/packages/b6/5a/b2ab6c18b4257e099587d5b7f903317bd7115333ad8d4ec4874278eafa61/numpy-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6687dc183aa55dae4a705b35f9c0f8cb178bcaa2f029b241ac5356221d5c021", size = 16071920, upload-time = "2025-09-09T15:58:05.029Z" }, - { url = "https://files.pythonhosted.org/packages/b8/f1/8b3fdc44324a259298520dd82147ff648979bed085feeacc1250ef1656c0/numpy-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d8f3b1080782469fdc1718c4ed1d22549b5fb12af0d57d35e992158a772a37cf", size = 18577076, upload-time = "2025-09-09T15:58:07.745Z" }, - { url = "https://files.pythonhosted.org/packages/f0/a1/b87a284fb15a42e9274e7fcea0dad259d12ddbf07c1595b26883151ca3b4/numpy-2.3.3-cp314-cp314-win32.whl", hash = "sha256:cb248499b0bc3be66ebd6578b83e5acacf1d6cb2a77f2248ce0e40fbec5a76d0", size = 6366952, upload-time = "2025-09-09T15:58:10.096Z" }, - { url = "https://files.pythonhosted.org/packages/70/5f/1816f4d08f3b8f66576d8433a66f8fa35a5acfb3bbd0bf6c31183b003f3d/numpy-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:691808c2b26b0f002a032c73255d0bd89751425f379f7bcd22d140db593a96e8", size = 12919322, upload-time = "2025-09-09T15:58:12.138Z" }, - { url = "https://files.pythonhosted.org/packages/8c/de/072420342e46a8ea41c324a555fa90fcc11637583fb8df722936aed1736d/numpy-2.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:9ad12e976ca7b10f1774b03615a2a4bab8addce37ecc77394d8e986927dc0dfe", size = 10478630, upload-time = "2025-09-09T15:58:14.64Z" }, - { url = "https://files.pythonhosted.org/packages/d5/df/ee2f1c0a9de7347f14da5dd3cd3c3b034d1b8607ccb6883d7dd5c035d631/numpy-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9cc48e09feb11e1db00b320e9d30a4151f7369afb96bd0e48d942d09da3a0d00", size = 21047987, upload-time = "2025-09-09T15:58:16.889Z" }, - { url = "https://files.pythonhosted.org/packages/d6/92/9453bdc5a4e9e69cf4358463f25e8260e2ffc126d52e10038b9077815989/numpy-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:901bf6123879b7f251d3631967fd574690734236075082078e0571977c6a8e6a", size = 14301076, upload-time = "2025-09-09T15:58:20.343Z" }, - { url = "https://files.pythonhosted.org/packages/13/77/1447b9eb500f028bb44253105bd67534af60499588a5149a94f18f2ca917/numpy-2.3.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:7f025652034199c301049296b59fa7d52c7e625017cae4c75d8662e377bf487d", size = 5229491, upload-time = "2025-09-09T15:58:22.481Z" }, - { url = "https://files.pythonhosted.org/packages/3d/f9/d72221b6ca205f9736cb4b2ce3b002f6e45cd67cd6a6d1c8af11a2f0b649/numpy-2.3.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:533ca5f6d325c80b6007d4d7fb1984c303553534191024ec6a524a4c92a5935a", size = 6737913, upload-time = "2025-09-09T15:58:24.569Z" }, - { url = "https://files.pythonhosted.org/packages/3c/5f/d12834711962ad9c46af72f79bb31e73e416ee49d17f4c797f72c96b6ca5/numpy-2.3.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0edd58682a399824633b66885d699d7de982800053acf20be1eaa46d92009c54", size = 14352811, upload-time = "2025-09-09T15:58:26.416Z" }, - { url = "https://files.pythonhosted.org/packages/a1/0d/fdbec6629d97fd1bebed56cd742884e4eead593611bbe1abc3eb40d304b2/numpy-2.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:367ad5d8fbec5d9296d18478804a530f1191e24ab4d75ab408346ae88045d25e", size = 16702689, upload-time = "2025-09-09T15:58:28.831Z" }, - { url = "https://files.pythonhosted.org/packages/9b/09/0a35196dc5575adde1eb97ddfbc3e1687a814f905377621d18ca9bc2b7dd/numpy-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8f6ac61a217437946a1fa48d24c47c91a0c4f725237871117dea264982128097", size = 16133855, upload-time = "2025-09-09T15:58:31.349Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ca/c9de3ea397d576f1b6753eaa906d4cdef1bf97589a6d9825a349b4729cc2/numpy-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:179a42101b845a816d464b6fe9a845dfaf308fdfc7925387195570789bb2c970", size = 18652520, upload-time = "2025-09-09T15:58:33.762Z" }, - { url = "https://files.pythonhosted.org/packages/fd/c2/e5ed830e08cd0196351db55db82f65bc0ab05da6ef2b72a836dcf1936d2f/numpy-2.3.3-cp314-cp314t-win32.whl", hash = "sha256:1250c5d3d2562ec4174bce2e3a1523041595f9b651065e4a4473f5f48a6bc8a5", size = 6515371, upload-time = "2025-09-09T15:58:36.04Z" }, - { url = "https://files.pythonhosted.org/packages/47/c7/b0f6b5b67f6788a0725f744496badbb604d226bf233ba716683ebb47b570/numpy-2.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b37a0b2e5935409daebe82c1e42274d30d9dd355852529eab91dab8dcca7419f", size = 13112576, upload-time = "2025-09-09T15:58:37.927Z" }, - { url = "https://files.pythonhosted.org/packages/06/b9/33bba5ff6fb679aa0b1f8a07e853f002a6b04b9394db3069a1270a7784ca/numpy-2.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:78c9f6560dc7e6b3990e32df7ea1a50bbd0e2a111e05209963f5ddcab7073b0b", size = 10545953, upload-time = "2025-09-09T15:58:40.576Z" }, +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7a/6a3d14e205d292b738db449d0de649b373a59edb0d0b4493821d0a3e8718/numpy-2.4.0.tar.gz", hash = "sha256:6e504f7b16118198f138ef31ba24d985b124c2c469fe8467007cf30fd992f934", size = 20685720, upload-time = "2025-12-20T16:18:19.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/ff/f6400ffec95de41c74b8e73df32e3fff1830633193a7b1e409be7fb1bb8c/numpy-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a8b6bb8369abefb8bd1801b054ad50e02b3275c8614dc6e5b0373c305291037", size = 16653117, upload-time = "2025-12-20T16:16:06.709Z" }, + { url = "https://files.pythonhosted.org/packages/fd/28/6c23e97450035072e8d830a3c411bf1abd1f42c611ff9d29e3d8f55c6252/numpy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e284ca13d5a8367e43734148622caf0b261b275673823593e3e3634a6490f83", size = 12369711, upload-time = "2025-12-20T16:16:08.758Z" }, + { url = "https://files.pythonhosted.org/packages/bc/af/acbef97b630ab1bb45e6a7d01d1452e4251aa88ce680ac36e56c272120ec/numpy-2.4.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:49ff32b09f5aa0cd30a20c2b39db3e669c845589f2b7fc910365210887e39344", size = 5198355, upload-time = "2025-12-20T16:16:10.902Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c8/4e0d436b66b826f2e53330adaa6311f5cac9871a5b5c31ad773b27f25a74/numpy-2.4.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:36cbfb13c152b1c7c184ddac43765db8ad672567e7bafff2cc755a09917ed2e6", size = 6545298, upload-time = "2025-12-20T16:16:12.607Z" }, + { url = "https://files.pythonhosted.org/packages/ef/27/e1f5d144ab54eac34875e79037011d511ac57b21b220063310cb96c80fbc/numpy-2.4.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35ddc8f4914466e6fc954c76527aa91aa763682a4f6d73249ef20b418fe6effb", size = 14398387, upload-time = "2025-12-20T16:16:14.257Z" }, + { url = "https://files.pythonhosted.org/packages/67/64/4cb909dd5ab09a9a5d086eff9586e69e827b88a5585517386879474f4cf7/numpy-2.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc578891de1db95b2a35001b695451767b580bb45753717498213c5ff3c41d63", size = 16363091, upload-time = "2025-12-20T16:16:17.32Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/8efe24577523ec6809261859737cf117b0eb6fdb655abdfdc81b2e468ce4/numpy-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98e81648e0b36e325ab67e46b5400a7a6d4a22b8a7c8e8bbfe20e7db7906bf95", size = 16176394, upload-time = "2025-12-20T16:16:19.524Z" }, + { url = "https://files.pythonhosted.org/packages/61/f0/1687441ece7b47a62e45a1f82015352c240765c707928edd8aef875d5951/numpy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d57b5046c120561ba8fa8e4030fbb8b822f3063910fa901ffadf16e2b7128ad6", size = 18287378, upload-time = "2025-12-20T16:16:22.866Z" }, + { url = "https://files.pythonhosted.org/packages/d3/6f/f868765d44e6fc466467ed810ba9d8d6db1add7d4a748abfa2a4c99a3194/numpy-2.4.0-cp312-cp312-win32.whl", hash = "sha256:92190db305a6f48734d3982f2c60fa30d6b5ee9bff10f2887b930d7b40119f4c", size = 5955432, upload-time = "2025-12-20T16:16:25.06Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b5/94c1e79fcbab38d1ca15e13777477b2914dd2d559b410f96949d6637b085/numpy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:680060061adb2d74ce352628cb798cfdec399068aa7f07ba9fb818b2b3305f98", size = 12306201, upload-time = "2025-12-20T16:16:26.979Z" }, + { url = "https://files.pythonhosted.org/packages/70/09/c39dadf0b13bb0768cd29d6a3aaff1fb7c6905ac40e9aaeca26b1c086e06/numpy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:39699233bc72dd482da1415dcb06076e32f60eddc796a796c5fb6c5efce94667", size = 10308234, upload-time = "2025-12-20T16:16:29.417Z" }, + { url = "https://files.pythonhosted.org/packages/a7/0d/853fd96372eda07c824d24adf02e8bc92bb3731b43a9b2a39161c3667cc4/numpy-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a152d86a3ae00ba5f47b3acf3b827509fd0b6cb7d3259665e63dafbad22a75ea", size = 16649088, upload-time = "2025-12-20T16:16:31.421Z" }, + { url = "https://files.pythonhosted.org/packages/e3/37/cc636f1f2a9f585434e20a3e6e63422f70bfe4f7f6698e941db52ea1ac9a/numpy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39b19251dec4de8ff8496cd0806cbe27bf0684f765abb1f4809554de93785f2d", size = 12364065, upload-time = "2025-12-20T16:16:33.491Z" }, + { url = "https://files.pythonhosted.org/packages/ed/69/0b78f37ca3690969beee54103ce5f6021709134e8020767e93ba691a72f1/numpy-2.4.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:009bd0ea12d3c784b6639a8457537016ce5172109e585338e11334f6a7bb88ee", size = 5192640, upload-time = "2025-12-20T16:16:35.636Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2a/08569f8252abf590294dbb09a430543ec8f8cc710383abfb3e75cc73aeda/numpy-2.4.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5fe44e277225fd3dff6882d86d3d447205d43532c3627313d17e754fb3905a0e", size = 6541556, upload-time = "2025-12-20T16:16:37.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/e9/a949885a4e177493d61519377952186b6cbfdf1d6002764c664ba28349b5/numpy-2.4.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f935c4493eda9069851058fa0d9e39dbf6286be690066509305e52912714dbb2", size = 14396562, upload-time = "2025-12-20T16:16:38.953Z" }, + { url = "https://files.pythonhosted.org/packages/99/98/9d4ad53b0e9ef901c2ef1d550d2136f5ac42d3fd2988390a6def32e23e48/numpy-2.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cfa5f29a695cb7438965e6c3e8d06e0416060cf0d709c1b1c1653a939bf5c2a", size = 16351719, upload-time = "2025-12-20T16:16:41.503Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/5f3711a38341d6e8dd619f6353251a0cdd07f3d6d101a8fd46f4ef87f895/numpy-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba0cb30acd3ef11c94dc27fbfba68940652492bc107075e7ffe23057f9425681", size = 16176053, upload-time = "2025-12-20T16:16:44.552Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5b/2a3753dc43916501b4183532e7ace862e13211042bceafa253afb5c71272/numpy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60e8c196cd82cbbd4f130b5290007e13e6de3eca79f0d4d38014769d96a7c475", size = 18277859, upload-time = "2025-12-20T16:16:47.174Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c5/a18bcdd07a941db3076ef489d036ab16d2bfc2eae0cf27e5a26e29189434/numpy-2.4.0-cp313-cp313-win32.whl", hash = "sha256:5f48cb3e88fbc294dc90e215d86fbaf1c852c63dbdb6c3a3e63f45c4b57f7344", size = 5953849, upload-time = "2025-12-20T16:16:49.554Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f1/719010ff8061da6e8a26e1980cf090412d4f5f8060b31f0c45d77dd67a01/numpy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:a899699294f28f7be8992853c0c60741f16ff199205e2e6cdca155762cbaa59d", size = 12302840, upload-time = "2025-12-20T16:16:51.227Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5a/b3d259083ed8b4d335270c76966cb6cf14a5d1b69e1a608994ac57a659e6/numpy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9198f447e1dc5647d07c9a6bbe2063cc0132728cc7175b39dbc796da5b54920d", size = 10308509, upload-time = "2025-12-20T16:16:53.313Z" }, + { url = "https://files.pythonhosted.org/packages/31/01/95edcffd1bb6c0633df4e808130545c4f07383ab629ac7e316fb44fff677/numpy-2.4.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74623f2ab5cc3f7c886add4f735d1031a1d2be4a4ae63c0546cfd74e7a31ddf6", size = 12491815, upload-time = "2025-12-20T16:16:55.496Z" }, + { url = "https://files.pythonhosted.org/packages/59/ea/5644b8baa92cc1c7163b4b4458c8679852733fa74ca49c942cfa82ded4e0/numpy-2.4.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:0804a8e4ab070d1d35496e65ffd3cf8114c136a2b81f61dfab0de4b218aacfd5", size = 5320321, upload-time = "2025-12-20T16:16:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/26/4e/e10938106d70bc21319bd6a86ae726da37edc802ce35a3a71ecdf1fdfe7f/numpy-2.4.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:02a2038eb27f9443a8b266a66911e926566b5a6ffd1a689b588f7f35b81e7dc3", size = 6641635, upload-time = "2025-12-20T16:16:59.379Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8d/a8828e3eaf5c0b4ab116924df82f24ce3416fa38d0674d8f708ddc6c8aac/numpy-2.4.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1889b3a3f47a7b5bee16bc25a2145bd7cb91897f815ce3499db64c7458b6d91d", size = 14456053, upload-time = "2025-12-20T16:17:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/68/a1/17d97609d87d4520aa5ae2dcfb32305654550ac6a35effb946d303e594ce/numpy-2.4.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85eef4cb5625c47ee6425c58a3502555e10f45ee973da878ac8248ad58c136f3", size = 16401702, upload-time = "2025-12-20T16:17:04.235Z" }, + { url = "https://files.pythonhosted.org/packages/18/32/0f13c1b2d22bea1118356b8b963195446f3af124ed7a5adfa8fdecb1b6ca/numpy-2.4.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6dc8b7e2f4eb184b37655195f421836cfae6f58197b67e3ffc501f1333d993fa", size = 16242493, upload-time = "2025-12-20T16:17:06.856Z" }, + { url = "https://files.pythonhosted.org/packages/ae/23/48f21e3d309fbc137c068a1475358cbd3a901b3987dcfc97a029ab3068e2/numpy-2.4.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:44aba2f0cafd287871a495fb3163408b0bd25bbce135c6f621534a07f4f7875c", size = 18324222, upload-time = "2025-12-20T16:17:09.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/52/41f3d71296a3dcaa4f456aaa3c6fc8e745b43d0552b6bde56571bb4b4a0f/numpy-2.4.0-cp313-cp313t-win32.whl", hash = "sha256:20c115517513831860c573996e395707aa9fb691eb179200125c250e895fcd93", size = 6076216, upload-time = "2025-12-20T16:17:11.437Z" }, + { url = "https://files.pythonhosted.org/packages/35/ff/46fbfe60ab0710d2a2b16995f708750307d30eccbb4c38371ea9e986866e/numpy-2.4.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b48e35f4ab6f6a7597c46e301126ceba4c44cd3280e3750f85db48b082624fa4", size = 12444263, upload-time = "2025-12-20T16:17:13.182Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e3/9189ab319c01d2ed556c932ccf55064c5d75bb5850d1df7a482ce0badead/numpy-2.4.0-cp313-cp313t-win_arm64.whl", hash = "sha256:4d1cfce39e511069b11e67cd0bd78ceff31443b7c9e5c04db73c7a19f572967c", size = 10378265, upload-time = "2025-12-20T16:17:15.211Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ed/52eac27de39d5e5a6c9aadabe672bc06f55e24a3d9010cd1183948055d76/numpy-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c95eb6db2884917d86cde0b4d4cf31adf485c8ec36bf8696dd66fa70de96f36b", size = 16647476, upload-time = "2025-12-20T16:17:17.671Z" }, + { url = "https://files.pythonhosted.org/packages/77/c0/990ce1b7fcd4e09aeaa574e2a0a839589e4b08b2ca68070f1acb1fea6736/numpy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:65167da969cd1ec3a1df31cb221ca3a19a8aaa25370ecb17d428415e93c1935e", size = 12374563, upload-time = "2025-12-20T16:17:20.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/7c/8c5e389c6ae8f5fd2277a988600d79e9625db3fff011a2d87ac80b881a4c/numpy-2.4.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3de19cfecd1465d0dcf8a5b5ea8b3155b42ed0b639dba4b71e323d74f2a3be5e", size = 5203107, upload-time = "2025-12-20T16:17:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/e6/94/ca5b3bd6a8a70a5eec9a0b8dd7f980c1eff4b8a54970a9a7fef248ef564f/numpy-2.4.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6c05483c3136ac4c91b4e81903cb53a8707d316f488124d0398499a4f8e8ef51", size = 6538067, upload-time = "2025-12-20T16:17:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/79/43/993eb7bb5be6761dde2b3a3a594d689cec83398e3f58f4758010f3b85727/numpy-2.4.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36667db4d6c1cea79c8930ab72fadfb4060feb4bfe724141cd4bd064d2e5f8ce", size = 14411926, upload-time = "2025-12-20T16:17:25.822Z" }, + { url = "https://files.pythonhosted.org/packages/03/75/d4c43b61de473912496317a854dac54f1efec3eeb158438da6884b70bb90/numpy-2.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a818668b674047fd88c4cddada7ab8f1c298812783e8328e956b78dc4807f9f", size = 16354295, upload-time = "2025-12-20T16:17:28.308Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0a/b54615b47ee8736a6461a4bb6749128dd3435c5a759d5663f11f0e9af4ac/numpy-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ee32359fb7543b7b7bd0b2f46294db27e29e7bbdf70541e81b190836cd83ded", size = 16190242, upload-time = "2025-12-20T16:17:30.993Z" }, + { url = "https://files.pythonhosted.org/packages/98/ce/ea207769aacad6246525ec6c6bbd66a2bf56c72443dc10e2f90feed29290/numpy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e493962256a38f58283de033d8af176c5c91c084ea30f15834f7545451c42059", size = 18280875, upload-time = "2025-12-20T16:17:33.327Z" }, + { url = "https://files.pythonhosted.org/packages/17/ef/ec409437aa962ea372ed601c519a2b141701683ff028f894b7466f0ab42b/numpy-2.4.0-cp314-cp314-win32.whl", hash = "sha256:6bbaebf0d11567fa8926215ae731e1d58e6ec28a8a25235b8a47405d301332db", size = 6002530, upload-time = "2025-12-20T16:17:35.729Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4a/5cb94c787a3ed1ac65e1271b968686521169a7b3ec0b6544bb3ca32960b0/numpy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d857f55e7fdf7c38ab96c4558c95b97d1c685be6b05c249f5fdafcbd6f9899e", size = 12435890, upload-time = "2025-12-20T16:17:37.599Z" }, + { url = "https://files.pythonhosted.org/packages/48/a0/04b89db963af9de1104975e2544f30de89adbf75b9e75f7dd2599be12c79/numpy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:bb50ce5fb202a26fd5404620e7ef820ad1ab3558b444cb0b55beb7ef66cd2d63", size = 10591892, upload-time = "2025-12-20T16:17:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/53/e5/d74b5ccf6712c06c7a545025a6a71bfa03bdc7e0568b405b0d655232fd92/numpy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:355354388cba60f2132df297e2d53053d4063f79077b67b481d21276d61fc4df", size = 12494312, upload-time = "2025-12-20T16:17:41.714Z" }, + { url = "https://files.pythonhosted.org/packages/c2/08/3ca9cc2ddf54dfee7ae9a6479c071092a228c68aef08252aa08dac2af002/numpy-2.4.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:1d8f9fde5f6dc1b6fc34df8162f3b3079365468703fee7f31d4e0cc8c63baed9", size = 5322862, upload-time = "2025-12-20T16:17:44.145Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/0bb63a68394c0c1e52670cfff2e309afa41edbe11b3327d9af29e4383f34/numpy-2.4.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e0434aa22c821f44eeb4c650b81c7fbdd8c0122c6c4b5a576a76d5a35625ecd9", size = 6644986, upload-time = "2025-12-20T16:17:46.203Z" }, + { url = "https://files.pythonhosted.org/packages/06/8f/9264d9bdbcf8236af2823623fe2f3981d740fc3461e2787e231d97c38c28/numpy-2.4.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40483b2f2d3ba7aad426443767ff5632ec3156ef09742b96913787d13c336471", size = 14457958, upload-time = "2025-12-20T16:17:48.017Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d9/f9a69ae564bbc7236a35aa883319364ef5fd41f72aa320cc1cbe66148fe2/numpy-2.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6a7664ddd9746e20b7325351fe1a8408d0a2bf9c63b5e898290ddc8f09544", size = 16398394, upload-time = "2025-12-20T16:17:50.409Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/39241501408dde7f885d241a98caba5421061a2c6d2b2197ac5e3aa842d8/numpy-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ecb0019d44f4cdb50b676c5d0cb4b1eae8e15d1ed3d3e6639f986fc92b2ec52c", size = 16241044, upload-time = "2025-12-20T16:17:52.661Z" }, + { url = "https://files.pythonhosted.org/packages/7c/95/cae7effd90e065a95e59fe710eeee05d7328ed169776dfdd9f789e032125/numpy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d0ffd9e2e4441c96a9c91ec1783285d80bf835b677853fc2770a89d50c1e48ac", size = 18321772, upload-time = "2025-12-20T16:17:54.947Z" }, + { url = "https://files.pythonhosted.org/packages/96/df/3c6c279accd2bfb968a76298e5b276310bd55d243df4fa8ac5816d79347d/numpy-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:77f0d13fa87036d7553bf81f0e1fe3ce68d14c9976c9851744e4d3e91127e95f", size = 6148320, upload-time = "2025-12-20T16:17:57.249Z" }, + { url = "https://files.pythonhosted.org/packages/92/8d/f23033cce252e7a75cae853d17f582e86534c46404dea1c8ee094a9d6d84/numpy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b1f5b45829ac1848893f0ddf5cb326110604d6df96cdc255b0bf9edd154104d4", size = 12623460, upload-time = "2025-12-20T16:17:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/a4/4f/1f8475907d1a7c4ef9020edf7f39ea2422ec896849245f00688e4b268a71/numpy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:23a3e9d1a6f360267e8fbb38ba5db355a6a7e9be71d7fce7ab3125e88bb646c8", size = 10661799, upload-time = "2025-12-20T16:18:01.078Z" }, ] [[package]] @@ -811,6 +937,7 @@ dependencies = [ { name = "dacite" }, { name = "earcut" }, { name = "faker" }, + { name = "fastapi" }, { name = "folium" }, { name = "geojson" }, { name = "http-message-signatures" }, @@ -837,6 +964,7 @@ dependencies = [ { name = "redis" }, { name = "shapely" }, { name = "uas-standards" }, + { name = "uvicorn" }, { name = "walrus" }, { name = "websocket-client" }, ] @@ -862,6 +990,7 @@ requires-dist = [ { name = "dacite", specifier = ">=1.9.2" }, { name = "earcut", specifier = ">=1.1.5" }, { name = "faker", specifier = "==9.3.1" }, + { name = "fastapi", specifier = ">=0.121.3" }, { name = "folium", specifier = ">=0.20.0" }, { name = "geojson", specifier = "==3.2.0" }, { name = "http-message-signatures", specifier = "==0.5.0" }, @@ -888,6 +1017,7 @@ requires-dist = [ { name = "redis", specifier = "==6.0.0" }, { name = "shapely", specifier = "==2.1.0" }, { name = "uas-standards", specifier = "==4.2.0" }, + { name = "uvicorn", specifier = ">=0.38.0" }, { name = "walrus", specifier = "==0.9.4" }, { name = "websocket-client", specifier = "==1.9.0" }, ] @@ -917,7 +1047,7 @@ wheels = [ [[package]] name = "pandas" -version = "2.3.2" +version = "2.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, @@ -925,28 +1055,41 @@ dependencies = [ { name = "pytz" }, { name = "tzdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/8e/0e90233ac205ad182bd6b422532695d2b9414944a280488105d598c70023/pandas-2.3.2.tar.gz", hash = "sha256:ab7b58f8f82706890924ccdfb5f48002b83d2b5a3845976a9fb705d36c34dcdb", size = 4488684, upload-time = "2025-08-21T10:28:29.257Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/db/614c20fb7a85a14828edd23f1c02db58a30abf3ce76f38806155d160313c/pandas-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fbb977f802156e7a3f829e9d1d5398f6192375a3e2d1a9ee0803e35fe70a2b9", size = 11587652, upload-time = "2025-08-21T10:27:15.888Z" }, - { url = "https://files.pythonhosted.org/packages/99/b0/756e52f6582cade5e746f19bad0517ff27ba9c73404607c0306585c201b3/pandas-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b9b52693123dd234b7c985c68b709b0b009f4521000d0525f2b95c22f15944b", size = 10717686, upload-time = "2025-08-21T10:27:18.486Z" }, - { url = "https://files.pythonhosted.org/packages/37/4c/dd5ccc1e357abfeee8353123282de17997f90ff67855f86154e5a13b81e5/pandas-2.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bd281310d4f412733f319a5bc552f86d62cddc5f51d2e392c8787335c994175", size = 11278722, upload-time = "2025-08-21T10:27:21.149Z" }, - { url = "https://files.pythonhosted.org/packages/d3/a4/f7edcfa47e0a88cda0be8b068a5bae710bf264f867edfdf7b71584ace362/pandas-2.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d31a6b4354e3b9b8a2c848af75d31da390657e3ac6f30c05c82068b9ed79b9", size = 11987803, upload-time = "2025-08-21T10:27:23.767Z" }, - { url = "https://files.pythonhosted.org/packages/f6/61/1bce4129f93ab66f1c68b7ed1c12bac6a70b1b56c5dab359c6bbcd480b52/pandas-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:df4df0b9d02bb873a106971bb85d448378ef14b86ba96f035f50bbd3688456b4", size = 12766345, upload-time = "2025-08-21T10:27:26.6Z" }, - { url = "https://files.pythonhosted.org/packages/8e/46/80d53de70fee835531da3a1dae827a1e76e77a43ad22a8cd0f8142b61587/pandas-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:213a5adf93d020b74327cb2c1b842884dbdd37f895f42dcc2f09d451d949f811", size = 13439314, upload-time = "2025-08-21T10:27:29.213Z" }, - { url = "https://files.pythonhosted.org/packages/28/30/8114832daff7489f179971dbc1d854109b7f4365a546e3ea75b6516cea95/pandas-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c13b81a9347eb8c7548f53fd9a4f08d4dfe996836543f805c987bafa03317ae", size = 10983326, upload-time = "2025-08-21T10:27:31.901Z" }, - { url = "https://files.pythonhosted.org/packages/27/64/a2f7bf678af502e16b472527735d168b22b7824e45a4d7e96a4fbb634b59/pandas-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0c6ecbac99a354a051ef21c5307601093cb9e0f4b1855984a084bfec9302699e", size = 11531061, upload-time = "2025-08-21T10:27:34.647Z" }, - { url = "https://files.pythonhosted.org/packages/54/4c/c3d21b2b7769ef2f4c2b9299fcadd601efa6729f1357a8dbce8dd949ed70/pandas-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c6f048aa0fd080d6a06cc7e7537c09b53be6642d330ac6f54a600c3ace857ee9", size = 10668666, upload-time = "2025-08-21T10:27:37.203Z" }, - { url = "https://files.pythonhosted.org/packages/50/e2/f775ba76ecfb3424d7f5862620841cf0edb592e9abd2d2a5387d305fe7a8/pandas-2.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0064187b80a5be6f2f9c9d6bdde29372468751dfa89f4211a3c5871854cfbf7a", size = 11332835, upload-time = "2025-08-21T10:27:40.188Z" }, - { url = "https://files.pythonhosted.org/packages/8f/52/0634adaace9be2d8cac9ef78f05c47f3a675882e068438b9d7ec7ef0c13f/pandas-2.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ac8c320bded4718b298281339c1a50fb00a6ba78cb2a63521c39bec95b0209b", size = 12057211, upload-time = "2025-08-21T10:27:43.117Z" }, - { url = "https://files.pythonhosted.org/packages/0b/9d/2df913f14b2deb9c748975fdb2491da1a78773debb25abbc7cbc67c6b549/pandas-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:114c2fe4f4328cf98ce5716d1532f3ab79c5919f95a9cfee81d9140064a2e4d6", size = 12749277, upload-time = "2025-08-21T10:27:45.474Z" }, - { url = "https://files.pythonhosted.org/packages/87/af/da1a2417026bd14d98c236dba88e39837182459d29dcfcea510b2ac9e8a1/pandas-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:48fa91c4dfb3b2b9bfdb5c24cd3567575f4e13f9636810462ffed8925352be5a", size = 13415256, upload-time = "2025-08-21T10:27:49.885Z" }, - { url = "https://files.pythonhosted.org/packages/22/3c/f2af1ce8840ef648584a6156489636b5692c162771918aa95707c165ad2b/pandas-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:12d039facec710f7ba305786837d0225a3444af7bbd9c15c32ca2d40d157ed8b", size = 10982579, upload-time = "2025-08-21T10:28:08.435Z" }, - { url = "https://files.pythonhosted.org/packages/f3/98/8df69c4097a6719e357dc249bf437b8efbde808038268e584421696cbddf/pandas-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c624b615ce97864eb588779ed4046186f967374185c047070545253a52ab2d57", size = 12028163, upload-time = "2025-08-21T10:27:52.232Z" }, - { url = "https://files.pythonhosted.org/packages/0e/23/f95cbcbea319f349e10ff90db488b905c6883f03cbabd34f6b03cbc3c044/pandas-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0cee69d583b9b128823d9514171cabb6861e09409af805b54459bd0c821a35c2", size = 11391860, upload-time = "2025-08-21T10:27:54.673Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1b/6a984e98c4abee22058aa75bfb8eb90dce58cf8d7296f8bc56c14bc330b0/pandas-2.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2319656ed81124982900b4c37f0e0c58c015af9a7bbc62342ba5ad07ace82ba9", size = 11309830, upload-time = "2025-08-21T10:27:56.957Z" }, - { url = "https://files.pythonhosted.org/packages/15/d5/f0486090eb18dd8710bf60afeaf638ba6817047c0c8ae5c6a25598665609/pandas-2.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b37205ad6f00d52f16b6d09f406434ba928c1a1966e2771006a9033c736d30d2", size = 11883216, upload-time = "2025-08-21T10:27:59.302Z" }, - { url = "https://files.pythonhosted.org/packages/10/86/692050c119696da19e20245bbd650d8dfca6ceb577da027c3a73c62a047e/pandas-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:837248b4fc3a9b83b9c6214699a13f069dc13510a6a6d7f9ba33145d2841a012", size = 12699743, upload-time = "2025-08-21T10:28:02.447Z" }, - { url = "https://files.pythonhosted.org/packages/cd/d7/612123674d7b17cf345aad0a10289b2a384bff404e0463a83c4a3a59d205/pandas-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d2c3554bd31b731cd6490d94a28f3abb8dd770634a9e06eb6d2911b9827db370", size = 13186141, upload-time = "2025-08-21T10:28:05.377Z" }, +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" }, ] [[package]] @@ -990,77 +1133,80 @@ wheels = [ [[package]] name = "pillow" -version = "11.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, - { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, - { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, - { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, - { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, - { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, - { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, - { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, - { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, - { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, - { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, - { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, - { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, - { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, - { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, - { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, - { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, - { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, - { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, - { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, - { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, - { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, - { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, - { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, - { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, - { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, - { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, - { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, - { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, - { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, - { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, - { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, - { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, - { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, - { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, - { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, - { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, - { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, - { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, - { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, - { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, - { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, - { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, - { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, - { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, - { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" }, + { url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" }, + { url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" }, + { url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, + { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, + { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, + { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, + { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, + { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, + { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, + { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, + { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, + { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, + { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, + { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, + { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, + { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, + { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, + { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, + { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, + { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, + { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, + { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, + { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, ] [[package]] name = "platformdirs" -version = "4.4.0" +version = "4.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, ] [[package]] @@ -1074,7 +1220,7 @@ wheels = [ [[package]] name = "pre-commit" -version = "4.3.0" +version = "4.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -1083,9 +1229,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] [[package]] @@ -1129,7 +1275,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.11.9" +version = "2.12.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -1137,65 +1283,94 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/5d/09a551ba512d7ca404d785072700d3f6727a02f6f3c24ecfd081c7cf0aa8/pydantic-2.11.9.tar.gz", hash = "sha256:6b8ffda597a14812a7975c90b82a8a2e777d9257aba3453f973acd3c032a18e2", size = 788495, upload-time = "2025-09-13T11:26:39.325Z" } +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/d3/108f2006987c58e76691d5ae5d200dd3e0f532cb4e5fa3560751c3a1feba/pydantic-2.11.9-py3-none-any.whl", hash = "sha256:c42dd626f5cfc1c6950ce6205ea58c93efa406da65f479dcb4029d5934857da2", size = 444855, upload-time = "2025-09-13T11:26:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, ] [[package]] name = "pydantic-core" -version = "2.33.2" +version = "2.41.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, - { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, - { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, - { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, - { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, - { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, - { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, - { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, - { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, - { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, - { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, - { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, - { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, - { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, - { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, - { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, - { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, - { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, - { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, - { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, - { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, - { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, - { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] [[package]] name = "pydantic-settings" -version = "2.11.0" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" }, + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, ] [[package]] @@ -1222,7 +1397,7 @@ wheels = [ [[package]] name = "pylint" -version = "3.3.8" +version = "4.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "astroid" }, @@ -1233,9 +1408,9 @@ dependencies = [ { name = "platformdirs" }, { name = "tomlkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/58/1f614a84d3295c542e9f6e2c764533eea3f318f4592dc1ea06c797114767/pylint-3.3.8.tar.gz", hash = "sha256:26698de19941363037e2937d3db9ed94fb3303fdadf7d98847875345a8bb6b05", size = 1523947, upload-time = "2025-08-09T09:12:57.234Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/d2/b081da1a8930d00e3fc06352a1d449aaf815d4982319fab5d8cdb2e9ab35/pylint-4.0.4.tar.gz", hash = "sha256:d9b71674e19b1c36d79265b5887bf8e55278cbe236c9e95d22dc82cf044fdbd2", size = 1571735, upload-time = "2025-11-30T13:29:04.315Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/1a/711e93a7ab6c392e349428ea56e794a3902bb4e0284c1997cff2d7efdbc1/pylint-3.3.8-py3-none-any.whl", hash = "sha256:7ef94aa692a600e82fabdd17102b73fc226758218c97473c7ad67bd4cb905d83", size = 523153, upload-time = "2025-08-09T09:12:54.836Z" }, + { url = "https://files.pythonhosted.org/packages/a6/92/d40f5d937517cc489ad848fc4414ecccc7592e4686b9071e09e64f5e378e/pylint-4.0.4-py3-none-any.whl", hash = "sha256:63e06a37d5922555ee2c20963eb42559918c20bd2b21244e4ef426e7c43b92e0", size = 536425, upload-time = "2025-11-30T13:29:02.53Z" }, ] [[package]] @@ -1280,7 +1455,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.4.2" +version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -1289,9 +1464,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, + { 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]] @@ -1405,16 +1580,16 @@ wheels = [ [[package]] name = "referencing" -version = "0.36.2" +version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744, upload-time = "2025-01-25T08:48:16.138Z" } +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775, upload-time = "2025-01-25T08:48:14.241Z" }, + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] [[package]] @@ -1434,109 +1609,109 @@ wheels = [ [[package]] name = "rpds-py" -version = "0.27.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e9/dd/2c0cbe774744272b0ae725f44032c77bdcab6e8bcf544bffa3b6e70c8dba/rpds_py-0.27.1.tar.gz", hash = "sha256:26a1c73171d10b7acccbded82bf6a586ab8203601e565badc74bbbf8bc5a10f8", size = 27479, upload-time = "2025-08-27T12:16:36.024Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/fe/38de28dee5df58b8198c743fe2bea0c785c6d40941b9950bac4cdb71a014/rpds_py-0.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ae2775c1973e3c30316892737b91f9283f9908e3cc7625b9331271eaaed7dc90", size = 361887, upload-time = "2025-08-27T12:13:10.233Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/4b6c7eedc7dd90986bf0fab6ea2a091ec11c01b15f8ba0a14d3f80450468/rpds_py-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2643400120f55c8a96f7c9d858f7be0c88d383cd4653ae2cf0d0c88f668073e5", size = 345795, upload-time = "2025-08-27T12:13:11.65Z" }, - { url = "https://files.pythonhosted.org/packages/6f/0e/e650e1b81922847a09cca820237b0edee69416a01268b7754d506ade11ad/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16323f674c089b0360674a4abd28d5042947d54ba620f72514d69be4ff64845e", size = 385121, upload-time = "2025-08-27T12:13:13.008Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ea/b306067a712988e2bff00dcc7c8f31d26c29b6d5931b461aa4b60a013e33/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a1f4814b65eacac94a00fc9a526e3fdafd78e439469644032032d0d63de4881", size = 398976, upload-time = "2025-08-27T12:13:14.368Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0a/26dc43c8840cb8fe239fe12dbc8d8de40f2365e838f3d395835dde72f0e5/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ba32c16b064267b22f1850a34051121d423b6f7338a12b9459550eb2096e7ec", size = 525953, upload-time = "2025-08-27T12:13:15.774Z" }, - { url = "https://files.pythonhosted.org/packages/22/14/c85e8127b573aaf3a0cbd7fbb8c9c99e735a4a02180c84da2a463b766e9e/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5c20f33fd10485b80f65e800bbe5f6785af510b9f4056c5a3c612ebc83ba6cb", size = 407915, upload-time = "2025-08-27T12:13:17.379Z" }, - { url = "https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5", size = 386883, upload-time = "2025-08-27T12:13:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/86/47/28fa6d60f8b74fcdceba81b272f8d9836ac0340570f68f5df6b41838547b/rpds_py-0.27.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:41e532bbdcb57c92ba3be62c42e9f096431b4cf478da9bc3bc6ce5c38ab7ba7a", size = 405699, upload-time = "2025-08-27T12:13:20.089Z" }, - { url = "https://files.pythonhosted.org/packages/d0/fd/c5987b5e054548df56953a21fe2ebed51fc1ec7c8f24fd41c067b68c4a0a/rpds_py-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f149826d742b406579466283769a8ea448eed82a789af0ed17b0cd5770433444", size = 423713, upload-time = "2025-08-27T12:13:21.436Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ba/3c4978b54a73ed19a7d74531be37a8bcc542d917c770e14d372b8daea186/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80c60cfb5310677bd67cb1e85a1e8eb52e12529545441b43e6f14d90b878775a", size = 562324, upload-time = "2025-08-27T12:13:22.789Z" }, - { url = "https://files.pythonhosted.org/packages/b5/6c/6943a91768fec16db09a42b08644b960cff540c66aab89b74be6d4a144ba/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7ee6521b9baf06085f62ba9c7a3e5becffbc32480d2f1b351559c001c38ce4c1", size = 593646, upload-time = "2025-08-27T12:13:24.122Z" }, - { url = "https://files.pythonhosted.org/packages/11/73/9d7a8f4be5f4396f011a6bb7a19fe26303a0dac9064462f5651ced2f572f/rpds_py-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a512c8263249a9d68cac08b05dd59d2b3f2061d99b322813cbcc14c3c7421998", size = 558137, upload-time = "2025-08-27T12:13:25.557Z" }, - { url = "https://files.pythonhosted.org/packages/6e/96/6772cbfa0e2485bcceef8071de7821f81aeac8bb45fbfd5542a3e8108165/rpds_py-0.27.1-cp312-cp312-win32.whl", hash = "sha256:819064fa048ba01b6dadc5116f3ac48610435ac9a0058bbde98e569f9e785c39", size = 221343, upload-time = "2025-08-27T12:13:26.967Z" }, - { url = "https://files.pythonhosted.org/packages/67/b6/c82f0faa9af1c6a64669f73a17ee0eeef25aff30bb9a1c318509efe45d84/rpds_py-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:d9199717881f13c32c4046a15f024971a3b78ad4ea029e8da6b86e5aa9cf4594", size = 232497, upload-time = "2025-08-27T12:13:28.326Z" }, - { url = "https://files.pythonhosted.org/packages/e1/96/2817b44bd2ed11aebacc9251da03689d56109b9aba5e311297b6902136e2/rpds_py-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:33aa65b97826a0e885ef6e278fbd934e98cdcfed80b63946025f01e2f5b29502", size = 222790, upload-time = "2025-08-27T12:13:29.71Z" }, - { url = "https://files.pythonhosted.org/packages/cc/77/610aeee8d41e39080c7e14afa5387138e3c9fa9756ab893d09d99e7d8e98/rpds_py-0.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e4b9fcfbc021633863a37e92571d6f91851fa656f0180246e84cbd8b3f6b329b", size = 361741, upload-time = "2025-08-27T12:13:31.039Z" }, - { url = "https://files.pythonhosted.org/packages/3a/fc/c43765f201c6a1c60be2043cbdb664013def52460a4c7adace89d6682bf4/rpds_py-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1441811a96eadca93c517d08df75de45e5ffe68aa3089924f963c782c4b898cf", size = 345574, upload-time = "2025-08-27T12:13:32.902Z" }, - { url = "https://files.pythonhosted.org/packages/20/42/ee2b2ca114294cd9847d0ef9c26d2b0851b2e7e00bf14cc4c0b581df0fc3/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55266dafa22e672f5a4f65019015f90336ed31c6383bd53f5e7826d21a0e0b83", size = 385051, upload-time = "2025-08-27T12:13:34.228Z" }, - { url = "https://files.pythonhosted.org/packages/fd/e8/1e430fe311e4799e02e2d1af7c765f024e95e17d651612425b226705f910/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d78827d7ac08627ea2c8e02c9e5b41180ea5ea1f747e9db0915e3adf36b62dcf", size = 398395, upload-time = "2025-08-27T12:13:36.132Z" }, - { url = "https://files.pythonhosted.org/packages/82/95/9dc227d441ff2670651c27a739acb2535ccaf8b351a88d78c088965e5996/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae92443798a40a92dc5f0b01d8a7c93adde0c4dc965310a29ae7c64d72b9fad2", size = 524334, upload-time = "2025-08-27T12:13:37.562Z" }, - { url = "https://files.pythonhosted.org/packages/87/01/a670c232f401d9ad461d9a332aa4080cd3cb1d1df18213dbd0d2a6a7ab51/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c46c9dd2403b66a2a3b9720ec4b74d4ab49d4fabf9f03dfdce2d42af913fe8d0", size = 407691, upload-time = "2025-08-27T12:13:38.94Z" }, - { url = "https://files.pythonhosted.org/packages/03/36/0a14aebbaa26fe7fab4780c76f2239e76cc95a0090bdb25e31d95c492fcd/rpds_py-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2efe4eb1d01b7f5f1939f4ef30ecea6c6b3521eec451fb93191bf84b2a522418", size = 386868, upload-time = "2025-08-27T12:13:40.192Z" }, - { url = "https://files.pythonhosted.org/packages/3b/03/8c897fb8b5347ff6c1cc31239b9611c5bf79d78c984430887a353e1409a1/rpds_py-0.27.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:15d3b4d83582d10c601f481eca29c3f138d44c92187d197aff663a269197c02d", size = 405469, upload-time = "2025-08-27T12:13:41.496Z" }, - { url = "https://files.pythonhosted.org/packages/da/07/88c60edc2df74850d496d78a1fdcdc7b54360a7f610a4d50008309d41b94/rpds_py-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4ed2e16abbc982a169d30d1a420274a709949e2cbdef119fe2ec9d870b42f274", size = 422125, upload-time = "2025-08-27T12:13:42.802Z" }, - { url = "https://files.pythonhosted.org/packages/6b/86/5f4c707603e41b05f191a749984f390dabcbc467cf833769b47bf14ba04f/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a75f305c9b013289121ec0f1181931975df78738cdf650093e6b86d74aa7d8dd", size = 562341, upload-time = "2025-08-27T12:13:44.472Z" }, - { url = "https://files.pythonhosted.org/packages/b2/92/3c0cb2492094e3cd9baf9e49bbb7befeceb584ea0c1a8b5939dca4da12e5/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67ce7620704745881a3d4b0ada80ab4d99df390838839921f99e63c474f82cf2", size = 592511, upload-time = "2025-08-27T12:13:45.898Z" }, - { url = "https://files.pythonhosted.org/packages/10/bb/82e64fbb0047c46a168faa28d0d45a7851cd0582f850b966811d30f67ad8/rpds_py-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d992ac10eb86d9b6f369647b6a3f412fc0075cfd5d799530e84d335e440a002", size = 557736, upload-time = "2025-08-27T12:13:47.408Z" }, - { url = "https://files.pythonhosted.org/packages/00/95/3c863973d409210da7fb41958172c6b7dbe7fc34e04d3cc1f10bb85e979f/rpds_py-0.27.1-cp313-cp313-win32.whl", hash = "sha256:4f75e4bd8ab8db624e02c8e2fc4063021b58becdbe6df793a8111d9343aec1e3", size = 221462, upload-time = "2025-08-27T12:13:48.742Z" }, - { url = "https://files.pythonhosted.org/packages/ce/2c/5867b14a81dc217b56d95a9f2a40fdbc56a1ab0181b80132beeecbd4b2d6/rpds_py-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:f9025faafc62ed0b75a53e541895ca272815bec18abe2249ff6501c8f2e12b83", size = 232034, upload-time = "2025-08-27T12:13:50.11Z" }, - { url = "https://files.pythonhosted.org/packages/c7/78/3958f3f018c01923823f1e47f1cc338e398814b92d83cd278364446fac66/rpds_py-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:ed10dc32829e7d222b7d3b93136d25a406ba9788f6a7ebf6809092da1f4d279d", size = 222392, upload-time = "2025-08-27T12:13:52.587Z" }, - { url = "https://files.pythonhosted.org/packages/01/76/1cdf1f91aed5c3a7bf2eba1f1c4e4d6f57832d73003919a20118870ea659/rpds_py-0.27.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:92022bbbad0d4426e616815b16bc4127f83c9a74940e1ccf3cfe0b387aba0228", size = 358355, upload-time = "2025-08-27T12:13:54.012Z" }, - { url = "https://files.pythonhosted.org/packages/c3/6f/bf142541229374287604caf3bb2a4ae17f0a580798fd72d3b009b532db4e/rpds_py-0.27.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:47162fdab9407ec3f160805ac3e154df042e577dd53341745fc7fb3f625e6d92", size = 342138, upload-time = "2025-08-27T12:13:55.791Z" }, - { url = "https://files.pythonhosted.org/packages/1a/77/355b1c041d6be40886c44ff5e798b4e2769e497b790f0f7fd1e78d17e9a8/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb89bec23fddc489e5d78b550a7b773557c9ab58b7946154a10a6f7a214a48b2", size = 380247, upload-time = "2025-08-27T12:13:57.683Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a4/d9cef5c3946ea271ce2243c51481971cd6e34f21925af2783dd17b26e815/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e48af21883ded2b3e9eb48cb7880ad8598b31ab752ff3be6457001d78f416723", size = 390699, upload-time = "2025-08-27T12:13:59.137Z" }, - { url = "https://files.pythonhosted.org/packages/3a/06/005106a7b8c6c1a7e91b73169e49870f4af5256119d34a361ae5240a0c1d/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f5b7bd8e219ed50299e58551a410b64daafb5017d54bbe822e003856f06a802", size = 521852, upload-time = "2025-08-27T12:14:00.583Z" }, - { url = "https://files.pythonhosted.org/packages/e5/3e/50fb1dac0948e17a02eb05c24510a8fe12d5ce8561c6b7b7d1339ab7ab9c/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08f1e20bccf73b08d12d804d6e1c22ca5530e71659e6673bce31a6bb71c1e73f", size = 402582, upload-time = "2025-08-27T12:14:02.034Z" }, - { url = "https://files.pythonhosted.org/packages/cb/b0/f4e224090dc5b0ec15f31a02d746ab24101dd430847c4d99123798661bfc/rpds_py-0.27.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0dc5dceeaefcc96dc192e3a80bbe1d6c410c469e97bdd47494a7d930987f18b2", size = 384126, upload-time = "2025-08-27T12:14:03.437Z" }, - { url = "https://files.pythonhosted.org/packages/54/77/ac339d5f82b6afff1df8f0fe0d2145cc827992cb5f8eeb90fc9f31ef7a63/rpds_py-0.27.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d76f9cc8665acdc0c9177043746775aa7babbf479b5520b78ae4002d889f5c21", size = 399486, upload-time = "2025-08-27T12:14:05.443Z" }, - { url = "https://files.pythonhosted.org/packages/d6/29/3e1c255eee6ac358c056a57d6d6869baa00a62fa32eea5ee0632039c50a3/rpds_py-0.27.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:134fae0e36022edad8290a6661edf40c023562964efea0cc0ec7f5d392d2aaef", size = 414832, upload-time = "2025-08-27T12:14:06.902Z" }, - { url = "https://files.pythonhosted.org/packages/3f/db/6d498b844342deb3fa1d030598db93937a9964fcf5cb4da4feb5f17be34b/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb11a4f1b2b63337cfd3b4d110af778a59aae51c81d195768e353d8b52f88081", size = 557249, upload-time = "2025-08-27T12:14:08.37Z" }, - { url = "https://files.pythonhosted.org/packages/60/f3/690dd38e2310b6f68858a331399b4d6dbb9132c3e8ef8b4333b96caf403d/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13e608ac9f50a0ed4faec0e90ece76ae33b34c0e8656e3dceb9a7db994c692cd", size = 587356, upload-time = "2025-08-27T12:14:10.034Z" }, - { url = "https://files.pythonhosted.org/packages/86/e3/84507781cccd0145f35b1dc32c72675200c5ce8d5b30f813e49424ef68fc/rpds_py-0.27.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dd2135527aa40f061350c3f8f89da2644de26cd73e4de458e79606384f4f68e7", size = 555300, upload-time = "2025-08-27T12:14:11.783Z" }, - { url = "https://files.pythonhosted.org/packages/e5/ee/375469849e6b429b3516206b4580a79e9ef3eb12920ddbd4492b56eaacbe/rpds_py-0.27.1-cp313-cp313t-win32.whl", hash = "sha256:3020724ade63fe320a972e2ffd93b5623227e684315adce194941167fee02688", size = 216714, upload-time = "2025-08-27T12:14:13.629Z" }, - { url = "https://files.pythonhosted.org/packages/21/87/3fc94e47c9bd0742660e84706c311a860dcae4374cf4a03c477e23ce605a/rpds_py-0.27.1-cp313-cp313t-win_amd64.whl", hash = "sha256:8ee50c3e41739886606388ba3ab3ee2aae9f35fb23f833091833255a31740797", size = 228943, upload-time = "2025-08-27T12:14:14.937Z" }, - { url = "https://files.pythonhosted.org/packages/70/36/b6e6066520a07cf029d385de869729a895917b411e777ab1cde878100a1d/rpds_py-0.27.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:acb9aafccaae278f449d9c713b64a9e68662e7799dbd5859e2c6b3c67b56d334", size = 362472, upload-time = "2025-08-27T12:14:16.333Z" }, - { url = "https://files.pythonhosted.org/packages/af/07/b4646032e0dcec0df9c73a3bd52f63bc6c5f9cda992f06bd0e73fe3fbebd/rpds_py-0.27.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b7fb801aa7f845ddf601c49630deeeccde7ce10065561d92729bfe81bd21fb33", size = 345676, upload-time = "2025-08-27T12:14:17.764Z" }, - { url = "https://files.pythonhosted.org/packages/b0/16/2f1003ee5d0af4bcb13c0cf894957984c32a6751ed7206db2aee7379a55e/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe0dd05afb46597b9a2e11c351e5e4283c741237e7f617ffb3252780cca9336a", size = 385313, upload-time = "2025-08-27T12:14:19.829Z" }, - { url = "https://files.pythonhosted.org/packages/05/cd/7eb6dd7b232e7f2654d03fa07f1414d7dfc980e82ba71e40a7c46fd95484/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b6dfb0e058adb12d8b1d1b25f686e94ffa65d9995a5157afe99743bf7369d62b", size = 399080, upload-time = "2025-08-27T12:14:21.531Z" }, - { url = "https://files.pythonhosted.org/packages/20/51/5829afd5000ec1cb60f304711f02572d619040aa3ec033d8226817d1e571/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed090ccd235f6fa8bb5861684567f0a83e04f52dfc2e5c05f2e4b1309fcf85e7", size = 523868, upload-time = "2025-08-27T12:14:23.485Z" }, - { url = "https://files.pythonhosted.org/packages/05/2c/30eebca20d5db95720ab4d2faec1b5e4c1025c473f703738c371241476a2/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf876e79763eecf3e7356f157540d6a093cef395b65514f17a356f62af6cc136", size = 408750, upload-time = "2025-08-27T12:14:24.924Z" }, - { url = "https://files.pythonhosted.org/packages/90/1a/cdb5083f043597c4d4276eae4e4c70c55ab5accec078da8611f24575a367/rpds_py-0.27.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12ed005216a51b1d6e2b02a7bd31885fe317e45897de81d86dcce7d74618ffff", size = 387688, upload-time = "2025-08-27T12:14:27.537Z" }, - { url = "https://files.pythonhosted.org/packages/7c/92/cf786a15320e173f945d205ab31585cc43969743bb1a48b6888f7a2b0a2d/rpds_py-0.27.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ee4308f409a40e50593c7e3bb8cbe0b4d4c66d1674a316324f0c2f5383b486f9", size = 407225, upload-time = "2025-08-27T12:14:28.981Z" }, - { url = "https://files.pythonhosted.org/packages/33/5c/85ee16df5b65063ef26017bef33096557a4c83fbe56218ac7cd8c235f16d/rpds_py-0.27.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0b08d152555acf1f455154d498ca855618c1378ec810646fcd7c76416ac6dc60", size = 423361, upload-time = "2025-08-27T12:14:30.469Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8e/1c2741307fcabd1a334ecf008e92c4f47bb6f848712cf15c923becfe82bb/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dce51c828941973a5684d458214d3a36fcd28da3e1875d659388f4f9f12cc33e", size = 562493, upload-time = "2025-08-27T12:14:31.987Z" }, - { url = "https://files.pythonhosted.org/packages/04/03/5159321baae9b2222442a70c1f988cbbd66b9be0675dd3936461269be360/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c1476d6f29eb81aa4151c9a31219b03f1f798dc43d8af1250a870735516a1212", size = 592623, upload-time = "2025-08-27T12:14:33.543Z" }, - { url = "https://files.pythonhosted.org/packages/ff/39/c09fd1ad28b85bc1d4554a8710233c9f4cefd03d7717a1b8fbfd171d1167/rpds_py-0.27.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3ce0cac322b0d69b63c9cdb895ee1b65805ec9ffad37639f291dd79467bee675", size = 558800, upload-time = "2025-08-27T12:14:35.436Z" }, - { url = "https://files.pythonhosted.org/packages/c5/d6/99228e6bbcf4baa764b18258f519a9035131d91b538d4e0e294313462a98/rpds_py-0.27.1-cp314-cp314-win32.whl", hash = "sha256:dfbfac137d2a3d0725758cd141f878bf4329ba25e34979797c89474a89a8a3a3", size = 221943, upload-time = "2025-08-27T12:14:36.898Z" }, - { url = "https://files.pythonhosted.org/packages/be/07/c802bc6b8e95be83b79bdf23d1aa61d68324cb1006e245d6c58e959e314d/rpds_py-0.27.1-cp314-cp314-win_amd64.whl", hash = "sha256:a6e57b0abfe7cc513450fcf529eb486b6e4d3f8aee83e92eb5f1ef848218d456", size = 233739, upload-time = "2025-08-27T12:14:38.386Z" }, - { url = "https://files.pythonhosted.org/packages/c8/89/3e1b1c16d4c2d547c5717377a8df99aee8099ff050f87c45cb4d5fa70891/rpds_py-0.27.1-cp314-cp314-win_arm64.whl", hash = "sha256:faf8d146f3d476abfee026c4ae3bdd9ca14236ae4e4c310cbd1cf75ba33d24a3", size = 223120, upload-time = "2025-08-27T12:14:39.82Z" }, - { url = "https://files.pythonhosted.org/packages/62/7e/dc7931dc2fa4a6e46b2a4fa744a9fe5c548efd70e0ba74f40b39fa4a8c10/rpds_py-0.27.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ba81d2b56b6d4911ce735aad0a1d4495e808b8ee4dc58715998741a26874e7c2", size = 358944, upload-time = "2025-08-27T12:14:41.199Z" }, - { url = "https://files.pythonhosted.org/packages/e6/22/4af76ac4e9f336bfb1a5f240d18a33c6b2fcaadb7472ac7680576512b49a/rpds_py-0.27.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:84f7d509870098de0e864cad0102711c1e24e9b1a50ee713b65928adb22269e4", size = 342283, upload-time = "2025-08-27T12:14:42.699Z" }, - { url = "https://files.pythonhosted.org/packages/1c/15/2a7c619b3c2272ea9feb9ade67a45c40b3eeb500d503ad4c28c395dc51b4/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e960fc78fecd1100539f14132425e1d5fe44ecb9239f8f27f079962021523e", size = 380320, upload-time = "2025-08-27T12:14:44.157Z" }, - { url = "https://files.pythonhosted.org/packages/a2/7d/4c6d243ba4a3057e994bb5bedd01b5c963c12fe38dde707a52acdb3849e7/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62f85b665cedab1a503747617393573995dac4600ff51869d69ad2f39eb5e817", size = 391760, upload-time = "2025-08-27T12:14:45.845Z" }, - { url = "https://files.pythonhosted.org/packages/b4/71/b19401a909b83bcd67f90221330bc1ef11bc486fe4e04c24388d28a618ae/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fed467af29776f6556250c9ed85ea5a4dd121ab56a5f8b206e3e7a4c551e48ec", size = 522476, upload-time = "2025-08-27T12:14:47.364Z" }, - { url = "https://files.pythonhosted.org/packages/e4/44/1a3b9715c0455d2e2f0f6df5ee6d6f5afdc423d0773a8a682ed2b43c566c/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2729615f9d430af0ae6b36cf042cb55c0936408d543fb691e1a9e36648fd35a", size = 403418, upload-time = "2025-08-27T12:14:49.991Z" }, - { url = "https://files.pythonhosted.org/packages/1c/4b/fb6c4f14984eb56673bc868a66536f53417ddb13ed44b391998100a06a96/rpds_py-0.27.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b207d881a9aef7ba753d69c123a35d96ca7cb808056998f6b9e8747321f03b8", size = 384771, upload-time = "2025-08-27T12:14:52.159Z" }, - { url = "https://files.pythonhosted.org/packages/c0/56/d5265d2d28b7420d7b4d4d85cad8ef891760f5135102e60d5c970b976e41/rpds_py-0.27.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:639fd5efec029f99b79ae47e5d7e00ad8a773da899b6309f6786ecaf22948c48", size = 400022, upload-time = "2025-08-27T12:14:53.859Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e9/9f5fc70164a569bdd6ed9046486c3568d6926e3a49bdefeeccfb18655875/rpds_py-0.27.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fecc80cb2a90e28af8a9b366edacf33d7a91cbfe4c2c4544ea1246e949cfebeb", size = 416787, upload-time = "2025-08-27T12:14:55.673Z" }, - { url = "https://files.pythonhosted.org/packages/d4/64/56dd03430ba491db943a81dcdef115a985aac5f44f565cd39a00c766d45c/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42a89282d711711d0a62d6f57d81aa43a1368686c45bc1c46b7f079d55692734", size = 557538, upload-time = "2025-08-27T12:14:57.245Z" }, - { url = "https://files.pythonhosted.org/packages/3f/36/92cc885a3129993b1d963a2a42ecf64e6a8e129d2c7cc980dbeba84e55fb/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cf9931f14223de59551ab9d38ed18d92f14f055a5f78c1d8ad6493f735021bbb", size = 588512, upload-time = "2025-08-27T12:14:58.728Z" }, - { url = "https://files.pythonhosted.org/packages/dd/10/6b283707780a81919f71625351182b4f98932ac89a09023cb61865136244/rpds_py-0.27.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f39f58a27cc6e59f432b568ed8429c7e1641324fbe38131de852cd77b2d534b0", size = 555813, upload-time = "2025-08-27T12:15:00.334Z" }, - { url = "https://files.pythonhosted.org/packages/04/2e/30b5ea18c01379da6272a92825dd7e53dc9d15c88a19e97932d35d430ef7/rpds_py-0.27.1-cp314-cp314t-win32.whl", hash = "sha256:d5fa0ee122dc09e23607a28e6d7b150da16c662e66409bbe85230e4c85bb528a", size = 217385, upload-time = "2025-08-27T12:15:01.937Z" }, - { url = "https://files.pythonhosted.org/packages/32/7d/97119da51cb1dd3f2f3c0805f155a3aa4a95fa44fe7d78ae15e69edf4f34/rpds_py-0.27.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6567d2bb951e21232c2f660c24cf3470bb96de56cdcb3f071a83feeaff8a2772", size = 230097, upload-time = "2025-08-27T12:15:03.961Z" }, +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, ] [[package]] name = "ruff" -version = "0.13.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/df/8d7d8c515d33adfc540e2edf6c6021ea1c5a58a678d8cfce9fae59aabcab/ruff-0.13.2.tar.gz", hash = "sha256:cb12fffd32fb16d32cef4ed16d8c7cdc27ed7c944eaa98d99d01ab7ab0b710ff", size = 5416417, upload-time = "2025-09-25T14:54:09.936Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/84/5716a7fa4758e41bf70e603e13637c42cfb9dbf7ceb07180211b9bbf75ef/ruff-0.13.2-py3-none-linux_armv6l.whl", hash = "sha256:3796345842b55f033a78285e4f1641078f902020d8450cade03aad01bffd81c3", size = 12343254, upload-time = "2025-09-25T14:53:27.784Z" }, - { url = "https://files.pythonhosted.org/packages/9b/77/c7042582401bb9ac8eff25360e9335e901d7a1c0749a2b28ba4ecb239991/ruff-0.13.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ff7e4dda12e683e9709ac89e2dd436abf31a4d8a8fc3d89656231ed808e231d2", size = 13040891, upload-time = "2025-09-25T14:53:31.38Z" }, - { url = "https://files.pythonhosted.org/packages/c6/15/125a7f76eb295cb34d19c6778e3a82ace33730ad4e6f28d3427e134a02e0/ruff-0.13.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c75e9d2a2fafd1fdd895d0e7e24b44355984affdde1c412a6f6d3f6e16b22d46", size = 12243588, upload-time = "2025-09-25T14:53:33.543Z" }, - { url = "https://files.pythonhosted.org/packages/9e/eb/0093ae04a70f81f8be7fd7ed6456e926b65d238fc122311293d033fdf91e/ruff-0.13.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cceac74e7bbc53ed7d15d1042ffe7b6577bf294611ad90393bf9b2a0f0ec7cb6", size = 12491359, upload-time = "2025-09-25T14:53:35.892Z" }, - { url = "https://files.pythonhosted.org/packages/43/fe/72b525948a6956f07dad4a6f122336b6a05f2e3fd27471cea612349fedb9/ruff-0.13.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6ae3f469b5465ba6d9721383ae9d49310c19b452a161b57507764d7ef15f4b07", size = 12162486, upload-time = "2025-09-25T14:53:38.171Z" }, - { url = "https://files.pythonhosted.org/packages/6a/e3/0fac422bbbfb2ea838023e0d9fcf1f30183d83ab2482800e2cb892d02dfe/ruff-0.13.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f8f9e3cd6714358238cd6626b9d43026ed19c0c018376ac1ef3c3a04ffb42d8", size = 13871203, upload-time = "2025-09-25T14:53:41.943Z" }, - { url = "https://files.pythonhosted.org/packages/6b/82/b721c8e3ec5df6d83ba0e45dcf00892c4f98b325256c42c38ef136496cbf/ruff-0.13.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c6ed79584a8f6cbe2e5d7dbacf7cc1ee29cbdb5df1172e77fbdadc8bb85a1f89", size = 14929635, upload-time = "2025-09-25T14:53:43.953Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a0/ad56faf6daa507b83079a1ad7a11694b87d61e6bf01c66bd82b466f21821/ruff-0.13.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aed130b2fde049cea2019f55deb939103123cdd191105f97a0599a3e753d61b0", size = 14338783, upload-time = "2025-09-25T14:53:46.205Z" }, - { url = "https://files.pythonhosted.org/packages/47/77/ad1d9156db8f99cd01ee7e29d74b34050e8075a8438e589121fcd25c4b08/ruff-0.13.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1887c230c2c9d65ed1b4e4cfe4d255577ea28b718ae226c348ae68df958191aa", size = 13355322, upload-time = "2025-09-25T14:53:48.164Z" }, - { url = "https://files.pythonhosted.org/packages/64/8b/e87cfca2be6f8b9f41f0bb12dc48c6455e2d66df46fe61bb441a226f1089/ruff-0.13.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bcb10276b69b3cfea3a102ca119ffe5c6ba3901e20e60cf9efb53fa417633c3", size = 13354427, upload-time = "2025-09-25T14:53:50.486Z" }, - { url = "https://files.pythonhosted.org/packages/7f/df/bf382f3fbead082a575edb860897287f42b1b3c694bafa16bc9904c11ed3/ruff-0.13.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:afa721017aa55a555b2ff7944816587f1cb813c2c0a882d158f59b832da1660d", size = 13537637, upload-time = "2025-09-25T14:53:52.887Z" }, - { url = "https://files.pythonhosted.org/packages/51/70/1fb7a7c8a6fc8bd15636288a46e209e81913b87988f26e1913d0851e54f4/ruff-0.13.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1dbc875cf3720c64b3990fef8939334e74cb0ca65b8dbc61d1f439201a38101b", size = 12340025, upload-time = "2025-09-25T14:53:54.88Z" }, - { url = "https://files.pythonhosted.org/packages/4c/27/1e5b3f1c23ca5dd4106d9d580e5c13d9acb70288bff614b3d7b638378cc9/ruff-0.13.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939a1b2a960e9742e9a347e5bbc9b3c3d2c716f86c6ae273d9cbd64f193f22", size = 12133449, upload-time = "2025-09-25T14:53:57.089Z" }, - { url = "https://files.pythonhosted.org/packages/2d/09/b92a5ccee289f11ab128df57d5911224197d8d55ef3bd2043534ff72ca54/ruff-0.13.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:50e2d52acb8de3804fc5f6e2fa3ae9bdc6812410a9e46837e673ad1f90a18736", size = 13051369, upload-time = "2025-09-25T14:53:59.124Z" }, - { url = "https://files.pythonhosted.org/packages/89/99/26c9d1c7d8150f45e346dc045cc49f23e961efceb4a70c47dea0960dea9a/ruff-0.13.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3196bc13ab2110c176b9a4ae5ff7ab676faaa1964b330a1383ba20e1e19645f2", size = 13523644, upload-time = "2025-09-25T14:54:01.622Z" }, - { url = "https://files.pythonhosted.org/packages/f7/00/e7f1501e81e8ec290e79527827af1d88f541d8d26151751b46108978dade/ruff-0.13.2-py3-none-win32.whl", hash = "sha256:7c2a0b7c1e87795fec3404a485096bcd790216c7c146a922d121d8b9c8f1aaac", size = 12245990, upload-time = "2025-09-25T14:54:03.647Z" }, - { url = "https://files.pythonhosted.org/packages/ee/bd/d9f33a73de84fafd0146c6fba4f497c4565fe8fa8b46874b8e438869abc2/ruff-0.13.2-py3-none-win_amd64.whl", hash = "sha256:17d95fb32218357c89355f6f6f9a804133e404fc1f65694372e02a557edf8585", size = 13324004, upload-time = "2025-09-25T14:54:06.05Z" }, - { url = "https://files.pythonhosted.org/packages/c3/12/28fa2f597a605884deb0f65c1b1ae05111051b2a7030f5d8a4ff7f4599ba/ruff-0.13.2-py3-none-win_arm64.whl", hash = "sha256:da711b14c530412c827219312b7d7fbb4877fb31150083add7e8c5336549cea7", size = 12484437, upload-time = "2025-09-25T14:54:08.022Z" }, +version = "0.14.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, + { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, + { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, ] [[package]] @@ -1583,15 +1758,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - [[package]] name = "stack-data" version = "0.6.3" @@ -1606,6 +1772,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + [[package]] name = "text-unidecode" version = "1.3" @@ -1635,39 +1814,39 @@ wheels = [ [[package]] name = "traittypes" -version = "0.2.1" +version = "0.2.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/71/0578e44d2110f93c2136eb705f5b11e706e1e8ea3acaaaeac043bd40d8fd/traittypes-0.2.1.tar.gz", hash = "sha256:be6fa26294733e7489822ded4ae25da5b4824a8a7a0e0c2dccfde596e3489bd6", size = 13544, upload-time = "2018-06-16T07:34:00.929Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/8d/37d686f52dfbccc47b857751531ffdec262b0f35158dd3b306030dafdb83/traittypes-0.2.3.tar.gz", hash = "sha256:212feed38d566d772648768b78d3347c148ef23915b91c02078188e631316c86", size = 16003, upload-time = "2025-10-22T11:06:09.952Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/d1/8d5bd662703cc1764d986f6908a608777305946fa634d34c470cd4a1e729/traittypes-0.2.1-py2.py3-none-any.whl", hash = "sha256:1340af133810b6eee1a2eb2e988f862b0d12b6c2d16f282aaf3207b782134c2e", size = 8550, upload-time = "2018-06-16T07:33:59.594Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c0/fdf9d3ee103ce66a55f0532835ad5e154226c5222423c6636ba049dc42fc/traittypes-0.2.3-py2.py3-none-any.whl", hash = "sha256:49016082ce740d6556d9bb4672ee2d899cd14f9365f17cbb79d5d96b47096d4e", size = 8130, upload-time = "2025-10-22T11:06:08.824Z" }, ] [[package]] name = "ty" -version = "0.0.1a21" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/0f/65606ccee2da5a05a3c3362f5233f058e9d29d3c5521697c7ae79545d246/ty-0.0.1a21.tar.gz", hash = "sha256:e941e9a9d1e54b03eeaf9c3197c26a19cf76009fd5e41e16e5657c1c827bd6d3", size = 4263980, upload-time = "2025-09-19T06:54:06.412Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/7a/c87a42d0a45cfa2d5c06c8d66aa1b243db16dc31b25e545fb0263308523b/ty-0.0.1a21-py3-none-linux_armv6l.whl", hash = "sha256:1f276ceab23a1410aec09508248c76ae0989c67fb7a0c287e0d4564994295531", size = 8421116, upload-time = "2025-09-19T06:53:35.029Z" }, - { url = "https://files.pythonhosted.org/packages/99/c2/721bf4fa21c84d4cdae0e57a06a88e7e64fc2dca38820232bd6cbeef644f/ty-0.0.1a21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c3bc66fcae41eff133cfe326dd65d82567a2fb5d4efe2128773b10ec2766819", size = 8512556, upload-time = "2025-09-19T06:53:37.455Z" }, - { url = "https://files.pythonhosted.org/packages/6c/58/b0585d9d61673e864a87e95760dfa2a90ac15702e7612ab064d354f6752a/ty-0.0.1a21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cc0880ec344fbdf736b05d8d0da01f0caaaa02409bd9a24b68d18d0127a79b0e", size = 8109188, upload-time = "2025-09-19T06:53:39.469Z" }, - { url = "https://files.pythonhosted.org/packages/ea/08/edf7b59ba24bb1a1af341207fc5a0106eb1fe4264c1d7fb672c171dd2daf/ty-0.0.1a21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:334d2a212ebf42a0e55d57561926af7679fe1e878175e11dcb81ad8df892844e", size = 8279000, upload-time = "2025-09-19T06:53:41.309Z" }, - { url = "https://files.pythonhosted.org/packages/05/8e/4b5e562623e0aa24a3972510287b4bc5d98251afb353388d14008ea99954/ty-0.0.1a21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8c769987d00fbc33054ff7e342633f475ea10dc43bc60fb9fb056159d48cb90", size = 8243261, upload-time = "2025-09-19T06:53:42.736Z" }, - { url = "https://files.pythonhosted.org/packages/c3/09/6476fa21f9962d5b9c8e8053fd0442ed8e3ceb7502e39700ab1935555199/ty-0.0.1a21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:218d53e7919e885bd98e9196d9cb952d82178b299aa36da6f7f39333eb7400ed", size = 9150228, upload-time = "2025-09-19T06:53:44.242Z" }, - { url = "https://files.pythonhosted.org/packages/d2/96/49c158b6255fc1e22a5701c38f7d4c1b7f8be17a476ce9226fcae82a7b36/ty-0.0.1a21-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:84243455f295ed850bd53f7089819321807d4e6ee3b1cbff6086137ae0259466", size = 9628323, upload-time = "2025-09-19T06:53:45.998Z" }, - { url = "https://files.pythonhosted.org/packages/f4/65/37a8a5cb7b3254365c54b5e10f069e311c4252ed160b86fabd1203fbca5c/ty-0.0.1a21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87a200c21e02962e8a27374d9d152582331d57d709672431be58f4f898bf6cad", size = 9251233, upload-time = "2025-09-19T06:53:48.042Z" }, - { url = "https://files.pythonhosted.org/packages/a3/30/5b06120747da4a0f0bc54a4b051b42172603033dbee0bcf51bce7c21ada9/ty-0.0.1a21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be8f457d7841b7ead2a3f6b65ba668abc172a1150a0f1f6c0958af3725dbb61a", size = 8996186, upload-time = "2025-09-19T06:53:49.753Z" }, - { url = "https://files.pythonhosted.org/packages/af/fc/5aa122536b1acb57389f404f6328c20342242b78513a60459fee9b7d6f27/ty-0.0.1a21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1474d883129bb63da3b2380fc7ead824cd3baf6a9551e6aa476ffefc58057af3", size = 8722848, upload-time = "2025-09-19T06:53:51.566Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c1/456dcc65a149df8410b1d75f0197a31d4beef74b7bb44cce42b03bf074e8/ty-0.0.1a21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0efba2e52b58f536f4198ba5c4a36cac2ba67d83ec6f429ebc7704233bcda4c3", size = 8220727, upload-time = "2025-09-19T06:53:53.753Z" }, - { url = "https://files.pythonhosted.org/packages/a4/86/b37505d942cd68235be5be407e43e15afa36669aaa2db9b6e5b43c1d9f91/ty-0.0.1a21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5dfc73299d441cc6454e36ed0a976877415024143dfca6592dc36f7701424383", size = 8279114, upload-time = "2025-09-19T06:53:55.343Z" }, - { url = "https://files.pythonhosted.org/packages/55/fe/0d9816f36d258e6b2a3d7518421be17c68954ea9a66b638de49588cc2e27/ty-0.0.1a21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ba13d03b9e095216ceb4e4d554a308517f28ab0a6e4dcd07cfe94563e4c2c489", size = 8701798, upload-time = "2025-09-19T06:53:57.17Z" }, - { url = "https://files.pythonhosted.org/packages/4e/7a/70539932e3e5a36c54bd5432ff44ed0c301c41a528365d8de5b8f79f4317/ty-0.0.1a21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9463cac96b8f1bb5ba740fe1d42cd6bd152b43c5b159b2f07f8fd629bcdded34", size = 8872676, upload-time = "2025-09-19T06:53:59.357Z" }, - { url = "https://files.pythonhosted.org/packages/ea/94/809d85f6982841fe28526ace3b282b0458d0a96bbc6b1a982d9269a5e481/ty-0.0.1a21-py3-none-win32.whl", hash = "sha256:ecf41706b803827b0de8717f32a434dad1e67be9f4b8caf403e12013179ea06a", size = 8003866, upload-time = "2025-09-19T06:54:01.393Z" }, - { url = "https://files.pythonhosted.org/packages/50/16/b3e914cec2a6344d2c30d3780ca6ecd39667173611f8776cecfd1294eab9/ty-0.0.1a21-py3-none-win_amd64.whl", hash = "sha256:7505aeb8bf2a62f00f12cfa496f6c965074d75c8126268776565284c8a12d5dd", size = 8675300, upload-time = "2025-09-19T06:54:02.893Z" }, - { url = "https://files.pythonhosted.org/packages/16/0b/293be6bc19f6da5e9b15e615a7100504f307dd4294d2c61cee3de91198e5/ty-0.0.1a21-py3-none-win_arm64.whl", hash = "sha256:21f708d02b6588323ffdbfdba38830dd0ecfd626db50aa6006b296b5470e52f9", size = 8193800, upload-time = "2025-09-19T06:54:04.583Z" }, +version = "0.0.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/0d/ed8a66c10ca2ec5d80d683f945c1d0ef6b030905baca4cc4ec5082c62a9f/ty-0.0.6.tar.gz", hash = "sha256:ecf195494fe442daac961ccbf1be286471b92a690adf1ae86de252cc0ce766e8", size = 4818224, upload-time = "2025-12-23T22:15:47.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/95/d5334c41e006242211719c69625e622b4cbab4eaa2c1da485f6f5d64da56/ty-0.0.6-py3-none-linux_armv6l.whl", hash = "sha256:02a600a9d8e5489097e7566f048d1be471bcd17d52f9236d659b3dd3d8bce3bd", size = 9865948, upload-time = "2025-12-23T22:16:08.429Z" }, + { url = "https://files.pythonhosted.org/packages/91/11/e0ef9b8e56c6fbfa6c3cda1dcfde21d670e75bce142233a748542f1969da/ty-0.0.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4e4654bf04bd517e02a2afe105ce86b62815900db7cca2d213284a0573635103", size = 9689798, upload-time = "2025-12-23T22:16:10.069Z" }, + { url = "https://files.pythonhosted.org/packages/9f/39/8afc6421ac283b23854b4e08d6c498e33ba2f8f1c2a0c669da1852191451/ty-0.0.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4bfdaa6f54a9123d9a5dd094596fc671015ec0f3ef2fad14494246c4654281bc", size = 9205779, upload-time = "2025-12-23T22:16:06.755Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/f6cb1781ad7cc55bd03264979cce97aa761064bd9e2959e5334dc9ee5d38/ty-0.0.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b42b1abc068c5012f60618139852f02f6d2c566979edf2306ef1ac15dd9ee4c4", size = 9693432, upload-time = "2025-12-23T22:15:54.679Z" }, + { url = "https://files.pythonhosted.org/packages/90/a7/2fb7f711ccbe2038b922b6b9daaddabad9df81b67a23b93fb82bbf59ae68/ty-0.0.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a890c1145ace0e16e3d1be4df8272ed5915ac35bd75e4a0e1c53001ca090713", size = 9668051, upload-time = "2025-12-23T22:16:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/01/4e/5bba4863f448f1bd593c01bb9616349c56ab4ec3c076cff3c0f840044875/ty-0.0.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f65b5d1505aa8a6ffb9dad673a3aa11c77411baa132d32b62e39662dafcb94ee", size = 10099592, upload-time = "2025-12-23T22:16:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/66/83/0c417c213003e21c976af8e897abc2565811d90935a820348d9507de5b66/ty-0.0.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7bb4d332d02e9b4abe3cbaa2f53fd0fe4c8d12b187a8aa398b658ba2f4f9f5a7", size = 10987979, upload-time = "2025-12-23T22:15:52.502Z" }, + { url = "https://files.pythonhosted.org/packages/31/2f/82ba22fc1f2f0edd123505c58a1736d83b9fd9fcf670357f68f4b1917f52/ty-0.0.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9331ba14dd90fa6f3650786d3fa5a08a4382e4e20a0879316843d09fc739f2a4", size = 10690933, upload-time = "2025-12-23T22:16:14.34Z" }, + { url = "https://files.pythonhosted.org/packages/8b/1a/d3e40753820b40218af0d9c6945b347ca524dd57dbe1a8d0160e980e9a91/ty-0.0.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c8bb30bc8e45fb5caba70bb63e338027019167a7f1e733a15f27eaabed28522", size = 10521677, upload-time = "2025-12-23T22:15:50.157Z" }, + { url = "https://files.pythonhosted.org/packages/38/c8/cd8c34e99a12db62fee1b715482242d712b421936e0a94e221b99ef0f0a3/ty-0.0.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e27661ea96e60320e12a660958c39a6630d111ecac0028cf164003c966ef758c", size = 10226973, upload-time = "2025-12-23T22:15:59.088Z" }, + { url = "https://files.pythonhosted.org/packages/65/c1/27ae51eab2cfe9177967c89b416b6beaa5100036b719d9b1621eb1b0a8c0/ty-0.0.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:36f85148acec63ac87a4e49139f50f0add95fdfcef171cc8973a530c72ca8e27", size = 9675931, upload-time = "2025-12-23T22:16:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/b1a7b223a9519b4e8b4ae31d1b092c79700e3977981abb1da07321bb7d88/ty-0.0.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:aa0988a3720882c255fa173753746d90bd9d82adebb1a00a561e0a04bbca1cd4", size = 9685617, upload-time = "2025-12-23T22:15:57.046Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a7/d86ca25d4f0e9c666dcd7053190c45f2c34219525fc4c586df667e65e38b/ty-0.0.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:48783f31a758bcb959e3ed58464872aee4b3f40da3b6b84fbc3bb450567232bd", size = 9834627, upload-time = "2025-12-23T22:16:17.708Z" }, + { url = "https://files.pythonhosted.org/packages/6d/21/76e9e7f507b412533ec000554731abd94a435bfc8a9bfadbde1d88d34914/ty-0.0.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b24660cdd84740dce548af4d0a821f2c76f8a59a7b2dc284c52e0d3ef0829f39", size = 10330699, upload-time = "2025-12-23T22:16:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ee/17e1d8dd2d75a175178cc3df776b9fb7ce7823975582cbb6f8f27e015f80/ty-0.0.6-py3-none-win32.whl", hash = "sha256:a53683a8e3eec225b00be402372139868bb29c15ddb3a0ba8ad7d38ec5196785", size = 9274125, upload-time = "2025-12-23T22:16:04.658Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ec/aa8dd57044319b857b5526178848ae6b94fd990135ef13d4d308f9a82736/ty-0.0.6-py3-none-win_amd64.whl", hash = "sha256:e6f840220462a2122c171440f5fc989d0125175290d636c5436eaa736bbc83b1", size = 10131668, upload-time = "2025-12-23T22:16:19.392Z" }, + { url = "https://files.pythonhosted.org/packages/86/fe/bb0783213193a3294119d3d12b0454e59d7c23b3d1592f04a9711d458289/ty-0.0.6-py3-none-win_arm64.whl", hash = "sha256:8e595ece22a130d32532b0c18808db98a258227dd53230968f45c6cbf040ea50", size = 9643743, upload-time = "2025-12-23T22:16:02.56Z" }, ] [[package]] @@ -1697,11 +1876,11 @@ wheels = [ [[package]] name = "types-python-dateutil" -version = "2.9.0.20250822" +version = "2.9.0.20251115" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0c/0a/775f8551665992204c756be326f3575abba58c4a3a52eef9909ef4536428/types_python_dateutil-2.9.0.20250822.tar.gz", hash = "sha256:84c92c34bd8e68b117bff742bc00b692a1e8531262d4507b33afcc9f7716cd53", size = 16084, upload-time = "2025-08-22T03:02:00.613Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/d9/a29dfa84363e88b053bf85a8b7f212a04f0d7343a4d24933baa45c06e08b/types_python_dateutil-2.9.0.20250822-py3-none-any.whl", hash = "sha256:849d52b737e10a6dc6621d2bd7940ec7c65fcb69e6aa2882acf4e56b2b508ddc", size = 17892, upload-time = "2025-08-22T03:01:59.436Z" }, + { 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" }, ] [[package]] @@ -1740,11 +1919,11 @@ wheels = [ [[package]] name = "types-setuptools" -version = "80.9.0.20250822" +version = "80.9.0.20251223" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/bd/1e5f949b7cb740c9f0feaac430e301b8f1c5f11a81e26324299ea671a237/types_setuptools-80.9.0.20250822.tar.gz", hash = "sha256:070ea7716968ec67a84c7f7768d9952ff24d28b65b6594797a464f1b3066f965", size = 41296, upload-time = "2025-08-22T03:02:08.771Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/2d/475bf15c1cdc172e7a0d665b6e373ebfb1e9bf734d3f2f543d668b07a142/types_setuptools-80.9.0.20250822-py3-none-any.whl", hash = "sha256:53bf881cb9d7e46ed12c76ef76c0aaf28cfe6211d3fab12e0b83620b1a8642c3", size = 63179, upload-time = "2025-08-22T03:02:07.643Z" }, + { 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" }, ] [[package]] @@ -1758,23 +1937,23 @@ wheels = [ [[package]] name = "typing-inspection" -version = "0.4.1" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "tzdata" -version = "2025.2" +version = "2025.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] [[package]] @@ -1791,25 +1970,38 @@ wheels = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, ] [[package]] name = "virtualenv" -version = "20.34.0" +version = "20.35.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, ] [[package]] @@ -1841,11 +2033,11 @@ wheels = [ [[package]] name = "widgetsnbextension" -version = "4.0.14" +version = "4.0.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/41/53/2e0253c5efd69c9656b1843892052a31c36d37ad42812b5da45c62191f7e/widgetsnbextension-4.0.14.tar.gz", hash = "sha256:a3629b04e3edb893212df862038c7232f62973373869db5084aed739b437b5af", size = 1097428, upload-time = "2025-04-10T13:01:25.628Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/51/5447876806d1088a0f8f71e16542bf350918128d0a69437df26047c8e46f/widgetsnbextension-4.0.14-py3-none-any.whl", hash = "sha256:4875a9eaf72fbf5079dc372a51a9f268fc38d46f767cbf85c43a36da5cb9b575", size = 2196503, upload-time = "2025-04-10T13:01:23.086Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, ] [[package]] @@ -1859,9 +2051,9 @@ wheels = [ [[package]] name = "xyzservices" -version = "2025.4.0" +version = "2025.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/00/af/c0f7f97bb320d14c089476f487b81f733238cc5603e0914f2e409f49d589/xyzservices-2025.4.0.tar.gz", hash = "sha256:6fe764713648fac53450fbc61a3c366cb6ae5335a1b2ae0c3796b495de3709d8", size = 1134722, upload-time = "2025-04-25T10:38:09.669Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/0f/022795fc1201e7c29e742a509913badb53ce0b38f64b6db859e2f6339da9/xyzservices-2025.11.0.tar.gz", hash = "sha256:2fc72b49502b25023fd71e8f532fb4beddbbf0aa124d90ea25dba44f545e17ce", size = 1135703, upload-time = "2025-11-22T11:31:51.82Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/7d/b77455d7c7c51255b2992b429107fab811b2e36ceaf76da1e55a045dc568/xyzservices-2025.4.0-py3-none-any.whl", hash = "sha256:8d4db9a59213ccb4ce1cf70210584f30b10795bff47627cdfb862b39ff6e10c9", size = 90391, upload-time = "2025-04-25T10:38:08.468Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5c/2c189d18d495dd0fa3f27ccc60762bbc787eed95b9b0147266e72bb76585/xyzservices-2025.11.0-py3-none-any.whl", hash = "sha256:de66a7599a8d6dad63980b77defd1d8f5a5a9cb5fc8774ea1c6e89ca7c2a3d2f", size = 93916, upload-time = "2025-11-22T11:31:50.525Z" }, ] diff --git a/web-editor/README.md b/web-editor/README.md index 8eef3f1..1a12c1f 100644 --- a/web-editor/README.md +++ b/web-editor/README.md @@ -7,9 +7,10 @@ A browser-based editor to design verification scenarios as a Directed Acyclic Gr - **Visual DAG Editor**: Design scenarios using a node-based interface powered by @xyflow/react. - **Drag and Drop**: Easily add steps from the toolbox to the canvas. - **Parameter Configuration**: Edit node parameters using a dedicated properties panel. -- **Scenario Execution**: Run scenarios directly in the editor and view results. +- **Scenario Execution**: Run scenarios directly in the editor with real-time visual feedback (running, success, failure) and view results. - **Import/Export**: Save and load scenarios as JSON files. -- **Dark Mode**: "Premium" developer-focused UI with theme toggling. +- **Dark Mode**: "Premium" developer-focused UI with theme toggling and optimized contrast. +- **Enhanced UX**: Clear node selection states and hover effects designed for usability. - **Responsive Layout**: Resizable panels and responsive design. ## Project Structure @@ -21,21 +22,43 @@ The project follows a modular architecture separating concerns into components, - **`src/types/`**: TypeScript definitions for strict type safety. - **`src/styles/`**: CSS Modules for scoped styling. -## Getting Started +## Backend Setup -1. Install dependencies: +The editor requires the backend server to be running to fetch available operations and execute scenarios. + +### Start the Backend Server + +The editor communicates with a local Python server to retrieve the list of available scenario steps (operations) and to execute them. + +Run the server from the project root: + +```bash +uv run src/openutm_verification/server/main.py +``` + +The server will start on `http://0.0.0.0:8989`. + +## Getting Started (Frontend) + +1. Navigate to the `web-editor` directory: + + ```bash + cd web-editor + ``` + +2. Install dependencies: ```bash npm install ``` -2. Start the development server: +3. Start the development server: ```bash npm run dev ``` -3. Open your browser at `http://localhost:5173`. +4. Open your browser at `http://localhost:5173`. ## Tech Stack diff --git a/web-editor/async_implementation_guide.md b/web-editor/async_implementation_guide.md new file mode 100644 index 0000000..a1bc665 --- /dev/null +++ b/web-editor/async_implementation_guide.md @@ -0,0 +1,86 @@ +# Implementing Async/Background Tasks in the UI + +This guide outlines the implementation of asynchronous background tasks (like those in `test_sdsp_track.py`) using the OpenUTM Verification Web Editor and Backend. + +## Status: Implemented + +The core infrastructure for async/background tasks has been implemented in both the Frontend and Backend. + +## 1. Client-Side Orchestration (Frontend) + +The frontend now executes scenarios step-by-step, allowing for dynamic updates and handling of long-running processes. + +### Key Components +- **`useScenarioRunner.ts`**: + - Performs topological sort of the graph to determine execution order. + - Iterates through nodes and calls the backend API for each step individually. + - Supports `runInBackground` flag: If a node is configured to run in background, it passes `run_in_background=true` to the backend. + - Updates node status to `running` (blue border + spinner) before execution, and `success`/`failure` after completion. + - Handles `step_id` passing to ensure results are stored in the backend session context. + +- **UI Updates**: + - **Running State**: Nodes now show a spinner and blue border while executing. + - **Connection Status**: A "Connected/Disconnected" indicator in the top-right panel shows backend health. + - **Toolbox Refresh**: Automatically reloads available operations when the backend connects. + +## 2. Backend Support for Background Tasks + +The backend `DynamicRunner` has been updated to support "fire-and-forget" tasks and task joining. + +### Implementation Details (`src/openutm_verification/server/runner.py`) + +1. **Background Execution**: + - When `run_in_background=True` is passed to `execute_single_step`: + - The method call is wrapped in `asyncio.create_task`. + - The task object is stored in `self.session_context["background_tasks"]` keyed by a UUID. + - Returns immediately with `{"task_id": , "status": "running"}`. + +2. **`SystemClient.join_task`**: + - A special handling block in `_execute_step` intercepts calls to `SystemClient.join_task`. + - It retrieves the `task_id` from parameters (which can be a direct string or a reference to a previous step's result). + - It looks up the task in `session_context["background_tasks"]`. + - It `await`s the task completion and returns the result. + +## 3. Usage Example (SDSP Track) + +To create a scenario with async tasks (e.g., `sdsp_track`): + +1. **Start Task**: Add a node (e.g., `FlightBlenderClient.submit_simulated_air_traffic`). + - Set `Run in Background` to `true` (via properties panel or JSON). +2. **Intermediate Steps**: Add other nodes (e.g., `Wait X Seconds`, `Verify Track`) that run while the background task is active. +3. **Join Task**: Add a `SystemClient.join_task` node. + - Link its `task_id` parameter to the result of the Start Task node. + +### Example JSON (`web-editor/examples/sdsp_track_async.json`) + +```json +{ + "nodes": [ + { + "id": "node_3", + "data": { + "label": "Submit Air Traffic (Async)", + "runInBackground": true, + ... + } + }, + ... + { + "id": "node_8", + "data": { + "label": "Join Background Task", + "operationId": "SystemClient.join_task", + "parameters": [ + { "name": "task_id", "default": "" } // Link this in UI + ] + } + } + ] +} +``` + +## 4. Pending / Future Work + +- **UI Property for Background**: Explicit checkbox in `PropertiesPanel` to toggle `runInBackground` (currently supported in JSON/Logic but needs UI control). +- **SystemClient in Toolbox**: Ensure `SystemClient` operations are exposed in the `/operations` endpoint so `join_task` appears in the toolbox. +- **Task ID Linking**: Improve UX for linking the `task_id` output of one node to the input of `join_task` (currently relies on implicit result passing or manual ID entry). diff --git a/web-editor/examples/scenario_2025-12-23T21-38-50-073Z.json b/web-editor/examples/scenario_2025-12-23T21-38-50-073Z.json new file mode 100644 index 0000000..8ec9f24 --- /dev/null +++ b/web-editor/examples/scenario_2025-12-23T21-38-50-073Z.json @@ -0,0 +1,400 @@ +{ + "nodes": [ + { + "id": "dndnode_16", + "type": "custom", + "position": { + "x": 0, + "y": 0 + }, + "data": { + "label": "Setup Flight Declaration", + "operationId": "FlightBlenderClient.setup_flight_declaration", + "className": "FlightBlenderClient", + "functionName": "setup_flight_declaration", + "description": "Generates data and uploads flight declaration.", + "parameters": [ + { + "name": "flight_declaration_path", + "type": "str", + "default": "config/bern/flight_declaration.json" + }, + { + "name": "trajectory_path", + "type": "str", + "default": "config/bern/trajectory_f1.json" + } + ], + "status": "success", + "result": { + "name": "Setup Flight Declaration", + "status": "PASS", + "duration": 0.3278837203979492, + "details": null, + "error_message": null + } + }, + "measured": { + "width": 248, + "height": 56 + }, + "selected": false, + "dragging": false + }, + { + "id": "dndnode_17", + "type": "custom", + "position": { + "x": 0, + "y": 360 + }, + "data": { + "label": "Update Operation State", + "operationId": "FlightBlenderClient.update_operation_state", + "className": "FlightBlenderClient", + "functionName": "update_operation_state", + "description": "Update the state of a flight operation.\n\nPosts the new state and optionally waits for the specified duration.\n\nArgs:\n new_state: The new OperationState to set.\n duration_seconds: Optional seconds to sleep after update (default 0).\n\nReturns:\n The JSON response from the API.\n\nRaises:\n FlightBlenderError: If the update request fails.", + "parameters": [ + { + "name": "new_state", + "type": "OperationState", + "options": [ + { + "name": "PROCESSING", + "value": 0 + }, + { + "name": "ACCEPTED", + "value": 1 + }, + { + "name": "ACTIVATED", + "value": 2 + }, + { + "name": "NONCONFORMING", + "value": 3 + }, + { + "name": "CONTINGENT", + "value": 4 + }, + { + "name": "ENDED", + "value": 5 + }, + { + "name": "WITHDRAWN", + "value": 6 + }, + { + "name": "CANCELLED", + "value": 7 + }, + { + "name": "REJECTED", + "value": 8 + } + ], + "isEnum": true, + "default": 2 + }, + { + "name": "duration_seconds", + "type": "int", + "default": 0 + } + ], + "status": "success", + "result": { + "name": "Update Operation State", + "status": "PASS", + "duration": 0.11129021644592285, + "details": { + "state": 2, + "submitted_by": null + }, + "error_message": null + } + }, + "measured": { + "width": 244, + "height": 56 + }, + "selected": false, + "dragging": false + }, + { + "id": "dndnode_18", + "type": "custom", + "position": { + "x": 0, + "y": 180 + }, + "data": { + "label": "Wait X seconds", + "operationId": "FlightBlenderClient.wait_x_seconds", + "className": "FlightBlenderClient", + "functionName": "wait_x_seconds", + "description": "Wait for a specified number of seconds.", + "parameters": [ + { + "name": "wait_time_seconds", + "type": "int", + "default": 5 + } + ], + "status": "success", + "result": { + "name": "Wait X seconds", + "status": "PASS", + "duration": 5.001457929611206, + "details": "Waited for Flight Blender to process 5 seconds.", + "error_message": null + } + }, + "measured": { + "width": 191, + "height": 56 + }, + "selected": false, + "dragging": false + }, + { + "id": "dndnode_19", + "type": "custom", + "position": { + "x": 0, + "y": 540 + }, + "data": { + "label": "Submit Telemetry", + "operationId": "FlightBlenderClient.submit_telemetry", + "className": "FlightBlenderClient", + "functionName": "submit_telemetry", + "description": "Submit telemetry data for a flight operation from in-memory states.\n\nSubmits telemetry states sequentially from the provided list, with optional\nduration limiting and error handling for rate limits.\n\nArgs:\n states: List of telemetry state dictionaries. If None, uses the generated telemetry states from context.\n duration_seconds: Optional maximum duration in seconds to submit telemetry (default 0 for unlimited).\n\nReturns:\n The JSON response from the last telemetry submission, or None if no submissions occurred.\n\nRaises:\n FlightBlenderError: If maximum waiting time is exceeded due to rate limits.", + "parameters": [ + { + "name": "states", + "type": "list[RIDAircraftState] | None", + "default": null + }, + { + "name": "duration_seconds", + "type": "int", + "default": 5 + } + ], + "status": "success", + "result": { + "name": "Submit Telemetry", + "status": "PASS", + "duration": 5.5007102489471436, + "details": { + "message": "Telemetry data successfully submitted" + }, + "error_message": null + } + }, + "measured": { + "width": 205, + "height": 56 + }, + "selected": false, + "dragging": false + }, + { + "id": "dndnode_20", + "type": "custom", + "position": { + "x": 0, + "y": 720 + }, + "data": { + "label": "Update Operation State", + "operationId": "FlightBlenderClient.update_operation_state", + "className": "FlightBlenderClient", + "functionName": "update_operation_state", + "description": "Update the state of a flight operation.\n\nPosts the new state and optionally waits for the specified duration.\n\nArgs:\n new_state: The new OperationState to set.\n duration_seconds: Optional seconds to sleep after update (default 0).\n\nReturns:\n The JSON response from the API.\n\nRaises:\n FlightBlenderError: If the update request fails.", + "parameters": [ + { + "name": "new_state", + "type": "OperationState", + "options": [ + { + "name": "PROCESSING", + "value": 0 + }, + { + "name": "ACCEPTED", + "value": 1 + }, + { + "name": "ACTIVATED", + "value": 2 + }, + { + "name": "NONCONFORMING", + "value": 3 + }, + { + "name": "CONTINGENT", + "value": 4 + }, + { + "name": "ENDED", + "value": 5 + }, + { + "name": "WITHDRAWN", + "value": 6 + }, + { + "name": "CANCELLED", + "value": 7 + }, + { + "name": "REJECTED", + "value": 8 + } + ], + "isEnum": true, + "default": 5 + }, + { + "name": "duration_seconds", + "type": "int", + "default": 0 + } + ], + "status": "success", + "result": { + "name": "Update Operation State", + "status": "PASS", + "duration": 0.09701776504516602, + "details": { + "state": 5, + "submitted_by": null + }, + "error_message": null + } + }, + "measured": { + "width": 244, + "height": 56 + }, + "selected": false, + "dragging": false + }, + { + "id": "dndnode_21", + "type": "custom", + "position": { + "x": 0, + "y": 900 + }, + "data": { + "label": "Delete Flight Declaration", + "operationId": "FlightBlenderClient.delete_flight_declaration", + "className": "FlightBlenderClient", + "functionName": "delete_flight_declaration", + "description": "Delete a flight declaration by ID.\n\nReturns:\n A dictionary with deletion status, including whether it was successful.", + "parameters": [], + "status": "success", + "result": { + "name": "Delete Flight Declaration", + "status": "PASS", + "duration": 0.13648104667663574, + "details": { + "deleted": true, + "id": "00723428-af8a-4bae-9cd5-6e6395b46377" + }, + "error_message": null + } + }, + "measured": { + "width": 251, + "height": 56 + }, + "selected": false, + "dragging": false + } + ], + "edges": [ + { + "source": "dndnode_16", + "target": "dndnode_18", + "animated": true, + "style": { + "stroke": "var(--accent-primary)", + "strokeWidth": 1 + }, + "markerEnd": { + "type": "arrowclosed", + "color": "var(--accent-primary)" + }, + "id": "xy-edge__dndnode_16-dndnode_18" + }, + { + "source": "dndnode_18", + "target": "dndnode_17", + "animated": true, + "style": { + "stroke": "var(--accent-primary)", + "strokeWidth": 1 + }, + "markerEnd": { + "type": "arrowclosed", + "color": "var(--accent-primary)" + }, + "id": "xy-edge__dndnode_18-dndnode_17" + }, + { + "source": "dndnode_17", + "target": "dndnode_19", + "animated": true, + "style": { + "stroke": "var(--accent-primary)", + "strokeWidth": 1 + }, + "markerEnd": { + "type": "arrowclosed", + "color": "var(--accent-primary)" + }, + "id": "xy-edge__dndnode_17-dndnode_19" + }, + { + "source": "dndnode_19", + "target": "dndnode_20", + "animated": true, + "style": { + "stroke": "var(--accent-primary)", + "strokeWidth": 1 + }, + "markerEnd": { + "type": "arrowclosed", + "color": "var(--accent-primary)" + }, + "id": "xy-edge__dndnode_19-dndnode_20" + }, + { + "source": "dndnode_20", + "target": "dndnode_21", + "animated": true, + "style": { + "stroke": "var(--accent-primary)", + "strokeWidth": 1 + }, + "markerEnd": { + "type": "arrowclosed", + "color": "var(--accent-primary)" + }, + "id": "xy-edge__dndnode_20-dndnode_21" + } + ], + "viewport": { + "x": 774.2531380753138, + "y": 83, + "zoom": 0.8744769874476988 + } +} diff --git a/web-editor/examples/sdsp_track_async.json b/web-editor/examples/sdsp_track_async.json new file mode 100644 index 0000000..aa0a975 --- /dev/null +++ b/web-editor/examples/sdsp_track_async.json @@ -0,0 +1,199 @@ +{ + "nodes": [ + { + "id": "node_1", + "type": "custom", + "position": { "x": 0, "y": 0 }, + "data": { + "label": "Start SDSP Session", + "operationId": "FlightBlenderClient.start_stop_sdsp_session", + "className": "FlightBlenderClient", + "functionName": "start_stop_sdsp_session", + "description": "Starts or stops an SDSP session.", + "parameters": [ + { + "name": "action", + "type": "SDSPSessionAction", + "options": [ + { "name": "START", "value": "start" }, + { "name": "STOP", "value": "stop" } + ], + "isEnum": true, + "default": "start" + }, + { + "name": "session_id", + "type": "str", + "default": "sdsp-session-123" + } + ] + }, + "measured": { "width": 250, "height": 60 } + }, + { + "id": "node_2", + "type": "custom", + "position": { "x": 0, "y": 150 }, + "data": { + "label": "Generate Air Traffic Data", + "operationId": "AirTrafficClient.generate_simulated_air_traffic_data", + "className": "AirTrafficClient", + "functionName": "generate_simulated_air_traffic_data", + "description": "Generates simulated air traffic data.", + "parameters": [] + }, + "measured": { "width": 250, "height": 60 } + }, + { + "id": "node_3", + "type": "custom", + "position": { "x": 0, "y": 300 }, + "data": { + "label": "Submit Air Traffic (Async)", + "operationId": "FlightBlenderClient.submit_simulated_air_traffic", + "className": "FlightBlenderClient", + "functionName": "submit_simulated_air_traffic", + "description": "Submits simulated air traffic data in background.", + "runInBackground": true, + "parameters": [ + { + "name": "observations", + "type": "list[Observation] | None", + "default": null + } + ] + }, + "measured": { "width": 250, "height": 60 } + }, + { + "id": "node_4", + "type": "custom", + "position": { "x": 0, "y": 450 }, + "data": { + "label": "Wait 2 Seconds", + "operationId": "FlightBlenderClient.wait_x_seconds", + "className": "FlightBlenderClient", + "functionName": "wait_x_seconds", + "description": "Wait for 2 seconds.", + "parameters": [ + { + "name": "wait_time_seconds", + "type": "int", + "default": 2 + } + ] + }, + "measured": { "width": 250, "height": 60 } + }, + { + "id": "node_5", + "type": "custom", + "position": { "x": 0, "y": 600 }, + "data": { + "label": "Initialize Verify SDSP Track", + "operationId": "FlightBlenderClient.initialize_verify_sdsp_track", + "className": "FlightBlenderClient", + "functionName": "initialize_verify_sdsp_track", + "description": "Verifies SDSP track.", + "parameters": [ + { + "name": "session_id", + "type": "str", + "default": "sdsp-session-123" + }, + { + "name": "expected_track_interval_seconds", + "type": "int", + "default": 1 + }, + { + "name": "expected_track_count", + "type": "int", + "default": 3 + } + ] + }, + "measured": { "width": 250, "height": 60 } + }, + { + "id": "node_6", + "type": "custom", + "position": { "x": 0, "y": 750 }, + "data": { + "label": "Wait 5 Seconds", + "operationId": "FlightBlenderClient.wait_x_seconds", + "className": "FlightBlenderClient", + "functionName": "wait_x_seconds", + "description": "Wait for 5 seconds.", + "parameters": [ + { + "name": "wait_time_seconds", + "type": "int", + "default": 5 + } + ] + }, + "measured": { "width": 250, "height": 60 } + }, + { + "id": "node_7", + "type": "custom", + "position": { "x": 0, "y": 900 }, + "data": { + "label": "Stop SDSP Session", + "operationId": "FlightBlenderClient.start_stop_sdsp_session", + "className": "FlightBlenderClient", + "functionName": "start_stop_sdsp_session", + "description": "Stops the SDSP session.", + "parameters": [ + { + "name": "action", + "type": "SDSPSessionAction", + "options": [ + { "name": "START", "value": "start" }, + { "name": "STOP", "value": "stop" } + ], + "isEnum": true, + "default": "stop" + }, + { + "name": "session_id", + "type": "str", + "default": "sdsp-session-123" + } + ] + }, + "measured": { "width": 250, "height": 60 } + }, + { + "id": "node_8", + "type": "custom", + "position": { "x": 0, "y": 1050 }, + "data": { + "label": "Join Background Task", + "operationId": "SystemClient.join_task", + "className": "SystemClient", + "functionName": "join_task", + "description": "Waits for the background task to complete.", + "parameters": [ + { + "name": "task_id", + "type": "str", + "default": "" + } + ] + }, + "measured": { "width": 250, "height": 60 } + } + ], + "edges": [ + { "id": "e1-2", "source": "node_1", "target": "node_2", "animated": true, "style": { "stroke": "var(--accent-primary)" } }, + { "id": "e2-3", "source": "node_2", "target": "node_3", "animated": true, "style": { "stroke": "var(--accent-primary)" } }, + { "id": "e3-4", "source": "node_3", "target": "node_4", "animated": true, "style": { "stroke": "var(--accent-primary)" } }, + { "id": "e4-5", "source": "node_4", "target": "node_5", "animated": true, "style": { "stroke": "var(--accent-primary)" } }, + { "id": "e5-6", "source": "node_5", "target": "node_6", "animated": true, "style": { "stroke": "var(--accent-primary)" } }, + { "id": "e6-7", "source": "node_6", "target": "node_7", "animated": true, "style": { "stroke": "var(--accent-primary)" } }, + { "id": "e7-8", "source": "node_7", "target": "node_8", "animated": true, "style": { "stroke": "var(--accent-primary)" } } + ], + "viewport": { "x": 0, "y": 0, "zoom": 1 } +} diff --git a/web-editor/src/components/ScenarioEditor.tsx b/web-editor/src/components/ScenarioEditor.tsx index dc4bbf5..8c13bb4 100644 --- a/web-editor/src/components/ScenarioEditor.tsx +++ b/web-editor/src/components/ScenarioEditor.tsx @@ -12,7 +12,6 @@ import { } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import layoutStyles from '../styles/EditorLayout.module.css'; -import operationsData from '../data/operations.json'; import type { Operation, OperationParam, NodeData } from '../types/scenario'; import { CustomNode } from './ScenarioEditor/CustomNode'; @@ -49,6 +48,36 @@ const ScenarioEditorContent = () => { const [selectedNode, setSelectedNode] = useState | null>(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [resultToDisplay, setResultToDisplay] = useState(null); + const [operations, setOperations] = useState([]); + const [isConnected, setIsConnected] = useState(false); + + useEffect(() => { + const checkHealth = async () => { + try { + const res = await fetch('http://localhost:8989/health'); + if (res.ok) { + setIsConnected(true); + } else { + setIsConnected(false); + } + } catch { + setIsConnected(false); + } + }; + + checkHealth(); + const interval = setInterval(checkHealth, 5000); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + if (isConnected) { + fetch('http://localhost:8989/operations') + .then(res => res.json()) + .then(data => setOperations(data)) + .catch(err => console.error('Failed to fetch operations:', err)); + } + }, [isConnected]); const { nodes, @@ -131,8 +160,8 @@ const ScenarioEditorContent = () => { }, []); const handleDrop = useCallback((event: React.DragEvent) => { - onDrop(event, operationsData as Operation[]); - }, [onDrop]); + onDrop(event, operations); + }, [onDrop, operations]); const handleClear = useCallback(() => { if (globalThis.confirm('Are you sure you want to clear the current scenario? All unsaved changes will be lost.')) { @@ -182,10 +211,27 @@ const ScenarioEditorContent = () => { const currentNodes = reactFlowInstance ? reactFlowInstance.getNodes() : nodesRef.current; const currentEdges = reactFlowInstance ? reactFlowInstance.getEdges() : edgesRef.current; - const result = await runScenario(currentNodes, currentEdges); - if (result?.results) { - setNodes((nds) => updateNodesWithResults(nds, result.results)); - } + // Pass a callback to update nodes incrementally + const onStepComplete = (stepResult: { id: string; status: 'success' | 'failure' | 'error'; result?: unknown }) => { + setNodes((nds) => updateNodesWithResults(nds, [stepResult])); + }; + + const onStepStart = (nodeId: string) => { + setNodes((nds) => nds.map(node => { + if (node.id === nodeId) { + return { + ...node, + data: { + ...node.data, + status: 'running' + } + }; + } + return node; + })); + }; + + await runScenario(currentNodes, currentEdges, onStepComplete, onStepStart); }, [runScenario, setNodes, updateNodesWithResults, reactFlowInstance, setResultToDisplay]); const updateNodeParameter = useCallback((nodeId: string, paramName: string, value: unknown) => { @@ -220,6 +266,28 @@ const ScenarioEditorContent = () => { }); }, [setNodes]); + const updateNodeRunInBackground = useCallback((nodeId: string, value: boolean) => { + setNodes((nds) => + nds.map((node) => { + if (node.id === nodeId) { + return { + ...node, + data: { ...node.data, runInBackground: value }, + }; + } + return node; + }) + ); + + setSelectedNode((prev) => { + if (!prev || prev.id !== nodeId) return prev; + return { + ...prev, + data: { ...prev.data, runInBackground: value }, + }; + }); + }, [setNodes]); + const getConnectedSourceNodes = useCallback((targetNodeId: string) => { const sourceNodeIds = new Set(edges .filter(edge => edge.target === targetNodeId) @@ -266,7 +334,7 @@ const ScenarioEditorContent = () => { />
- +
, Edge> @@ -292,8 +360,8 @@ const ScenarioEditorContent = () => {
-
- Connected +
+ {isConnected ? 'Connected' : 'Disconnected'}
@@ -313,6 +381,7 @@ const ScenarioEditorContent = () => { connectedNodes={connectedNodes} onClose={() => setSelectedNode(null)} onUpdateParameter={updateNodeParameter} + onUpdateRunInBackground={updateNodeRunInBackground} /> )}
diff --git a/web-editor/src/components/ScenarioEditor/CustomNode.tsx b/web-editor/src/components/ScenarioEditor/CustomNode.tsx index e516d42..6b0826c 100644 --- a/web-editor/src/components/ScenarioEditor/CustomNode.tsx +++ b/web-editor/src/components/ScenarioEditor/CustomNode.tsx @@ -1,12 +1,13 @@ import { Handle, Position, type NodeProps, type Node } from '@xyflow/react'; -import { Box, CheckCircle, XCircle, AlertTriangle } from 'lucide-react'; +import { Box, CheckCircle, XCircle, AlertTriangle, Loader2 } from 'lucide-react'; import styles from '../../styles/Node.module.css'; import type { NodeData } from '../../types/scenario'; export const CustomNode = ({ data, selected }: NodeProps>) => { const statusClass = data.status === 'success' ? styles.statusSuccess : - (data.status === 'failure' || data.status === 'error') ? styles.statusError : ''; + (data.status === 'failure' || data.status === 'error') ? styles.statusError : + data.status === 'running' ? styles.statusRunning : ''; const selectedClass = selected ? styles.selected : ''; return ( @@ -16,19 +17,50 @@ export const CustomNode = ({ data, selected }: NodeProps>) => { {data.label} {data.status && ( - +
+ {data.status === 'success' && ( + + )} + {data.status === 'failure' && ( + + )} + {data.status === 'error' && ( + + )} + {data.status === 'running' && ( + + )} +
)}
diff --git a/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx b/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx index 4ca9fff..e5dbd9d 100644 --- a/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx +++ b/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx @@ -38,9 +38,10 @@ interface PropertiesPanelProps { connectedNodes: Node[]; onClose: () => void; onUpdateParameter: (nodeId: string, paramName: string, value: unknown) => void; + onUpdateRunInBackground: (nodeId: string, value: boolean) => void; } -export const PropertiesPanel = ({ selectedNode, connectedNodes, onClose, onUpdateParameter }: PropertiesPanelProps) => { +export const PropertiesPanel = ({ selectedNode, connectedNodes, onClose, onUpdateParameter, onUpdateRunInBackground }: PropertiesPanelProps) => { const formatParamValue = (value: unknown): string => { if (value === null || value === undefined) { return ''; @@ -80,6 +81,21 @@ export const PropertiesPanel = ({ selectedNode, connectedNodes, onClose, onUpdat

{selectedNode.data.label}

+ +
+ +
+ If checked, this step will run asynchronously. Use SystemClient.join_task to wait for it later. +
+
+

Parameters

{(selectedNode.data.parameters || []).length > 0 ? ( (selectedNode.data.parameters || []).map(param => { @@ -87,8 +103,7 @@ export const PropertiesPanel = ({ selectedNode, connectedNodes, onClose, onUpdat return (
-
- +
+
{isLinked ? ( diff --git a/web-editor/src/components/ScenarioEditor/Toolbox.tsx b/web-editor/src/components/ScenarioEditor/Toolbox.tsx index 417a8d0..af078f5 100644 --- a/web-editor/src/components/ScenarioEditor/Toolbox.tsx +++ b/web-editor/src/components/ScenarioEditor/Toolbox.tsx @@ -2,7 +2,6 @@ import { useState, useMemo } from 'react'; import { ChevronDown, ChevronRight, Box } from 'lucide-react'; import styles from '../../styles/Toolbox.module.css'; import layoutStyles from '../../styles/EditorLayout.module.css'; -import operationsData from '../../data/operations.json'; import type { Operation } from '../../types/scenario'; const ToolboxGroup = ({ title, ops }: { title: string, ops: Operation[] }) => { @@ -43,9 +42,7 @@ const ToolboxGroup = ({ title, ops }: { title: string, ops: Operation[] }) => { ); }; -export const Toolbox = () => { - const operations = operationsData as Operation[]; - +export const Toolbox = ({ operations }: { operations: Operation[] }) => { const groupedOperations = useMemo(() => { const grouped = operations.reduce((acc, op) => { if (!acc[op.className]) { diff --git a/web-editor/src/data/operations.json b/web-editor/src/data/operations.json index 133820d..25766db 100644 --- a/web-editor/src/data/operations.json +++ b/web-editor/src/data/operations.json @@ -8,7 +8,7 @@ "parameters": [ { "name": "filename", - "type": "Optional[str]", + "type": "str | None", "default": null } ], @@ -32,7 +32,7 @@ "parameters": [ { "name": "geo_fence_id", - "type": "Optional[str]", + "type": "str | None", "default": null } ], @@ -47,7 +47,22 @@ "parameters": [ { "name": "declaration", - "type": "str | Any" + "type": "str | BaseModel" + } + ], + "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" + }, + { + "id": "FlightBlenderClient.wait_for_user_input", + "name": "Wait for User Input", + "functionName": "wait_for_user_input", + "className": "FlightBlenderClient", + "description": "Wait for user input to proceed.\n\nThis method prompts the user for input and waits until the user responds.\n\nArgs:\n prompt: The message to display to the user.", + "parameters": [ + { + "name": "prompt", + "type": "str", + "default": "Press Enter to continue..." } ], "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" @@ -153,7 +168,7 @@ "parameters": [ { "name": "states", - "type": "Optional[List[Dict[Any]]]", + "type": "list[RIDAircraftState] | None", "default": null }, { @@ -298,7 +313,7 @@ "parameters": [ { "name": "observations", - "type": "List[List[Dict[Any]]]" + "type": "list[list[FlightObservationSchema]]" }, { "name": "single_or_multiple_sensors", @@ -317,7 +332,7 @@ "parameters": [ { "name": "observations", - "type": "List[Dict[Any]]" + "type": "list[FlightObservationSchema]" } ], "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" @@ -395,6 +410,15 @@ ], "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" }, + { + "id": "FlightBlenderClient.teardown_flight_declaration", + "name": "Teardown Flight Declaration", + "functionName": "teardown_flight_declaration", + "className": "FlightBlenderClient", + "description": null, + "parameters": [], + "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" + }, { "id": "FlightBlenderClient.setup_flight_declaration", "name": "Setup Flight Declaration", @@ -422,12 +446,12 @@ "parameters": [ { "name": "config_path", - "type": "Optional[str]", + "type": "str | None", "default": null }, { "name": "duration", - "type": "Optional[int]", + "type": "int | None", "default": null } ], @@ -442,4 +466,4 @@ "parameters": [], "filePath": "src/openutm_verification/core/clients/opensky/opensky_client.py" } -] \ No newline at end of file +] diff --git a/web-editor/src/hooks/useScenarioRunner.ts b/web-editor/src/hooks/useScenarioRunner.ts index ffcd746..601f98d 100644 --- a/web-editor/src/hooks/useScenarioRunner.ts +++ b/web-editor/src/hooks/useScenarioRunner.ts @@ -1,11 +1,16 @@ import { useState, useCallback } from 'react'; import type { Node, Edge } from '@xyflow/react'; -import type { NodeData, ScenarioStep, ScenarioExecutionResult } from '../types/scenario'; +import type { NodeData } from '../types/scenario'; export const useScenarioRunner = () => { const [isRunning, setIsRunning] = useState(false); - const runScenario = useCallback(async (nodes: Node[], edges: Edge[]) => { + const runScenario = useCallback(async ( + nodes: Node[], + edges: Edge[], + onStepComplete?: (result: { id: string; status: 'success' | 'failure' | 'error'; result?: unknown }) => void, + onStepStart?: (nodeId: string) => void + ) => { if (nodes.length === 0) return null; // Simple topological sort / path following @@ -38,9 +43,20 @@ export const useScenarioRunner = () => { } } - const steps: ScenarioStep[] = sortedNodes - .filter(node => node.data.operationId) // Filter out nodes without operationId (like Start node) - .map(node => { + const steps = sortedNodes.filter(node => node.data.operationId); // Filter out nodes without operationId + + try { + // 1. Reset Session + await fetch('http://localhost:8989/session/reset', { method: 'POST' }); + + const results: { id: string; status: string; result?: unknown; error?: unknown }[] = []; + + // 2. Execute steps one by one + for (const node of steps) { + if (onStepStart) { + onStepStart(node.id); + } + const params = (node.data.parameters || []).reduce((acc, param) => { if (param.default !== undefined && param.default !== null && param.default !== '') { acc[param.name] = param.default; @@ -51,7 +67,6 @@ export const useScenarioRunner = () => { let className = node.data.className as string; let functionName = node.data.functionName as string; - // Fallback to parsing operationId if className/functionName are missing if ((!className || !functionName) && typeof node.data.operationId === 'string') { const parts = node.data.operationId.split('.'); if (parts.length === 2) { @@ -59,32 +74,53 @@ export const useScenarioRunner = () => { } } - return { + // Construct URL with query param for background execution + const url = new URL(`http://localhost:8989/api/${className}/${functionName}`); + if (node.data.runInBackground) { + url.searchParams.append('run_in_background', 'true'); + } + url.searchParams.append('step_id', node.id); + + console.log(`Executing step ${node.id}: ${className}.${functionName}`, params); + + const response = await fetch(url.toString(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Step ${className}.${functionName} failed: ${response.status} ${errorText}`); + } + + const result = await response.json(); + + // Add ID to result to match expected format + const stepResult = { id: node.id, - className, - functionName, - parameters: params + status: result.status || 'success', + result: result.result || result, + error: result.error }; - }); + results.push(stepResult); - try { - console.log('Sending scenario steps:', steps); - const response = await fetch('http://localhost:8989/run-scenario', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ steps }) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + if (onStepComplete) { + onStepComplete(stepResult as { id: string; status: 'success' | 'failure' | 'error'; result?: unknown }); + } + + // If error, stop execution + if (stepResult.status === 'error' || stepResult.status === 'failure') { + console.error(`Step ${node.id} failed`, stepResult); + break; + } } - const result: ScenarioExecutionResult = await response.json(); - return result; + return { results, status: 'completed', duration: 0 }; } catch (error) { console.error('Error running scenario:', error); - alert('Failed to run scenario. Is the backend server running?'); + alert(`Failed to run scenario: ${error}`); return null; } finally { setIsRunning(false); diff --git a/web-editor/src/styles/Node.module.css b/web-editor/src/styles/Node.module.css index 1cdcc4c..d6c63b4 100644 --- a/web-editor/src/styles/Node.module.css +++ b/web-editor/src/styles/Node.module.css @@ -1,6 +1,7 @@ .customNode { - background-color: var(--bg-primary); - border: 1px solid var(--border-color); + --node-color: var(--border-color); + background-color: var(--rf-node-bg); + border: 1px solid var(--node-color); border-radius: var(--radius-md); padding: 12px 16px; min-width: 180px; @@ -10,7 +11,6 @@ .customNode:hover { box-shadow: var(--shadow-lg); - border-color: var(--accent-primary); } .customNodeHeader { @@ -34,7 +34,6 @@ } .statusButton { - margin-left: auto; cursor: pointer; background: none; border: none; @@ -44,23 +43,45 @@ transition: transform 0.2s ease; } -.statusButton:hover { - transform: scale(1.1); +.statusIndicator { + margin-left: auto; + display: flex; + align-items: center; +} + +.spinner { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } } /* Status indicators */ .statusSuccess { - border-color: var(--success); - box-shadow: 0 0 0 1px var(--success); + --node-color: var(--success); + border-color: var(--node-color); } .statusError { - border-color: var(--danger); - box-shadow: 0 0 0 1px var(--danger); + --node-color: var(--danger); + border-color: var(--node-color); background-color: rgba(239, 68, 68, 0.05); } +.statusRunning { + --node-color: var(--accent-primary); + border-color: var(--node-color); + background-color: rgba(59, 130, 246, 0.05); +} + .selected { - border-color: var(--accent-primary); - box-shadow: 0 0 0 2px var(--accent-primary); + border-width: 2px; + padding: 11px 15px; + border-color: var(--node-color); } diff --git a/web-editor/src/styles/theme.css b/web-editor/src/styles/theme.css index fbdb558..7a1132b 100644 --- a/web-editor/src/styles/theme.css +++ b/web-editor/src/styles/theme.css @@ -50,9 +50,9 @@ /* Borders & UI */ --border-color: #334155; /* Slate 700 */ - --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3); - --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3); - --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3), 0 4px 6px -4px rgb(0 0 0 / 0.3); + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.6); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.6), 0 2px 4px -2px rgb(0 0 0 / 0.6); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.6), 0 4px 6px -4px rgb(0 0 0 / 0.6); /* React Flow overrides */ --rf-bg: #0f172a; diff --git a/web-editor/src/types/scenario.ts b/web-editor/src/types/scenario.ts index 933326e..0690ace 100644 --- a/web-editor/src/types/scenario.ts +++ b/web-editor/src/types/scenario.ts @@ -22,9 +22,10 @@ export interface NodeData extends Record { functionName?: string; description?: string; parameters?: OperationParam[]; - status?: 'success' | 'failure' | 'error'; + status?: 'success' | 'failure' | 'error' | 'running'; result?: unknown; onShowResult?: (result: unknown) => void; + runInBackground?: boolean; } export interface ScenarioStep { @@ -32,6 +33,7 @@ export interface ScenarioStep { className: string; functionName: string; parameters: Record; + run_in_background?: boolean; } export interface StepResult { From fd19743faf953e563a61aeb5175cfad605641350 Mon Sep 17 00:00:00 2001 From: Roman Pszonka Date: Wed, 24 Dec 2025 01:19:58 +0000 Subject: [PATCH 04/20] cleanup runner --- .../server/introspection.py | 93 + src/openutm_verification/server/main.py | 12 +- src/openutm_verification/server/runner.py | 460 ++-- .../scenario_2025-12-23T21-38-50-073Z.json | 400 ---- .../scenario_2025-12-24T01-15-16-238Z.json | 1906 +++++++++++++++++ 5 files changed, 2144 insertions(+), 727 deletions(-) create mode 100644 src/openutm_verification/server/introspection.py delete mode 100644 web-editor/examples/scenario_2025-12-23T21-38-50-073Z.json create mode 100644 web-editor/examples/scenario_2025-12-24T01-15-16-238Z.json diff --git a/src/openutm_verification/server/introspection.py b/src/openutm_verification/server/introspection.py new file mode 100644 index 0000000..cf7d6f6 --- /dev/null +++ b/src/openutm_verification/server/introspection.py @@ -0,0 +1,93 @@ +import inspect +import re +from enum import Enum +from typing import Any, Dict, Type + +from openutm_verification.core.execution.dependency_resolution import DEPENDENCIES + + +def _get_type_info(annotation: Any) -> tuple[str, bool, list[dict[str, Any]] | None]: + """Extracts type string, enum status, and options from an annotation.""" + type_str = "Any" + is_enum = False + options = None + + if annotation != inspect.Parameter.empty: + if inspect.isclass(annotation) and issubclass(annotation, Enum): + is_enum = True + type_str = annotation.__name__ + options = [{"name": e.name, "value": e.value} for e in annotation] + else: + type_str = str(annotation) + # Use regex to remove module paths + type_str = re.sub(r"([a-zA-Z_]\w*\.)+", "", type_str) + # Remove wrapper if present + if type_str.startswith(""): + type_str = type_str[8:-2] + + return type_str, is_enum, options + + +def _get_default_value(default: Any) -> Any: + """Extracts the default value for a parameter.""" + if default == inspect.Parameter.empty: + return None + if default is None: + return None + if isinstance(default, Enum): + return default.value + return str(default) + + +def process_parameter(param_name: str, param: inspect.Parameter) -> Dict[str, Any] | None: + """ + Extracts metadata from a function parameter for API generation. + """ + if param_name == "self": + return None + + type_str, is_enum, options = _get_type_info(param.annotation) + default_val = _get_default_value(param.default) + + param_info = { + "name": param_name, + "type": type_str, + "default": default_val, + "required": param.default == inspect.Parameter.empty, + } + + if is_enum: + param_info["isEnum"] = True + param_info["options"] = options + + return param_info + + +def process_method(class_name: str, client_class: Type, name: str, method: Any) -> Dict[str, Any] | None: + """ + Extracts metadata from a client method if it's a scenario step. + """ + if not hasattr(method, "_is_scenario_step"): + return None + + step_name = getattr(method, "_step_name") + sig = inspect.signature(method) + parameters = [] + for param_name, param in sig.parameters.items(): + # Skip dependencies that are automatically injected + if param.annotation in DEPENDENCIES: + continue + + param_info = process_parameter(param_name, param) + if param_info: + parameters.append(param_info) + + return { + "id": f"{class_name}.{name}", + "name": step_name, + "functionName": name, + "className": class_name, + "description": inspect.getdoc(method) or "", + "parameters": parameters, + "filePath": inspect.getfile(client_class), + } diff --git a/src/openutm_verification/server/main.py b/src/openutm_verification/server/main.py index bde58e0..d0e4353 100644 --- a/src/openutm_verification/server/main.py +++ b/src/openutm_verification/server/main.py @@ -5,6 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, ConfigDict, create_model +from openutm_verification.core.execution.dependency_resolution import DEPENDENCIES from openutm_verification.server.runner import DynamicRunner, ScenarioDefinition, StepDefinition app = FastAPI() @@ -70,6 +71,9 @@ async def run_scenario(scenario: ScenarioDefinition): if param_name == "self": continue + if param.annotation in DEPENDENCIES: + continue + annotation = param.annotation if annotation == inspect.Parameter.empty: annotation = Any @@ -105,4 +109,10 @@ async def handler(body: req_model, run_in_background: bool = False, step_id: Opt if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8989) + uvicorn.run( + "openutm_verification.server.main:app", + host="0.0.0.0", + port=8989, + reload=True, + reload_includes=["*.py"], + ) diff --git a/src/openutm_verification/server/runner.py b/src/openutm_verification/server/runner.py index 416b9a0..965f1b5 100644 --- a/src/openutm_verification/server/runner.py +++ b/src/openutm_verification/server/runner.py @@ -16,13 +16,13 @@ from openutm_verification.core.clients.flight_blender.flight_blender_client import FlightBlenderClient from openutm_verification.core.clients.opensky.opensky_client import OpenSkyClient from openutm_verification.core.clients.system.system_client import SystemClient -from openutm_verification.core.execution.config_models import AppConfig, ConfigProxy -from openutm_verification.core.execution.dependency_resolution import DependencyResolver +from openutm_verification.core.execution.config_models import AppConfig, ConfigProxy, DataFiles +from openutm_verification.core.execution.dependency_resolution import CONTEXT, DEPENDENCIES, DependencyResolver +from openutm_verification.core.execution.scenario_runner import ScenarioState, _scenario_state from openutm_verification.core.reporting.reporting_models import Status, StepResult from openutm_verification.models import OperationState -from openutm_verification.simulator.flight_declaration import FlightDeclarationGenerator -from openutm_verification.simulator.geo_json_telemetry import GeoJSONFlightsSimulator -from openutm_verification.simulator.models.flight_data_types import GeoJSONFlightsSimulatorConfiguration +from openutm_verification.scenarios.common import generate_flight_declaration, generate_telemetry +from openutm_verification.server.introspection import process_method class StepDefinition(BaseModel): @@ -41,37 +41,55 @@ class DynamicRunner: def __init__(self, config_path: str = "config/default.yaml"): self.config_path = Path(config_path) self.config = self._load_config() - self.client_map: Dict[str, Type] = { - "FlightBlenderClient": FlightBlenderClient, - "OpenSkyClient": OpenSkyClient, - "AirTrafficClient": AirTrafficClient, - "SystemClient": SystemClient, - } + self.client_map: Dict[str, Type] = {} + for dep_type in DEPENDENCIES: + if isinstance(dep_type, type) and dep_type.__name__.endswith("Client"): + self.client_map[dep_type.__name__] = dep_type + self.session_stack: AsyncExitStack | None = None self.session_resolver: DependencyResolver | None = None self.session_context: Dict[str, Any] = {} async def initialize_session(self): + logger.info("Initializing new session") if self.session_stack: await self.close_session() self.session_stack = AsyncExitStack() self.session_resolver = DependencyResolver(self.session_stack) - # Pre-generate data + # Set up context for dependencies + # We use a default context so dependencies like DataFiles can be resolved + suite_name = next(iter(self.config.suites.keys()), "default") + + CONTEXT.set( + { + "scenario_id": "interactive_session", + "suite_scenario": None, + "suite_name": suite_name, + "docs": None, + } + ) + + # Pre-generate data using resolved DataFiles try: - flight_declaration, telemetry_states = self._generate_data() + data_files = cast(DataFiles, await self.session_resolver.resolve(DataFiles)) + flight_declaration, telemetry_states = self._generate_data(data_files) + + scenario_state = ScenarioState(active=True, flight_declaration_data=flight_declaration, telemetry_data=telemetry_states) + self.session_context = { "operation_id": None, "flight_declaration": flight_declaration, "telemetry_states": telemetry_states, "step_results": {}, + "scenario_state": scenario_state, } except Exception as e: logger.error(f"Data generation failed: {e}") - raise async def close_session(self): + logger.info("Closing session") if self.session_stack: await self.session_stack.aclose() self.session_stack = None @@ -80,144 +98,24 @@ async def close_session(self): async def execute_single_step(self, step: StepDefinition) -> Dict[str, Any]: if not self.session_resolver: + logger.info("Session resolver not found, initializing session") await self.initialize_session() + assert self.session_resolver is not None + + # Set scenario state context for this execution + token = None + if "scenario_state" in self.session_context: + token = _scenario_state.set(self.session_context["scenario_state"]) + try: return await self._execute_step(step, self.session_resolver, self.session_context) except Exception as e: logger.error(f"Error executing step {step.functionName}: {e}") return {"step": f"{step.className}.{step.functionName}", "status": "error", "error": str(e)} - - def _process_parameter(self, param_name: str, param: inspect.Parameter) -> Dict[str, Any] | None: - if param_name == "self": - return None - - annotation = param.annotation - default = param.default - - # Handle Type - type_str = "Any" - is_enum = False - options = None - - if annotation != inspect.Parameter.empty: - # Check for Enum - if inspect.isclass(annotation) and issubclass(annotation, Enum): - is_enum = True - type_str = annotation.__name__ - options = [{"name": e.name, "value": e.value} for e in annotation] - else: - # Clean up type string - type_str = str(annotation) - # Use regex to remove module paths (e.g. "list[openutm_verification.models.FlightObservation]" -> "list[FlightObservation]") - type_str = re.sub(r"([a-zA-Z_][a-zA-Z0-9_]*\.)+", "", type_str) - # Remove wrapper if present - if type_str.startswith(""): - type_str = type_str[8:-2] - - # Handle Default - default_val = None - if default != inspect.Parameter.empty: - if default is None: - default_val = None - # If default is an Enum member, get its value - elif isinstance(default, Enum): - default_val = default.value - else: - default_val = str(default) - - param_info = {"name": param_name, "type": type_str, "default": default_val, "required": default == inspect.Parameter.empty} - - if is_enum: - param_info["isEnum"] = True - param_info["options"] = options - - return param_info - - def _process_method(self, class_name: str, client_class: Type, name: str, method: Any) -> Dict[str, Any] | None: - if not hasattr(method, "_is_scenario_step"): - return None - - step_name = getattr(method, "_step_name") - sig = inspect.signature(method) - parameters = [] - for param_name, param in sig.parameters.items(): - param_info = self._process_parameter(param_name, param) - if param_info: - parameters.append(param_info) - - return { - "id": f"{class_name}.{name}", - "name": step_name, - "functionName": name, - "className": class_name, - "description": inspect.getdoc(method) or "", - "parameters": parameters, - "filePath": inspect.getfile(client_class), - } - - def get_available_operations(self) -> List[Dict[str, Any]]: - operations = [] - for class_name, client_class in self.client_map.items(): - for name, method in inspect.getmembers(client_class): - op_info = self._process_method(class_name, client_class, name, method) - if op_info: - operations.append(op_info) - return operations - - def _load_config(self) -> AppConfig: - if not self.config_path.exists(): - # Try to find it relative to project root if we are in src/... - pass - - # Fallback to absolute path if needed, but for now assume running from root - with open(self.config_path, "r", encoding="utf-8") as f: - config_data = yaml.safe_load(f) - - config = AppConfig.model_validate(config_data) - project_root = self.config_path.parent.parent - config.resolve_paths(project_root) - - # Only initialize ConfigProxy if it hasn't been initialized yet - try: - ConfigProxy.initialize(config) - except TypeError: - # If already initialized, we can optionally override it or just ignore - # For now, let's override to ensure we have the latest config - ConfigProxy.override(config) - - return config - - def _generate_data(self): - # Hardcoded for now, could be parameterized - config_path = Path("config/bern/flight_declaration.json") - - # Generate Flight Declaration - generator = FlightDeclarationGenerator(bounds_path=config_path) - flight_declaration = generator.generate() - - # Generate Telemetry - with open(config_path, "r", encoding="utf-8") as f: - bounds = json.load(f) - - # Create a simple LineString feature from min/min to max/max - flight_path_geojson = { - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "properties": {}, - "geometry": {"type": "LineString", "coordinates": [[bounds["minx"], bounds["miny"]], [bounds["maxx"], bounds["maxy"]]]}, - } - ], - } - - simulator_config = GeoJSONFlightsSimulatorConfiguration(geojson=flight_path_geojson) - simulator = GeoJSONFlightsSimulator(simulator_config) - simulator.generate_flight_grid_and_path_points(altitude_of_ground_level_wgs_84=570) - telemetry_states = simulator.generate_states(duration=30) - - return flight_declaration, telemetry_states + finally: + if token: + _scenario_state.reset(token) def _resolve_ref(self, ref: str, context: Dict[str, Any]) -> Any: # ref format: "step_id.field.subfield" or just "step_id" @@ -241,8 +139,6 @@ def _resolve_ref(self, ref: str, context: Dict[str, Any]) -> Any: elif hasattr(current_value, part): current_value = getattr(current_value, part) else: - # If we can't find it, maybe the user meant to access a property of the result object - # but the result object was serialized to a dict. raise ValueError( f"Could not resolve '{part}' in '{ref}'." f"Available keys: {list(current_value.keys()) if isinstance(current_value, dict) else 'Not a dict'}" @@ -250,10 +146,7 @@ def _resolve_ref(self, ref: str, context: Dict[str, Any]) -> Any: return current_value - def _prepare_params(self, step: StepDefinition, context: Dict[str, Any], method: Any) -> Dict[str, Any]: - params = step.parameters.copy() - - # Resolve references + def _resolve_references_in_params(self, params: Dict[str, Any], context: Dict[str, Any]) -> None: for key, value in params.items(): if isinstance(value, dict) and "$ref" in value: try: @@ -263,43 +156,16 @@ def _prepare_params(self, step: StepDefinition, context: Dict[str, Any], method: logger.error(f"Failed to resolve reference {value['$ref']}: {e}") raise - # Inject operation_id if missing and available, AND if the method accepts it - if "operation_id" not in params and context["operation_id"]: - sig = inspect.signature(method) - if "operation_id" in sig.parameters: - params["operation_id"] = context["operation_id"] - - # Special handling for upload_flight_declaration - if step.functionName == "upload_flight_declaration": - if "declaration" not in params and "filename" not in params: - params["declaration"] = context["flight_declaration"] - - # Handle parameter renaming for initialize_verify_sdsp_track - if step.functionName == "initialize_verify_sdsp_track": - if "expected_heartbeat_interval_seconds" in params: - params["expected_track_interval_seconds"] = params.pop("expected_heartbeat_interval_seconds") - if "expected_heartbeat_count" in params: - params["expected_track_count"] = params.pop("expected_heartbeat_count") - - # Handle parameter renaming for setup_flight_declaration - if step.functionName == "setup_flight_declaration": - if "telemetry_path" in params: - params["trajectory_path"] = params.pop("telemetry_path") + def _prepare_params(self, step: StepDefinition, context: Dict[str, Any]) -> Dict[str, Any]: + params = step.parameters.copy() + + # Resolve references + self._resolve_references_in_params(params, context) # Special handling for submit_telemetry if step.functionName == "submit_telemetry" and "states" not in params: params["states"] = context["telemetry_states"] - # # Special handling for submit_simulated_air_traffic - # if step.functionName == "submit_simulated_air_traffic" and "observations" not in params: - # if "air_traffic_observations" in context: - # params["observations"] = context["air_traffic_observations"] - - # # Special handling for submit_air_traffic - # if step.functionName == "submit_air_traffic" and "observations" not in params: - # if "air_traffic_observations" in context: - # params["observations"] = context["air_traffic_observations"] - # Special handling for update_operation_state if step.functionName == "update_operation_state" and "new_state" in params: if isinstance(params["new_state"], int): @@ -310,178 +176,120 @@ def _prepare_params(self, step: StepDefinition, context: Dict[str, Any], method: return params - async def _execute_step(self, step: StepDefinition, resolver: DependencyResolver, context: Dict[str, Any]) -> Dict[str, Any]: - logger.info(f"Executing step: {step.className}.{step.functionName}") - - if step.className not in self.client_map: - raise ValueError(f"Unknown client class: {step.className}") - - # Special handling for SystemClient.join_task - if step.className == "SystemClient" and step.functionName == "join_task": - task_id_param = step.parameters.get("task_id") - if not task_id_param: - raise ValueError("task_id is required for join_task") - - # Handle if task_id is passed as a dictionary (from a previous step result) - if isinstance(task_id_param, dict) and "task_id" in task_id_param: - task_id = task_id_param["task_id"] - else: - task_id = str(task_id_param) - - background_tasks = context.get("background_tasks", {}) - if task_id not in background_tasks: - raise ValueError(f"Background task {task_id} not found") - - logger.info(f"Joining background task {task_id}") - task = background_tasks[task_id] - result = await task - # Clean up - del background_tasks[task_id] - - # Continue to result processing... + def _serialize_result(self, result: Any) -> Any: + if hasattr(result, "to_dict"): + return getattr(result, "to_dict")() + elif isinstance(result, BaseModel): + return result.model_dump() else: - client_type = self.client_map[step.className] - client = await resolver.resolve(client_type) - - method = getattr(client, step.functionName) - params = self._prepare_params(step, context, method) + return result - if step.run_in_background: - import asyncio - import uuid - - task_id = str(uuid.uuid4()) - logger.info(f"Starting background task {task_id} for {step.className}.{step.functionName}") - - # Create a coroutine but don't await it yet - coro = method(**params) - if not inspect.isawaitable(coro): - # If it's not async, wrap it? For now assume async. - pass - - task = asyncio.create_task(coro) - context.setdefault("background_tasks", {})[task_id] = task - - result = {"task_id": task_id, "status": "running"} + def _determine_status(self, result: Any) -> str: + if hasattr(result, "status"): + status_val = getattr(result, "status") + if status_val == Status.FAIL: + return "failure" + elif status_val == Status.PASS: + return "success" + return "success" - # Store result for referencing - if step.id: - context.setdefault("step_results", {})[step.id] = result + async def _execute_step(self, step: StepDefinition, resolver: DependencyResolver, context: Dict[str, Any]) -> Dict[str, Any]: + client_class = self.client_map[step.className] + client = await resolver.resolve(client_class) - return {"id": step.id, "step": f"{step.className}.{step.functionName}", "status": "running", "result": result} + method = getattr(client, step.functionName) - result = method(**params) - if inspect.isawaitable(result): - result = await result + # Prepare parameters (resolve refs, inject context) + kwargs = self._prepare_params(step, context) - # Store result for referencing - if step.id: - context.setdefault("step_results", {})[step.id] = result - logger.info(f"Stored result for step {step.id}") - else: - logger.warning(f"No step ID provided, result not stored for {step.functionName}") + # Inject dependencies if missing + sig = inspect.signature(method) + for name, param in sig.parameters.items(): + if name == "self" or name in kwargs: + continue - # Capture air traffic observations - if step.functionName in ["generate_simulated_air_traffic_data", "fetch_data"]: - observations = None - if hasattr(result, "details"): - observations = result.details - else: - observations = result + if param.annotation in DEPENDENCIES: + kwargs[name] = await resolver.resolve(param.annotation) - context["air_traffic_observations"] = observations - if observations is not None: - try: - logger.info(f"Captured {len(observations)} air traffic observations") - except TypeError: - logger.warning(f"Captured observations of type {type(observations)} which has no len()") - else: - logger.warning("Captured None observations") - - # Capture operation_id from result if available - if isinstance(result, dict) and "id" in result: - # Only update if it looks like an operation ID (UUID-ish) or if we just uploaded a declaration - if step.functionName == "upload_flight_declaration": - context["operation_id"] = result["id"] - logger.info(f"Captured operation_id: {context['operation_id']}") - elif hasattr(result, "details") and isinstance(result.details, dict): - # Handle StepResult - op_id = result.details.get("id") - if op_id and step.functionName == "upload_flight_declaration": - context["operation_id"] = op_id - logger.info(f"Captured operation_id: {context['operation_id']}") + result = await method(**kwargs) # Serialize result if it's an object - if hasattr(result, "to_dict"): - # Use getattr to avoid type checking errors on dynamic objects - result_data = getattr(result, "to_dict")() - elif hasattr(result, "model_dump"): - result_data = getattr(result, "model_dump")() - else: - result_data = str(result) # Fallback + result_data = self._serialize_result(result) # Store result for linking if step.id: if "step_results" not in context: context["step_results"] = {} - # Store the raw result object to allow attribute access during resolution context["step_results"][step.id] = result # Determine overall status based on result content - status_str = "success" - if hasattr(result, "status"): - if result.status == Status.FAIL: - status_str = "failure" - elif result.status == Status.PASS: - status_str = "success" + status_str = self._determine_status(result) + + # Add to scenario state if not already added by decorator + # The decorator adds it, but if the method wasn't decorated, we might want to add it here? + # Most client methods are decorated. If we add it again, we might duplicate. + # Let's assume the decorator handles it if present. return {"id": step.id, "step": f"{step.className}.{step.functionName}", "status": status_str, "result": result_data} - async def _run_implicit_teardown(self, resolver: DependencyResolver, context: Dict[str, Any]) -> Dict[str, Any]: - logger.info(f"Implicit Teardown: Deleting Operation {context['operation_id']}") - fb_client = cast(FlightBlenderClient, await resolver.resolve(FlightBlenderClient)) - # delete_flight_declaration uses the stored latest_flight_declaration_id in the client instance - teardown_result = await fb_client.delete_flight_declaration() + def get_available_operations(self) -> List[Dict[str, Any]]: + operations = [] + for class_name, client_class in self.client_map.items(): + for name, method in inspect.getmembers(client_class): + op_info = process_method(class_name, client_class, name, method) + if op_info: + operations.append(op_info) + return operations + + def _load_config(self) -> AppConfig: + if not self.config_path.exists(): + # Try to find it relative to project root if we are in src/... + pass - result_data = getattr(teardown_result, "model_dump")() if hasattr(teardown_result, "model_dump") else str(teardown_result) - return {"step": "Teardown: Delete Flight Declaration", "status": "success", "result": result_data} + # Fallback to absolute path if needed, but for now assume running from root + with open(self.config_path, "r", encoding="utf-8") as f: + config_data = yaml.safe_load(f) - async def run_scenario(self, scenario: ScenarioDefinition) -> List[Dict[str, Any]]: - results = [] + config = AppConfig.model_validate(config_data) + project_root = self.config_path.parent.parent + config.resolve_paths(project_root) - # Pre-generate data + # Only initialize ConfigProxy if it hasn't been initialized yet try: - flight_declaration, telemetry_states = self._generate_data() - except Exception as e: - logger.error(f"Data generation failed: {e}") - return [{"step": "Data Generation", "status": "error", "error": str(e)}] + ConfigProxy.initialize(config) + except TypeError: + # If already initialized, we can optionally override it or just ignore + # For now, let's override to ensure we have the latest config + ConfigProxy.override(config) + + return config - context = {"operation_id": None, "flight_declaration": flight_declaration, "telemetry_states": telemetry_states, "step_results": {}} + def _generate_data(self, data_files: DataFiles): + flight_declaration = None + telemetry_states = None - async with AsyncExitStack() as stack: - resolver = DependencyResolver(stack) + if data_files.flight_declaration: + try: + flight_declaration = generate_flight_declaration(data_files.flight_declaration) + except Exception as e: + logger.warning(f"Could not generate flight declaration: {e}") + if data_files.trajectory: try: - for step in scenario.steps: - try: - step_result = await self._execute_step(step, resolver, context) - results.append(step_result) - except Exception as e: - logger.error(f"Error in step {step.functionName}: {e}") - results.append({"step": f"{step.className}.{step.functionName}", "status": "error", "error": str(e)}) - # Stop on error? - break + telemetry_states = generate_telemetry(data_files.trajectory) except Exception as e: - logger.error(f"Scenario execution failed: {e}") - results.append({"step": "Scenario Setup", "status": "error", "error": str(e)}) - finally: - # Implicit Teardown - if context["operation_id"]: - try: - teardown_result = await self._run_implicit_teardown(resolver, context) - results.append(teardown_result) - except Exception as teardown_error: - logger.error(f"Teardown failed: {teardown_error}") - results.append({"step": "Teardown", "status": "error", "error": str(teardown_error)}) + logger.warning(f"Could not generate telemetry: {e}") + + return flight_declaration, telemetry_states + + async def run_scenario(self, scenario: ScenarioDefinition) -> List[Dict[str, Any]]: + results = [] + if not self.session_resolver: + await self.initialize_session() + for step in scenario.steps: + result = await self.execute_single_step(step) + results.append(result) + if result.get("status") == "error": + break return results diff --git a/web-editor/examples/scenario_2025-12-23T21-38-50-073Z.json b/web-editor/examples/scenario_2025-12-23T21-38-50-073Z.json deleted file mode 100644 index 8ec9f24..0000000 --- a/web-editor/examples/scenario_2025-12-23T21-38-50-073Z.json +++ /dev/null @@ -1,400 +0,0 @@ -{ - "nodes": [ - { - "id": "dndnode_16", - "type": "custom", - "position": { - "x": 0, - "y": 0 - }, - "data": { - "label": "Setup Flight Declaration", - "operationId": "FlightBlenderClient.setup_flight_declaration", - "className": "FlightBlenderClient", - "functionName": "setup_flight_declaration", - "description": "Generates data and uploads flight declaration.", - "parameters": [ - { - "name": "flight_declaration_path", - "type": "str", - "default": "config/bern/flight_declaration.json" - }, - { - "name": "trajectory_path", - "type": "str", - "default": "config/bern/trajectory_f1.json" - } - ], - "status": "success", - "result": { - "name": "Setup Flight Declaration", - "status": "PASS", - "duration": 0.3278837203979492, - "details": null, - "error_message": null - } - }, - "measured": { - "width": 248, - "height": 56 - }, - "selected": false, - "dragging": false - }, - { - "id": "dndnode_17", - "type": "custom", - "position": { - "x": 0, - "y": 360 - }, - "data": { - "label": "Update Operation State", - "operationId": "FlightBlenderClient.update_operation_state", - "className": "FlightBlenderClient", - "functionName": "update_operation_state", - "description": "Update the state of a flight operation.\n\nPosts the new state and optionally waits for the specified duration.\n\nArgs:\n new_state: The new OperationState to set.\n duration_seconds: Optional seconds to sleep after update (default 0).\n\nReturns:\n The JSON response from the API.\n\nRaises:\n FlightBlenderError: If the update request fails.", - "parameters": [ - { - "name": "new_state", - "type": "OperationState", - "options": [ - { - "name": "PROCESSING", - "value": 0 - }, - { - "name": "ACCEPTED", - "value": 1 - }, - { - "name": "ACTIVATED", - "value": 2 - }, - { - "name": "NONCONFORMING", - "value": 3 - }, - { - "name": "CONTINGENT", - "value": 4 - }, - { - "name": "ENDED", - "value": 5 - }, - { - "name": "WITHDRAWN", - "value": 6 - }, - { - "name": "CANCELLED", - "value": 7 - }, - { - "name": "REJECTED", - "value": 8 - } - ], - "isEnum": true, - "default": 2 - }, - { - "name": "duration_seconds", - "type": "int", - "default": 0 - } - ], - "status": "success", - "result": { - "name": "Update Operation State", - "status": "PASS", - "duration": 0.11129021644592285, - "details": { - "state": 2, - "submitted_by": null - }, - "error_message": null - } - }, - "measured": { - "width": 244, - "height": 56 - }, - "selected": false, - "dragging": false - }, - { - "id": "dndnode_18", - "type": "custom", - "position": { - "x": 0, - "y": 180 - }, - "data": { - "label": "Wait X seconds", - "operationId": "FlightBlenderClient.wait_x_seconds", - "className": "FlightBlenderClient", - "functionName": "wait_x_seconds", - "description": "Wait for a specified number of seconds.", - "parameters": [ - { - "name": "wait_time_seconds", - "type": "int", - "default": 5 - } - ], - "status": "success", - "result": { - "name": "Wait X seconds", - "status": "PASS", - "duration": 5.001457929611206, - "details": "Waited for Flight Blender to process 5 seconds.", - "error_message": null - } - }, - "measured": { - "width": 191, - "height": 56 - }, - "selected": false, - "dragging": false - }, - { - "id": "dndnode_19", - "type": "custom", - "position": { - "x": 0, - "y": 540 - }, - "data": { - "label": "Submit Telemetry", - "operationId": "FlightBlenderClient.submit_telemetry", - "className": "FlightBlenderClient", - "functionName": "submit_telemetry", - "description": "Submit telemetry data for a flight operation from in-memory states.\n\nSubmits telemetry states sequentially from the provided list, with optional\nduration limiting and error handling for rate limits.\n\nArgs:\n states: List of telemetry state dictionaries. If None, uses the generated telemetry states from context.\n duration_seconds: Optional maximum duration in seconds to submit telemetry (default 0 for unlimited).\n\nReturns:\n The JSON response from the last telemetry submission, or None if no submissions occurred.\n\nRaises:\n FlightBlenderError: If maximum waiting time is exceeded due to rate limits.", - "parameters": [ - { - "name": "states", - "type": "list[RIDAircraftState] | None", - "default": null - }, - { - "name": "duration_seconds", - "type": "int", - "default": 5 - } - ], - "status": "success", - "result": { - "name": "Submit Telemetry", - "status": "PASS", - "duration": 5.5007102489471436, - "details": { - "message": "Telemetry data successfully submitted" - }, - "error_message": null - } - }, - "measured": { - "width": 205, - "height": 56 - }, - "selected": false, - "dragging": false - }, - { - "id": "dndnode_20", - "type": "custom", - "position": { - "x": 0, - "y": 720 - }, - "data": { - "label": "Update Operation State", - "operationId": "FlightBlenderClient.update_operation_state", - "className": "FlightBlenderClient", - "functionName": "update_operation_state", - "description": "Update the state of a flight operation.\n\nPosts the new state and optionally waits for the specified duration.\n\nArgs:\n new_state: The new OperationState to set.\n duration_seconds: Optional seconds to sleep after update (default 0).\n\nReturns:\n The JSON response from the API.\n\nRaises:\n FlightBlenderError: If the update request fails.", - "parameters": [ - { - "name": "new_state", - "type": "OperationState", - "options": [ - { - "name": "PROCESSING", - "value": 0 - }, - { - "name": "ACCEPTED", - "value": 1 - }, - { - "name": "ACTIVATED", - "value": 2 - }, - { - "name": "NONCONFORMING", - "value": 3 - }, - { - "name": "CONTINGENT", - "value": 4 - }, - { - "name": "ENDED", - "value": 5 - }, - { - "name": "WITHDRAWN", - "value": 6 - }, - { - "name": "CANCELLED", - "value": 7 - }, - { - "name": "REJECTED", - "value": 8 - } - ], - "isEnum": true, - "default": 5 - }, - { - "name": "duration_seconds", - "type": "int", - "default": 0 - } - ], - "status": "success", - "result": { - "name": "Update Operation State", - "status": "PASS", - "duration": 0.09701776504516602, - "details": { - "state": 5, - "submitted_by": null - }, - "error_message": null - } - }, - "measured": { - "width": 244, - "height": 56 - }, - "selected": false, - "dragging": false - }, - { - "id": "dndnode_21", - "type": "custom", - "position": { - "x": 0, - "y": 900 - }, - "data": { - "label": "Delete Flight Declaration", - "operationId": "FlightBlenderClient.delete_flight_declaration", - "className": "FlightBlenderClient", - "functionName": "delete_flight_declaration", - "description": "Delete a flight declaration by ID.\n\nReturns:\n A dictionary with deletion status, including whether it was successful.", - "parameters": [], - "status": "success", - "result": { - "name": "Delete Flight Declaration", - "status": "PASS", - "duration": 0.13648104667663574, - "details": { - "deleted": true, - "id": "00723428-af8a-4bae-9cd5-6e6395b46377" - }, - "error_message": null - } - }, - "measured": { - "width": 251, - "height": 56 - }, - "selected": false, - "dragging": false - } - ], - "edges": [ - { - "source": "dndnode_16", - "target": "dndnode_18", - "animated": true, - "style": { - "stroke": "var(--accent-primary)", - "strokeWidth": 1 - }, - "markerEnd": { - "type": "arrowclosed", - "color": "var(--accent-primary)" - }, - "id": "xy-edge__dndnode_16-dndnode_18" - }, - { - "source": "dndnode_18", - "target": "dndnode_17", - "animated": true, - "style": { - "stroke": "var(--accent-primary)", - "strokeWidth": 1 - }, - "markerEnd": { - "type": "arrowclosed", - "color": "var(--accent-primary)" - }, - "id": "xy-edge__dndnode_18-dndnode_17" - }, - { - "source": "dndnode_17", - "target": "dndnode_19", - "animated": true, - "style": { - "stroke": "var(--accent-primary)", - "strokeWidth": 1 - }, - "markerEnd": { - "type": "arrowclosed", - "color": "var(--accent-primary)" - }, - "id": "xy-edge__dndnode_17-dndnode_19" - }, - { - "source": "dndnode_19", - "target": "dndnode_20", - "animated": true, - "style": { - "stroke": "var(--accent-primary)", - "strokeWidth": 1 - }, - "markerEnd": { - "type": "arrowclosed", - "color": "var(--accent-primary)" - }, - "id": "xy-edge__dndnode_19-dndnode_20" - }, - { - "source": "dndnode_20", - "target": "dndnode_21", - "animated": true, - "style": { - "stroke": "var(--accent-primary)", - "strokeWidth": 1 - }, - "markerEnd": { - "type": "arrowclosed", - "color": "var(--accent-primary)" - }, - "id": "xy-edge__dndnode_20-dndnode_21" - } - ], - "viewport": { - "x": 774.2531380753138, - "y": 83, - "zoom": 0.8744769874476988 - } -} diff --git a/web-editor/examples/scenario_2025-12-24T01-15-16-238Z.json b/web-editor/examples/scenario_2025-12-24T01-15-16-238Z.json new file mode 100644 index 0000000..5830f0b --- /dev/null +++ b/web-editor/examples/scenario_2025-12-24T01-15-16-238Z.json @@ -0,0 +1,1906 @@ +{ + "nodes": [ + { + "id": "dndnode_16", + "type": "custom", + "position": { + "x": 0, + "y": 0 + }, + "data": { + "label": "Setup Flight Declaration", + "operationId": "FlightBlenderClient.setup_flight_declaration", + "className": "FlightBlenderClient", + "functionName": "setup_flight_declaration", + "description": "Generates data and uploads flight declaration.", + "parameters": [ + { + "name": "flight_declaration_path", + "type": "str", + "default": "config/bern/flight_declaration.json" + }, + { + "name": "trajectory_path", + "type": "str", + "default": "config/bern/trajectory_f1.json" + } + ], + "status": "success", + "result": { + "name": "Setup Flight Declaration", + "status": "PASS", + "duration": 0.4281291961669922, + "details": null, + "error_message": null + } + }, + "measured": { + "width": 248, + "height": 56 + }, + "selected": false, + "dragging": false + }, + { + "id": "dndnode_17", + "type": "custom", + "position": { + "x": 0, + "y": 360 + }, + "data": { + "label": "Update Operation State", + "operationId": "FlightBlenderClient.update_operation_state", + "className": "FlightBlenderClient", + "functionName": "update_operation_state", + "description": "Update the state of a flight operation.\n\nPosts the new state and optionally waits for the specified duration.\n\nArgs:\n new_state: The new OperationState to set.\n duration_seconds: Optional seconds to sleep after update (default 0).\n\nReturns:\n The JSON response from the API.\n\nRaises:\n FlightBlenderError: If the update request fails.", + "parameters": [ + { + "name": "new_state", + "type": "OperationState", + "options": [ + { + "name": "PROCESSING", + "value": 0 + }, + { + "name": "ACCEPTED", + "value": 1 + }, + { + "name": "ACTIVATED", + "value": 2 + }, + { + "name": "NONCONFORMING", + "value": 3 + }, + { + "name": "CONTINGENT", + "value": 4 + }, + { + "name": "ENDED", + "value": 5 + }, + { + "name": "WITHDRAWN", + "value": 6 + }, + { + "name": "CANCELLED", + "value": 7 + }, + { + "name": "REJECTED", + "value": 8 + } + ], + "isEnum": true, + "default": 2 + }, + { + "name": "duration_seconds", + "type": "int", + "default": 0 + } + ], + "status": "success", + "result": { + "name": "Update Operation State", + "status": "PASS", + "duration": 0.10649800300598145, + "details": { + "state": 2, + "submitted_by": null + }, + "error_message": null + } + }, + "measured": { + "width": 244, + "height": 56 + }, + "selected": false, + "dragging": false + }, + { + "id": "dndnode_18", + "type": "custom", + "position": { + "x": 0, + "y": 180 + }, + "data": { + "label": "Wait X seconds", + "operationId": "FlightBlenderClient.wait_x_seconds", + "className": "FlightBlenderClient", + "functionName": "wait_x_seconds", + "description": "Wait for a specified number of seconds.", + "parameters": [ + { + "name": "wait_time_seconds", + "type": "int", + "default": 5 + } + ], + "status": "success", + "result": { + "name": "Wait X seconds", + "status": "PASS", + "duration": 5.0012102127075195, + "details": "Waited for Flight Blender to process 5 seconds.", + "error_message": null + } + }, + "measured": { + "width": 191, + "height": 56 + }, + "selected": false, + "dragging": false + }, + { + "id": "dndnode_19", + "type": "custom", + "position": { + "x": 0, + "y": 720 + }, + "data": { + "label": "Submit Telemetry", + "operationId": "FlightBlenderClient.submit_telemetry", + "className": "FlightBlenderClient", + "functionName": "submit_telemetry", + "description": "Submit telemetry data for a flight operation from in-memory states.\n\nSubmits telemetry states sequentially from the provided list, with optional\nduration limiting and error handling for rate limits.\n\nArgs:\n states: List of telemetry state dictionaries. If None, uses the generated telemetry states from context.\n duration_seconds: Optional maximum duration in seconds to submit telemetry (default 0 for unlimited).\n\nReturns:\n The JSON response from the last telemetry submission, or None if no submissions occurred.\n\nRaises:\n FlightBlenderError: If maximum waiting time is exceeded due to rate limits.", + "parameters": [ + { + "name": "states", + "type": "list[RIDAircraftState] | None", + "default": null + }, + { + "name": "duration_seconds", + "type": "int", + "default": 5 + } + ], + "status": "success", + "result": { + "name": "Submit Telemetry", + "status": "PASS", + "duration": 5.538996934890747, + "details": { + "message": "Telemetry data successfully submitted" + }, + "error_message": null + } + }, + "measured": { + "width": 205, + "height": 56 + }, + "selected": false, + "dragging": false + }, + { + "id": "dndnode_20", + "type": "custom", + "position": { + "x": 0, + "y": 900 + }, + "data": { + "label": "Update Operation State", + "operationId": "FlightBlenderClient.update_operation_state", + "className": "FlightBlenderClient", + "functionName": "update_operation_state", + "description": "Update the state of a flight operation.\n\nPosts the new state and optionally waits for the specified duration.\n\nArgs:\n new_state: The new OperationState to set.\n duration_seconds: Optional seconds to sleep after update (default 0).\n\nReturns:\n The JSON response from the API.\n\nRaises:\n FlightBlenderError: If the update request fails.", + "parameters": [ + { + "name": "new_state", + "type": "OperationState", + "options": [ + { + "name": "PROCESSING", + "value": 0 + }, + { + "name": "ACCEPTED", + "value": 1 + }, + { + "name": "ACTIVATED", + "value": 2 + }, + { + "name": "NONCONFORMING", + "value": 3 + }, + { + "name": "CONTINGENT", + "value": 4 + }, + { + "name": "ENDED", + "value": 5 + }, + { + "name": "WITHDRAWN", + "value": 6 + }, + { + "name": "CANCELLED", + "value": 7 + }, + { + "name": "REJECTED", + "value": 8 + } + ], + "isEnum": true, + "default": 5 + }, + { + "name": "duration_seconds", + "type": "int", + "default": 0 + } + ], + "status": "success", + "result": { + "name": "Update Operation State", + "status": "PASS", + "duration": 0.1755518913269043, + "details": { + "state": 5, + "submitted_by": null + }, + "error_message": null + } + }, + "measured": { + "width": 244, + "height": 56 + }, + "selected": false, + "dragging": false + }, + { + "id": "dndnode_21", + "type": "custom", + "position": { + "x": 0, + "y": 1260 + }, + "data": { + "label": "Delete Flight Declaration", + "operationId": "FlightBlenderClient.delete_flight_declaration", + "className": "FlightBlenderClient", + "functionName": "delete_flight_declaration", + "description": "Delete a flight declaration by ID.\n\nReturns:\n A dictionary with deletion status, including whether it was successful.", + "parameters": [], + "status": "success", + "result": { + "name": "Delete Flight Declaration", + "status": "PASS", + "duration": 0.09616422653198242, + "details": { + "deleted": true, + "id": "4f925234-be8e-4480-9ecc-fa87f74843e5" + }, + "error_message": null + } + }, + "measured": { + "width": 251, + "height": 56 + }, + "selected": false, + "dragging": false + }, + { + "id": "dndnode_0", + "type": "custom", + "position": { + "x": 0, + "y": 540 + }, + "data": { + "label": "Check Operation State Connected", + "operationId": "FlightBlenderClient.check_operation_state_connected", + "className": "FlightBlenderClient", + "functionName": "check_operation_state_connected", + "description": "Check the operation state by polling the API until the expected state is reached.\n\nArgs:\n expected_state: The expected OperationState.\n duration_seconds: Maximum seconds to poll for the state.\n\nReturns:\n The JSON response from the API when the state is reached.\n\nRaises:\n FlightBlenderError: If the expected state is not reached within the timeout.", + "parameters": [ + { + "name": "expected_state", + "type": "OperationState", + "default": 2, + "required": true, + "isEnum": true, + "options": [ + { + "name": "PROCESSING", + "value": 0 + }, + { + "name": "ACCEPTED", + "value": 1 + }, + { + "name": "ACTIVATED", + "value": 2 + }, + { + "name": "NONCONFORMING", + "value": 3 + }, + { + "name": "CONTINGENT", + "value": 4 + }, + { + "name": "ENDED", + "value": 5 + }, + { + "name": "WITHDRAWN", + "value": 6 + }, + { + "name": "CANCELLED", + "value": 7 + }, + { + "name": "REJECTED", + "value": 8 + } + ] + }, + { + "name": "duration_seconds", + "type": "int", + "default": 2, + "required": false + } + ], + "status": "success", + "result": { + "name": "Check Operation State Connected", + "status": "PASS", + "duration": 0.09958386421203613, + "details": { + "operational_intent": { + "volumes": [ + { + "volume": { + "outline_polygon": { + "vertices": [ + { + "lat": 46.9794127188804, + "lng": 7.487045772981162 + }, + { + "lat": 46.9794127188804, + "lng": 7.471958949151656 + }, + { + "lat": 46.97941512651706, + "lng": 7.471909940581491 + }, + { + "lat": 46.9794223262402, + "lng": 7.471861403990648 + }, + { + "lat": 46.979434248712536, + "lng": 7.471813806813029 + }, + { + "lat": 46.979450779114146, + "lng": 7.471767607435473 + }, + { + "lat": 46.97947175824822, + "lng": 7.471723250783243 + }, + { + "lat": 46.979496984074245, + "lng": 7.471681164035146 + }, + { + "lat": 46.97952621365372, + "lng": 7.471641752509574 + }, + { + "lat": 46.9795591654898, + "lng": 7.471605395761062 + }, + { + "lat": 46.97959552223832, + "lng": 7.471572443924974 + }, + { + "lat": 46.97963493376389, + "lng": 7.4715432143455045 + }, + { + "lat": 46.979677020511986, + "lng": 7.471517988519482 + }, + { + "lat": 46.97972137716422, + "lng": 7.4714970093854 + }, + { + "lat": 46.979767576541775, + "lng": 7.47148047898379 + }, + { + "lat": 46.979815173719395, + "lng": 7.4714685565114545 + }, + { + "lat": 46.979863710310234, + "lng": 7.4714613567883195 + }, + { + "lat": 46.9799127188804, + "lng": 7.471458949151656 + }, + { + "lat": 46.986538963424294, + "lng": 7.471458949151656 + }, + { + "lat": 46.98658797199446, + "lng": 7.4714613567883195 + }, + { + "lat": 46.9866365085853, + "lng": 7.4714685565114545 + }, + { + "lat": 46.98668410576292, + "lng": 7.47148047898379 + }, + { + "lat": 46.986730305140476, + "lng": 7.4714970093854 + }, + { + "lat": 46.98677466179271, + "lng": 7.471517988519482 + }, + { + "lat": 46.986816748540804, + "lng": 7.4715432143455045 + }, + { + "lat": 46.986856160066374, + "lng": 7.471572443924974 + }, + { + "lat": 46.98689251681489, + "lng": 7.471605395761062 + }, + { + "lat": 46.98692546865097, + "lng": 7.471641752509574 + }, + { + "lat": 46.98695469823045, + "lng": 7.471681164035146 + }, + { + "lat": 46.98697992405647, + "lng": 7.471723250783243 + }, + { + "lat": 46.98700090319055, + "lng": 7.471767607435473 + }, + { + "lat": 46.98701743359216, + "lng": 7.471813806813029 + }, + { + "lat": 46.9870293560645, + "lng": 7.471861403990648 + }, + { + "lat": 46.98703655578763, + "lng": 7.471909940581491 + }, + { + "lat": 46.9870389634243, + "lng": 7.471958949151656 + }, + { + "lat": 46.9870389634243, + "lng": 7.487045772981162 + }, + { + "lat": 46.98703655578763, + "lng": 7.487094781551327 + }, + { + "lat": 46.9870293560645, + "lng": 7.48714331814217 + }, + { + "lat": 46.98701743359216, + "lng": 7.487190915319789 + }, + { + "lat": 46.98700090319055, + "lng": 7.487237114697344 + }, + { + "lat": 46.98697992405647, + "lng": 7.487281471349575 + }, + { + "lat": 46.98695469823045, + "lng": 7.487323558097672 + }, + { + "lat": 46.98692546865097, + "lng": 7.487362969623244 + }, + { + "lat": 46.98689251681489, + "lng": 7.487399326371755 + }, + { + "lat": 46.986856160066374, + "lng": 7.4874322782078435 + }, + { + "lat": 46.986816748540804, + "lng": 7.487461507787313 + }, + { + "lat": 46.98677466179271, + "lng": 7.487486733613336 + }, + { + "lat": 46.986730305140476, + "lng": 7.487507712747417 + }, + { + "lat": 46.98668410576292, + "lng": 7.487524243149028 + }, + { + "lat": 46.9866365085853, + "lng": 7.487536165621363 + }, + { + "lat": 46.98658797199446, + "lng": 7.487543365344498 + }, + { + "lat": 46.986538963424294, + "lng": 7.487545772981162 + }, + { + "lat": 46.9799127188804, + "lng": 7.487545772981162 + }, + { + "lat": 46.979863710310234, + "lng": 7.487543365344498 + }, + { + "lat": 46.979815173719395, + "lng": 7.487536165621363 + }, + { + "lat": 46.979767576541775, + "lng": 7.487524243149028 + }, + { + "lat": 46.97972137716422, + "lng": 7.487507712747417 + }, + { + "lat": 46.979677020511986, + "lng": 7.487486733613336 + }, + { + "lat": 46.97963493376389, + "lng": 7.487461507787313 + }, + { + "lat": 46.97959552223832, + "lng": 7.4874322782078435 + }, + { + "lat": 46.9795591654898, + "lng": 7.487399326371755 + }, + { + "lat": 46.97952621365372, + "lng": 7.487362969623244 + }, + { + "lat": 46.979496984074245, + "lng": 7.487323558097672 + }, + { + "lat": 46.97947175824822, + "lng": 7.487281471349575 + }, + { + "lat": 46.979450779114146, + "lng": 7.487237114697344 + }, + { + "lat": 46.979434248712536, + "lng": 7.487190915319789 + }, + { + "lat": 46.9794223262402, + "lng": 7.48714331814217 + }, + { + "lat": 46.97941512651706, + "lng": 7.487094781551327 + } + ] + }, + "altitude_lower": { + "value": 50, + "reference": "W84", + "units": "M" + }, + "altitude_upper": { + "value": 120, + "reference": "W84", + "units": "M" + }, + "outline_circle": null + }, + "time_start": { + "format": "RFC3339", + "value": "2025-12-24T01:13:58.428252+00:00" + }, + "time_end": { + "format": "RFC3339", + "value": "2025-12-24T01:17:53.428252+00:00" + } + } + ], + "priority": 0, + "state": "Accepted", + "off_nominal_volumes": [] + }, + "originating_party": "Medicine Delivery Company", + "type_of_operation": 0, + "id": "4f925234-be8e-4480-9ecc-fa87f74843e5", + "state": 2, + "is_approved": true, + "start_datetime": "2025-12-24T01:13:58.428252Z", + "end_datetime": "2025-12-24T01:17:53.428252Z", + "flight_declaration_geojson": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "time_start": "2025-12-24T01:13:58.428252+00:00", + "time_end": "2025-12-24T01:17:53.428252+00:00" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 7.487045772981162, + 46.9794127188804 + ], + [ + 7.487094781551327, + 46.97941512651706 + ], + [ + 7.48714331814217, + 46.9794223262402 + ], + [ + 7.487190915319789, + 46.979434248712536 + ], + [ + 7.487237114697344, + 46.979450779114146 + ], + [ + 7.487281471349575, + 46.97947175824822 + ], + [ + 7.487323558097672, + 46.979496984074245 + ], + [ + 7.487362969623244, + 46.97952621365372 + ], + [ + 7.487399326371755, + 46.9795591654898 + ], + [ + 7.4874322782078435, + 46.97959552223832 + ], + [ + 7.487461507787313, + 46.97963493376389 + ], + [ + 7.487486733613336, + 46.979677020511986 + ], + [ + 7.487507712747417, + 46.97972137716422 + ], + [ + 7.487524243149028, + 46.979767576541775 + ], + [ + 7.487536165621363, + 46.979815173719395 + ], + [ + 7.487543365344498, + 46.979863710310234 + ], + [ + 7.487545772981162, + 46.9799127188804 + ], + [ + 7.487545772981162, + 46.986538963424294 + ], + [ + 7.487543365344498, + 46.98658797199446 + ], + [ + 7.487536165621363, + 46.9866365085853 + ], + [ + 7.487524243149028, + 46.98668410576292 + ], + [ + 7.487507712747417, + 46.986730305140476 + ], + [ + 7.487486733613336, + 46.98677466179271 + ], + [ + 7.487461507787313, + 46.986816748540804 + ], + [ + 7.4874322782078435, + 46.986856160066374 + ], + [ + 7.487399326371755, + 46.98689251681489 + ], + [ + 7.487362969623244, + 46.98692546865097 + ], + [ + 7.487323558097672, + 46.98695469823045 + ], + [ + 7.487281471349575, + 46.98697992405647 + ], + [ + 7.487237114697344, + 46.98700090319055 + ], + [ + 7.487190915319789, + 46.98701743359216 + ], + [ + 7.48714331814217, + 46.9870293560645 + ], + [ + 7.487094781551327, + 46.98703655578763 + ], + [ + 7.487045772981162, + 46.9870389634243 + ], + [ + 7.471958949151656, + 46.9870389634243 + ], + [ + 7.471909940581491, + 46.98703655578763 + ], + [ + 7.471861403990648, + 46.9870293560645 + ], + [ + 7.471813806813029, + 46.98701743359216 + ], + [ + 7.471767607435473, + 46.98700090319055 + ], + [ + 7.471723250783243, + 46.98697992405647 + ], + [ + 7.471681164035146, + 46.98695469823045 + ], + [ + 7.471641752509574, + 46.98692546865097 + ], + [ + 7.471605395761062, + 46.98689251681489 + ], + [ + 7.471572443924974, + 46.986856160066374 + ], + [ + 7.4715432143455045, + 46.986816748540804 + ], + [ + 7.471517988519482, + 46.98677466179271 + ], + [ + 7.4714970093854, + 46.986730305140476 + ], + [ + 7.47148047898379, + 46.98668410576292 + ], + [ + 7.4714685565114545, + 46.9866365085853 + ], + [ + 7.4714613567883195, + 46.98658797199446 + ], + [ + 7.471458949151656, + 46.986538963424294 + ], + [ + 7.471458949151656, + 46.9799127188804 + ], + [ + 7.4714613567883195, + 46.979863710310234 + ], + [ + 7.4714685565114545, + 46.979815173719395 + ], + [ + 7.47148047898379, + 46.979767576541775 + ], + [ + 7.4714970093854, + 46.97972137716422 + ], + [ + 7.471517988519482, + 46.979677020511986 + ], + [ + 7.4715432143455045, + 46.97963493376389 + ], + [ + 7.471572443924974, + 46.97959552223832 + ], + [ + 7.471605395761062, + 46.9795591654898 + ], + [ + 7.471641752509574, + 46.97952621365372 + ], + [ + 7.471681164035146, + 46.979496984074245 + ], + [ + 7.471723250783243, + 46.97947175824822 + ], + [ + 7.471767607435473, + 46.979450779114146 + ], + [ + 7.471813806813029, + 46.979434248712536 + ], + [ + 7.471861403990648, + 46.9794223262402 + ], + [ + 7.471909940581491, + 46.97941512651706 + ], + [ + 7.471958949151656, + 46.9794127188804 + ], + [ + 7.487045772981162, + 46.9794127188804 + ] + ] + ] + } + } + ] + }, + "flight_declaration_raw_geojson": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 7.487045772981162, + 46.9799127188804 + ], + [ + 7.487045772981162, + 46.986538963424294 + ], + [ + 7.471958949151656, + 46.986538963424294 + ], + [ + 7.471958949151656, + 46.9799127188804 + ], + [ + 7.487045772981162, + 46.9799127188804 + ] + ] + ] + }, + "properties": { + "min_altitude": { + "meters": 50, + "datum": "w84" + }, + "max_altitude": { + "meters": 120, + "datum": "w84" + } + } + } + ] + }, + "bounds": "7.4714589,46.9794127,7.4875458,46.9870390", + "approved_by": null, + "submitted_by": null + }, + "error_message": null + } + }, + "measured": { + "width": 316, + "height": 56 + }, + "selected": false, + "dragging": false + }, + { + "id": "dndnode_1", + "type": "custom", + "position": { + "x": 0, + "y": 1080 + }, + "data": { + "label": "Check Operation State Connected", + "operationId": "FlightBlenderClient.check_operation_state_connected", + "className": "FlightBlenderClient", + "functionName": "check_operation_state_connected", + "description": "Check the operation state by polling the API until the expected state is reached.\n\nArgs:\n expected_state: The expected OperationState.\n duration_seconds: Maximum seconds to poll for the state.\n\nReturns:\n The JSON response from the API when the state is reached.\n\nRaises:\n FlightBlenderError: If the expected state is not reached within the timeout.", + "parameters": [ + { + "name": "expected_state", + "type": "OperationState", + "default": 5, + "required": true, + "isEnum": true, + "options": [ + { + "name": "PROCESSING", + "value": 0 + }, + { + "name": "ACCEPTED", + "value": 1 + }, + { + "name": "ACTIVATED", + "value": 2 + }, + { + "name": "NONCONFORMING", + "value": 3 + }, + { + "name": "CONTINGENT", + "value": 4 + }, + { + "name": "ENDED", + "value": 5 + }, + { + "name": "WITHDRAWN", + "value": 6 + }, + { + "name": "CANCELLED", + "value": 7 + }, + { + "name": "REJECTED", + "value": 8 + } + ] + }, + { + "name": "duration_seconds", + "type": "int", + "default": 2, + "required": false + } + ], + "status": "success", + "result": { + "name": "Check Operation State Connected", + "status": "PASS", + "duration": 0.09664797782897949, + "details": { + "operational_intent": { + "volumes": [ + { + "volume": { + "outline_polygon": { + "vertices": [ + { + "lat": 46.9794127188804, + "lng": 7.487045772981162 + }, + { + "lat": 46.9794127188804, + "lng": 7.471958949151656 + }, + { + "lat": 46.97941512651706, + "lng": 7.471909940581491 + }, + { + "lat": 46.9794223262402, + "lng": 7.471861403990648 + }, + { + "lat": 46.979434248712536, + "lng": 7.471813806813029 + }, + { + "lat": 46.979450779114146, + "lng": 7.471767607435473 + }, + { + "lat": 46.97947175824822, + "lng": 7.471723250783243 + }, + { + "lat": 46.979496984074245, + "lng": 7.471681164035146 + }, + { + "lat": 46.97952621365372, + "lng": 7.471641752509574 + }, + { + "lat": 46.9795591654898, + "lng": 7.471605395761062 + }, + { + "lat": 46.97959552223832, + "lng": 7.471572443924974 + }, + { + "lat": 46.97963493376389, + "lng": 7.4715432143455045 + }, + { + "lat": 46.979677020511986, + "lng": 7.471517988519482 + }, + { + "lat": 46.97972137716422, + "lng": 7.4714970093854 + }, + { + "lat": 46.979767576541775, + "lng": 7.47148047898379 + }, + { + "lat": 46.979815173719395, + "lng": 7.4714685565114545 + }, + { + "lat": 46.979863710310234, + "lng": 7.4714613567883195 + }, + { + "lat": 46.9799127188804, + "lng": 7.471458949151656 + }, + { + "lat": 46.986538963424294, + "lng": 7.471458949151656 + }, + { + "lat": 46.98658797199446, + "lng": 7.4714613567883195 + }, + { + "lat": 46.9866365085853, + "lng": 7.4714685565114545 + }, + { + "lat": 46.98668410576292, + "lng": 7.47148047898379 + }, + { + "lat": 46.986730305140476, + "lng": 7.4714970093854 + }, + { + "lat": 46.98677466179271, + "lng": 7.471517988519482 + }, + { + "lat": 46.986816748540804, + "lng": 7.4715432143455045 + }, + { + "lat": 46.986856160066374, + "lng": 7.471572443924974 + }, + { + "lat": 46.98689251681489, + "lng": 7.471605395761062 + }, + { + "lat": 46.98692546865097, + "lng": 7.471641752509574 + }, + { + "lat": 46.98695469823045, + "lng": 7.471681164035146 + }, + { + "lat": 46.98697992405647, + "lng": 7.471723250783243 + }, + { + "lat": 46.98700090319055, + "lng": 7.471767607435473 + }, + { + "lat": 46.98701743359216, + "lng": 7.471813806813029 + }, + { + "lat": 46.9870293560645, + "lng": 7.471861403990648 + }, + { + "lat": 46.98703655578763, + "lng": 7.471909940581491 + }, + { + "lat": 46.9870389634243, + "lng": 7.471958949151656 + }, + { + "lat": 46.9870389634243, + "lng": 7.487045772981162 + }, + { + "lat": 46.98703655578763, + "lng": 7.487094781551327 + }, + { + "lat": 46.9870293560645, + "lng": 7.48714331814217 + }, + { + "lat": 46.98701743359216, + "lng": 7.487190915319789 + }, + { + "lat": 46.98700090319055, + "lng": 7.487237114697344 + }, + { + "lat": 46.98697992405647, + "lng": 7.487281471349575 + }, + { + "lat": 46.98695469823045, + "lng": 7.487323558097672 + }, + { + "lat": 46.98692546865097, + "lng": 7.487362969623244 + }, + { + "lat": 46.98689251681489, + "lng": 7.487399326371755 + }, + { + "lat": 46.986856160066374, + "lng": 7.4874322782078435 + }, + { + "lat": 46.986816748540804, + "lng": 7.487461507787313 + }, + { + "lat": 46.98677466179271, + "lng": 7.487486733613336 + }, + { + "lat": 46.986730305140476, + "lng": 7.487507712747417 + }, + { + "lat": 46.98668410576292, + "lng": 7.487524243149028 + }, + { + "lat": 46.9866365085853, + "lng": 7.487536165621363 + }, + { + "lat": 46.98658797199446, + "lng": 7.487543365344498 + }, + { + "lat": 46.986538963424294, + "lng": 7.487545772981162 + }, + { + "lat": 46.9799127188804, + "lng": 7.487545772981162 + }, + { + "lat": 46.979863710310234, + "lng": 7.487543365344498 + }, + { + "lat": 46.979815173719395, + "lng": 7.487536165621363 + }, + { + "lat": 46.979767576541775, + "lng": 7.487524243149028 + }, + { + "lat": 46.97972137716422, + "lng": 7.487507712747417 + }, + { + "lat": 46.979677020511986, + "lng": 7.487486733613336 + }, + { + "lat": 46.97963493376389, + "lng": 7.487461507787313 + }, + { + "lat": 46.97959552223832, + "lng": 7.4874322782078435 + }, + { + "lat": 46.9795591654898, + "lng": 7.487399326371755 + }, + { + "lat": 46.97952621365372, + "lng": 7.487362969623244 + }, + { + "lat": 46.979496984074245, + "lng": 7.487323558097672 + }, + { + "lat": 46.97947175824822, + "lng": 7.487281471349575 + }, + { + "lat": 46.979450779114146, + "lng": 7.487237114697344 + }, + { + "lat": 46.979434248712536, + "lng": 7.487190915319789 + }, + { + "lat": 46.9794223262402, + "lng": 7.48714331814217 + }, + { + "lat": 46.97941512651706, + "lng": 7.487094781551327 + } + ] + }, + "altitude_lower": { + "value": 50, + "reference": "W84", + "units": "M" + }, + "altitude_upper": { + "value": 120, + "reference": "W84", + "units": "M" + }, + "outline_circle": null + }, + "time_start": { + "format": "RFC3339", + "value": "2025-12-24T01:13:58.428252+00:00" + }, + "time_end": { + "format": "RFC3339", + "value": "2025-12-24T01:17:53.428252+00:00" + } + } + ], + "priority": 0, + "state": "Accepted", + "off_nominal_volumes": [] + }, + "originating_party": "Medicine Delivery Company", + "type_of_operation": 0, + "id": "4f925234-be8e-4480-9ecc-fa87f74843e5", + "state": 5, + "is_approved": true, + "start_datetime": "2025-12-24T01:13:58.428252Z", + "end_datetime": "2025-12-24T01:17:53.428252Z", + "flight_declaration_geojson": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { + "time_start": "2025-12-24T01:13:58.428252+00:00", + "time_end": "2025-12-24T01:17:53.428252+00:00" + }, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 7.487045772981162, + 46.9794127188804 + ], + [ + 7.487094781551327, + 46.97941512651706 + ], + [ + 7.48714331814217, + 46.9794223262402 + ], + [ + 7.487190915319789, + 46.979434248712536 + ], + [ + 7.487237114697344, + 46.979450779114146 + ], + [ + 7.487281471349575, + 46.97947175824822 + ], + [ + 7.487323558097672, + 46.979496984074245 + ], + [ + 7.487362969623244, + 46.97952621365372 + ], + [ + 7.487399326371755, + 46.9795591654898 + ], + [ + 7.4874322782078435, + 46.97959552223832 + ], + [ + 7.487461507787313, + 46.97963493376389 + ], + [ + 7.487486733613336, + 46.979677020511986 + ], + [ + 7.487507712747417, + 46.97972137716422 + ], + [ + 7.487524243149028, + 46.979767576541775 + ], + [ + 7.487536165621363, + 46.979815173719395 + ], + [ + 7.487543365344498, + 46.979863710310234 + ], + [ + 7.487545772981162, + 46.9799127188804 + ], + [ + 7.487545772981162, + 46.986538963424294 + ], + [ + 7.487543365344498, + 46.98658797199446 + ], + [ + 7.487536165621363, + 46.9866365085853 + ], + [ + 7.487524243149028, + 46.98668410576292 + ], + [ + 7.487507712747417, + 46.986730305140476 + ], + [ + 7.487486733613336, + 46.98677466179271 + ], + [ + 7.487461507787313, + 46.986816748540804 + ], + [ + 7.4874322782078435, + 46.986856160066374 + ], + [ + 7.487399326371755, + 46.98689251681489 + ], + [ + 7.487362969623244, + 46.98692546865097 + ], + [ + 7.487323558097672, + 46.98695469823045 + ], + [ + 7.487281471349575, + 46.98697992405647 + ], + [ + 7.487237114697344, + 46.98700090319055 + ], + [ + 7.487190915319789, + 46.98701743359216 + ], + [ + 7.48714331814217, + 46.9870293560645 + ], + [ + 7.487094781551327, + 46.98703655578763 + ], + [ + 7.487045772981162, + 46.9870389634243 + ], + [ + 7.471958949151656, + 46.9870389634243 + ], + [ + 7.471909940581491, + 46.98703655578763 + ], + [ + 7.471861403990648, + 46.9870293560645 + ], + [ + 7.471813806813029, + 46.98701743359216 + ], + [ + 7.471767607435473, + 46.98700090319055 + ], + [ + 7.471723250783243, + 46.98697992405647 + ], + [ + 7.471681164035146, + 46.98695469823045 + ], + [ + 7.471641752509574, + 46.98692546865097 + ], + [ + 7.471605395761062, + 46.98689251681489 + ], + [ + 7.471572443924974, + 46.986856160066374 + ], + [ + 7.4715432143455045, + 46.986816748540804 + ], + [ + 7.471517988519482, + 46.98677466179271 + ], + [ + 7.4714970093854, + 46.986730305140476 + ], + [ + 7.47148047898379, + 46.98668410576292 + ], + [ + 7.4714685565114545, + 46.9866365085853 + ], + [ + 7.4714613567883195, + 46.98658797199446 + ], + [ + 7.471458949151656, + 46.986538963424294 + ], + [ + 7.471458949151656, + 46.9799127188804 + ], + [ + 7.4714613567883195, + 46.979863710310234 + ], + [ + 7.4714685565114545, + 46.979815173719395 + ], + [ + 7.47148047898379, + 46.979767576541775 + ], + [ + 7.4714970093854, + 46.97972137716422 + ], + [ + 7.471517988519482, + 46.979677020511986 + ], + [ + 7.4715432143455045, + 46.97963493376389 + ], + [ + 7.471572443924974, + 46.97959552223832 + ], + [ + 7.471605395761062, + 46.9795591654898 + ], + [ + 7.471641752509574, + 46.97952621365372 + ], + [ + 7.471681164035146, + 46.979496984074245 + ], + [ + 7.471723250783243, + 46.97947175824822 + ], + [ + 7.471767607435473, + 46.979450779114146 + ], + [ + 7.471813806813029, + 46.979434248712536 + ], + [ + 7.471861403990648, + 46.9794223262402 + ], + [ + 7.471909940581491, + 46.97941512651706 + ], + [ + 7.471958949151656, + 46.9794127188804 + ], + [ + 7.487045772981162, + 46.9794127188804 + ] + ] + ] + } + } + ] + }, + "flight_declaration_raw_geojson": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 7.487045772981162, + 46.9799127188804 + ], + [ + 7.487045772981162, + 46.986538963424294 + ], + [ + 7.471958949151656, + 46.986538963424294 + ], + [ + 7.471958949151656, + 46.9799127188804 + ], + [ + 7.487045772981162, + 46.9799127188804 + ] + ] + ] + }, + "properties": { + "min_altitude": { + "meters": 50, + "datum": "w84" + }, + "max_altitude": { + "meters": 120, + "datum": "w84" + } + } + } + ] + }, + "bounds": "7.4714589,46.9794127,7.4875458,46.9870390", + "approved_by": null, + "submitted_by": null + }, + "error_message": null + } + }, + "measured": { + "width": 316, + "height": 56 + }, + "selected": false, + "dragging": false + } + ], + "edges": [ + { + "source": "dndnode_16", + "target": "dndnode_18", + "animated": true, + "style": { + "stroke": "var(--accent-primary)", + "strokeWidth": 1 + }, + "markerEnd": { + "type": "arrowclosed", + "color": "var(--accent-primary)" + }, + "id": "xy-edge__dndnode_16-dndnode_18" + }, + { + "source": "dndnode_18", + "target": "dndnode_17", + "animated": true, + "style": { + "stroke": "var(--accent-primary)", + "strokeWidth": 1 + }, + "markerEnd": { + "type": "arrowclosed", + "color": "var(--accent-primary)" + }, + "id": "xy-edge__dndnode_18-dndnode_17" + }, + { + "source": "dndnode_19", + "target": "dndnode_20", + "animated": true, + "style": { + "stroke": "var(--accent-primary)", + "strokeWidth": 1 + }, + "markerEnd": { + "type": "arrowclosed", + "color": "var(--accent-primary)" + }, + "id": "xy-edge__dndnode_19-dndnode_20" + }, + { + "source": "dndnode_17", + "target": "dndnode_0", + "animated": true, + "style": { + "stroke": "var(--accent-primary)", + "strokeWidth": 1 + }, + "markerEnd": { + "type": "arrowclosed", + "color": "var(--accent-primary)" + }, + "id": "xy-edge__dndnode_17-dndnode_0" + }, + { + "source": "dndnode_0", + "target": "dndnode_19", + "animated": true, + "style": { + "stroke": "var(--accent-primary)", + "strokeWidth": 1 + }, + "markerEnd": { + "type": "arrowclosed", + "color": "var(--accent-primary)" + }, + "id": "xy-edge__dndnode_0-dndnode_19" + }, + { + "source": "dndnode_20", + "target": "dndnode_1", + "animated": true, + "style": { + "stroke": "var(--accent-primary)", + "strokeWidth": 1 + }, + "markerEnd": { + "type": "arrowclosed", + "color": "var(--accent-primary)" + }, + "id": "xy-edge__dndnode_20-dndnode_1" + }, + { + "source": "dndnode_1", + "target": "dndnode_21", + "animated": true, + "style": { + "stroke": "var(--accent-primary)", + "strokeWidth": 1 + }, + "markerEnd": { + "type": "arrowclosed", + "color": "var(--accent-primary)" + }, + "id": "xy-edge__dndnode_1-dndnode_21" + } + ], + "viewport": { + "x": 577.2942305770323, + "y": -361.29404313600276, + "zoom": 1.0434035118685243 + } +} From d57a812db51dd79e98b2a7487c46de2d4850137a Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Sat, 27 Dec 2025 00:03:30 +0100 Subject: [PATCH 05/20] Background tasks, and browser saving, etc --- .vscode/launch.json | 17 + .vscode/settings.json | 9 + pyproject.toml | 11 +- src/openutm_verification/cli/__init__.py | 2 +- src/openutm_verification/core/__init__.py | 4 +- .../core/clients/system/system_client.py | 46 --- .../core/execution/__init__.py | 4 +- .../core/execution/definitions.py | 15 + .../core/execution/dependencies.py | 9 +- .../core/execution/dependency_resolution.py | 45 ++- .../core/execution/execution.py | 17 +- .../core/execution/scenario_runner.py | 137 ++++++-- .../core/reporting/reporting_models.py | 3 + .../scenarios/registry.py | 7 +- .../server/introspection.py | 8 +- src/openutm_verification/server/main.py | 154 +++++---- src/openutm_verification/server/router.py | 28 ++ src/openutm_verification/server/runner.py | 310 ++++++++++-------- tests/test_server_main.py | 59 ++++ web-editor/src/components/ScenarioEditor.tsx | 228 ++++++++----- .../components/ScenarioEditor/BottomPanel.tsx | 170 ++++++++++ .../components/ScenarioEditor/CustomNode.tsx | 33 +- .../ScenarioEditor/PropertiesPanel.tsx | 54 ++- .../components/ScenarioEditor/ResultPanel.tsx | 63 ---- .../src/components/ScenarioEditor/Toolbox.tsx | 15 +- .../__tests__/PropertiesPanel.test.tsx | 1 + .../__tests__/ResultPanel.test.tsx | 60 ---- .../ScenarioEditor/__tests__/Toolbox.test.tsx | 61 ++-- web-editor/src/hooks/useBottomPanelResize.ts | 34 ++ web-editor/src/hooks/useScenarioGraph.ts | 91 +++-- web-editor/src/hooks/useScenarioRunner.ts | 30 +- web-editor/src/styles/BottomPanel.module.css | 130 ++++++++ web-editor/src/styles/EditorLayout.module.css | 19 +- web-editor/src/styles/ResultPanel.module.css | 55 ---- web-editor/src/types/scenario.ts | 9 +- web-editor/vite.config.ts | 2 +- 36 files changed, 1239 insertions(+), 701 deletions(-) delete mode 100644 src/openutm_verification/core/clients/system/system_client.py create mode 100644 src/openutm_verification/core/execution/definitions.py create mode 100644 src/openutm_verification/server/router.py create mode 100644 tests/test_server_main.py create mode 100644 web-editor/src/components/ScenarioEditor/BottomPanel.tsx delete mode 100644 web-editor/src/components/ScenarioEditor/ResultPanel.tsx delete mode 100644 web-editor/src/components/ScenarioEditor/__tests__/ResultPanel.test.tsx create mode 100644 web-editor/src/hooks/useBottomPanelResize.ts create mode 100644 web-editor/src/styles/BottomPanel.module.css delete mode 100644 web-editor/src/styles/ResultPanel.module.css diff --git a/.vscode/launch.json b/.vscode/launch.json index a70bd77..1fa416f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,6 +4,23 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ + { + "name": "Python Debugger: Start Server", + "type": "debugpy", + "request": "launch", + "module": "uvicorn", + "args": [ + "openutm_verification.server.main:app", + "--reload", + "--port", + "8989" + ], + "env": { + "PYTHONPATH": "${workspaceFolder}/src" + }, + "jinja": true, + "justMyCode": true + }, { "name": "Python Debugger: Verify with --debug", "type": "debugpy", diff --git a/.vscode/settings.json b/.vscode/settings.json index 7af9dd2..f991f7f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", "cSpell.words": [ "openutm" ], @@ -11,5 +12,13 @@ "**/__pycache__": true, "**/*.pyc": true, ".venv": true + }, + "[python]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit" + } } } diff --git a/pyproject.toml b/pyproject.toml index 6e869f9..c4866eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,14 +122,9 @@ select = [ "I", # "UP", ] -ignore = [ - "F401", -] -fixable = [ - "I", -] -unfixable = [ -] +ignore = [] +fixable = ["ALL"] +unfixable = [] dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" [tool.ruff.format] diff --git a/src/openutm_verification/cli/__init__.py b/src/openutm_verification/cli/__init__.py index 6184ae2..2a77b27 100644 --- a/src/openutm_verification/cli/__init__.py +++ b/src/openutm_verification/cli/__init__.py @@ -10,8 +10,8 @@ import yaml from openutm_verification.cli.parser import create_parser -from openutm_verification.core import run_verification_scenarios from openutm_verification.core.execution.config_models import AppConfig, ConfigProxy +from openutm_verification.core.execution.execution import run_verification_scenarios from openutm_verification.utils.logging import setup_logging diff --git a/src/openutm_verification/core/__init__.py b/src/openutm_verification/core/__init__.py index 1e6ca90..67ddc78 100644 --- a/src/openutm_verification/core/__init__.py +++ b/src/openutm_verification/core/__init__.py @@ -2,6 +2,4 @@ Core execution logic for the OpenUTM Verification Tool. """ -from openutm_verification.core.execution import run_verification_scenarios - -__all__ = ["run_verification_scenarios"] +__all__ = [] diff --git a/src/openutm_verification/core/clients/system/system_client.py b/src/openutm_verification/core/clients/system/system_client.py deleted file mode 100644 index c8f9439..0000000 --- a/src/openutm_verification/core/clients/system/system_client.py +++ /dev/null @@ -1,46 +0,0 @@ -import asyncio -from typing import Any, Dict - -from loguru import logger - -from openutm_verification.core.execution.scenario_runner import scenario_step - - -class SystemClient: - """A client for system-level operations like task management.""" - - def __init__(self) -> None: - pass - - @scenario_step("Join Background Task") - async def join_task(self, task_id: str | Dict[str, Any]) -> Any: - """Wait for a background task to complete and return its result. - - Args: - task_id: The ID of the background task to join, or the result object from a background step. - - Returns: - The result of the background task. - """ - # This method is a placeholder. The actual implementation logic - # resides in the runner, which intercepts this call or handles - # the task lookup. However, to keep it clean, we can also - # implement it here if we pass the context or runner to the client. - # But since clients are generally stateless regarding the runner's session, - # we'll rely on the runner to inject the result or handle the logic. - # - # actually, the runner executes the step. If we want to await the task here, - # we need access to the task object. - # - # For now, let's assume the runner handles the 'join_task' logic specially - # OR we pass the session context to the client (which is not ideal). - # - # A better approach: The runner sees "SystemClient.join_task" and - # executes special logic. - # - # OR: We make the runner inject the task object into the parameters? - # - # Let's go with the runner handling it for now, but we need this method - # to exist for the decorator and introspection. - logger.info(f"Joining task {task_id}") - return {"status": "joined", "task_id": task_id} diff --git a/src/openutm_verification/core/execution/__init__.py b/src/openutm_verification/core/execution/__init__.py index 05d7d54..67ddc78 100644 --- a/src/openutm_verification/core/execution/__init__.py +++ b/src/openutm_verification/core/execution/__init__.py @@ -2,6 +2,4 @@ Core execution logic for the OpenUTM Verification Tool. """ -from openutm_verification.core.execution.execution import run_verification_scenarios - -__all__ = ["run_verification_scenarios"] +__all__ = [] diff --git a/src/openutm_verification/core/execution/definitions.py b/src/openutm_verification/core/execution/definitions.py new file mode 100644 index 0000000..043edfe --- /dev/null +++ b/src/openutm_verification/core/execution/definitions.py @@ -0,0 +1,15 @@ +from typing import Any, Dict, List +from uuid import uuid4 + +from pydantic import BaseModel, Field + + +class StepDefinition(BaseModel): + id: str = Field(default_factory=lambda: uuid4().hex) + name: str + parameters: Dict[str, Any] + run_in_background: bool = False + + +class ScenarioDefinition(BaseModel): + steps: List[StepDefinition] diff --git a/src/openutm_verification/core/execution/dependencies.py b/src/openutm_verification/core/execution/dependencies.py index a2ab43a..2a9b0a8 100644 --- a/src/openutm_verification/core/execution/dependencies.py +++ b/src/openutm_verification/core/execution/dependencies.py @@ -25,7 +25,6 @@ create_opensky_settings, ) from openutm_verification.core.clients.opensky.opensky_client import OpenSkyClient -from openutm_verification.core.clients.system.system_client import SystemClient from openutm_verification.core.execution.config_models import ( AppConfig, DataFiles, @@ -38,6 +37,7 @@ ) from openutm_verification.core.reporting.reporting_models import ScenarioResult from openutm_verification.scenarios.registry import SCENARIO_REGISTRY +from openutm_verification.server.runner import SessionManager T = TypeVar("T") @@ -199,7 +199,6 @@ async def air_traffic_client( yield air_traffic_client -@dependency(SystemClient) -async def system_client() -> AsyncGenerator[SystemClient, None]: - """Provides a SystemClient instance for dependency injection.""" - yield SystemClient() +@dependency(SessionManager) +async def session_manager() -> AsyncGenerator[SessionManager, None]: + yield SessionManager() diff --git a/src/openutm_verification/core/execution/dependency_resolution.py b/src/openutm_verification/core/execution/dependency_resolution.py index d1736d1..96727ba 100644 --- a/src/openutm_verification/core/execution/dependency_resolution.py +++ b/src/openutm_verification/core/execution/dependency_resolution.py @@ -29,21 +29,6 @@ def wrapper(func: Callable[..., Generator | AsyncGenerator]) -> Callable[..., Ge return wrapper -async def call_with_dependencies(func: Callable[..., Coroutine[Any, Any, T]]) -> T: - """Call a function with its dependencies automatically provided. - - Args: - func: The function to call. - Returns: - The result of the function call. - """ - sig = inspect.signature(func) - async with provide(*(p.annotation for p in sig.parameters.values())) as dependencies: - if inspect.iscoroutinefunction(func): - return await func(*dependencies) - raise ValueError(f"Function {func.__name__} must be async") - - class DependencyResolver: """Resolves dependencies using a provided ExitStack.""" @@ -100,3 +85,33 @@ async def provide(*types: object) -> AsyncGenerator[tuple[object, ...], None]: for t in types: instances.append(await resolver.resolve(t)) yield tuple(instances) + + +async def call_with_dependencies(func: Callable[..., Coroutine[Any, Any, T]], resolver: DependencyResolver | None = None, **kwargs: Any) -> T: + """Call a function with its dependencies automatically provided. + + Args: + func: The function to call. + resolver: Optional DependencyResolver to use. If None, a new one is created. + **kwargs: Additional arguments to pass to the function. + Returns: + The result of the function call. + """ + if resolver: + sig = inspect.signature(func) + call_kwargs = kwargs.copy() + + for name, param in sig.parameters.items(): + if name in call_kwargs: + continue + + if param.annotation in DEPENDENCIES: + call_kwargs[name] = await resolver.resolve(param.annotation) + + if inspect.iscoroutinefunction(func): + return await func(**call_kwargs) + raise ValueError(f"Function {func.__name__} must be async") + else: + async with AsyncExitStack() as stack: + temp_resolver = DependencyResolver(stack) + return await call_with_dependencies(func, resolver=temp_resolver, **kwargs) diff --git a/src/openutm_verification/core/execution/execution.py b/src/openutm_verification/core/execution/execution.py index bd93ed3..95ebfad 100644 --- a/src/openutm_verification/core/execution/execution.py +++ b/src/openutm_verification/core/execution/execution.py @@ -19,7 +19,7 @@ ) 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, call_with_dependencies +from openutm_verification.core.execution.dependency_resolution import CONTEXT from openutm_verification.core.reporting.reporting import generate_reports from openutm_verification.core.reporting.reporting_models import ( ReportData, @@ -59,6 +59,9 @@ async def run_verification_scenarios(config: AppConfig, config_path: Path): """ Executes the verification scenarios based on the provided configuration. """ + + from openutm_verification.server.runner import SessionManager + run_timestamp = datetime.now(timezone.utc) start_time_utc = run_timestamp.isoformat() start_time_obj = run_timestamp @@ -70,10 +73,17 @@ async def run_verification_scenarios(config: AppConfig, config_path: Path): sanitized_config_dict = _sanitize_config(config.model_dump()) logger.debug(f"Configuration details:\n{json.dumps(sanitized_config_dict, indent=2)}") + # Initialize SessionManager + session_manager = SessionManager(config_path=str(config_path)) + scenario_results = [] for scenario_id, scenario_func in scenarios(): try: - result = await call_with_dependencies(scenario_func) + # Initialize session with the current context + await session_manager.initialize_session() + + # Execute the scenario function using the session manager + result = await session_manager.execute_function(scenario_func) except (AirTrafficError, OpenSkyError, ValidationError) as e: logger.error(f"Failed to run scenario '{scenario_id}': {e}") result = ScenarioResult( @@ -84,6 +94,9 @@ async def run_verification_scenarios(config: AppConfig, config_path: Path): error_message=str(e), docs=None, ) + finally: + # Ensure session is closed after each scenario to clean up resources + await session_manager.close_session() # Enrich result with context data context_data = CONTEXT.get() diff --git a/src/openutm_verification/core/execution/scenario_runner.py b/src/openutm_verification/core/execution/scenario_runner.py index 9b958c7..5e11d5c 100644 --- a/src/openutm_verification/core/execution/scenario_runner.py +++ b/src/openutm_verification/core/execution/scenario_runner.py @@ -1,6 +1,7 @@ import contextvars import inspect import time +import uuid from dataclasses import dataclass, field from functools import wraps from pathlib import Path @@ -10,17 +11,16 @@ Callable, Coroutine, ParamSpec, - Protocol, TypedDict, TypeVar, - cast, - overload, ) from loguru import logger +from pydantic import BaseModel, ConfigDict, Field, create_model from uas_standards.astm.f3411.v22a.api import RIDAircraftState from openutm_verification.core.clients.opensky.base_client import OpenSkyError +from openutm_verification.core.execution.dependency_resolution import DEPENDENCIES from openutm_verification.core.reporting.reporting_models import ( ScenarioResult, Status, @@ -40,6 +40,13 @@ R = TypeVar("R", bound=StepResult[Any]) +@dataclass +class StepRegistryEntry: + client_class: type + method_name: str + param_model: type[BaseModel] + + @dataclass class ScenarioState: steps: list[StepResult[Any]] = field(default_factory=list) @@ -49,22 +56,39 @@ class ScenarioState: telemetry_data: list[RIDAircraftState] | None = None air_traffic_data: list[list[FlightObservationSchema]] = field(default_factory=list) + @property + def step_results(self) -> dict[str, StepResult[Any]]: + """ + Returns a dictionary mapping step IDs to their result details. + Only includes steps that have an ID. + """ + return {step.id: step for step in self.steps if step.id} + class ScenarioRegistry(TypedDict): func: Callable[..., Coroutine[Any, Any, ScenarioResult]] docs: Path | None +class RefModel(BaseModel, serialize_by_alias=True): + ref: str = Field(..., alias="$ref") + + _scenario_state: contextvars.ContextVar[ScenarioState | None] = contextvars.ContextVar("scenario_state", default=None) +STEP_REGISTRY: dict[str, StepRegistryEntry] = {} + class ScenarioContext: - def __init__(self): + def __init__(self, state: ScenarioState | None = None): self._token = None - self._state: ScenarioState | None = None + self._state: ScenarioState | None = state def __enter__(self): - self._state = ScenarioState(active=True) + if self._state is None: + self._state = ScenarioState(active=True) + else: + self._state.active = True self._token = _scenario_state.set(self._state) return self @@ -78,6 +102,8 @@ 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]) state.steps.append(result) @classmethod @@ -104,6 +130,10 @@ def add_air_traffic_data(cls, data: list[FlightObservationSchema]) -> None: if state and state.active: state.air_traffic_data.append(data) + @property + def state(self) -> ScenarioState | None: + return self._state + @property def steps(self) -> list[StepResult[Any]]: if self._state: @@ -142,24 +172,47 @@ def air_traffic_data(self) -> list[list[FlightObservationSchema]]: return state.air_traffic_data if state else [] -class StepDecorator(Protocol): - @overload - def __call__(self, func: Callable[P, Awaitable[R]]) -> Callable[P, Coroutine[Any, Any, R]]: ... +class ScenarioStepDescriptor: + def __init__(self, func: Callable[..., Awaitable[Any]], step_name: str): + self.func = func + self.step_name = step_name + self.wrapper = self._create_wrapper(func, step_name) + self.param_model = self._create_param_model(func, step_name) + + def _create_param_model(self, func: Callable[..., Any], step_name: str) -> type[BaseModel]: + sig = inspect.signature(func) + fields = {} - @overload - def __call__(self, func: Callable[P, Awaitable[T]]) -> Callable[P, Coroutine[Any, Any, StepResult[T]]]: ... + for param_name, param in sig.parameters.items(): + if param_name == "self": + continue - def __call__(self, func: Callable[P, Awaitable[Any]]) -> Callable[P, Coroutine[Any, Any, Any]]: ... + # Skip dependencies that are automatically injected + if param.annotation in DEPENDENCIES: + continue + annotation = param.annotation + if annotation == inspect.Parameter.empty: + annotation = Any -def scenario_step(step_name: str) -> StepDecorator: - def decorator( - func: Callable[P, Awaitable[Any]], - ) -> Callable[P, Coroutine[Any, Any, Any]]: + default = param.default + if default == inspect.Parameter.empty: + fields[param_name] = (annotation, ...) + else: + fields[param_name] = (annotation, default) + + return create_model( # type: ignore[call-overload] + f"Params_{step_name}", + __config__=ConfigDict(arbitrary_types_allowed=True), + **fields, + ) + + def _create_wrapper(self, func: Callable[..., Awaitable[Any]], step_name: str) -> Callable[..., Awaitable[Any]]: def handle_result(result: Any, start_time: float) -> StepResult[Any]: duration = time.time() - start_time logger.info(f"Step '{step_name}' successful in {duration:.2f} seconds.") + step_result: StepResult[Any] if isinstance(result, StepResult): step_result = result else: @@ -175,6 +228,7 @@ def handle_result(result: Any, start_time: float) -> StepResult[Any]: def handle_exception(e: Exception, start_time: float) -> StepResult[Any]: duration = time.time() - start_time + step_result: StepResult[Any] if isinstance(e, (FlightBlenderError, OpenSkyError)): logger.error(f"Step '{step_name}' failed after {duration:.2f} seconds: {e}") step_result = StepResult( @@ -198,15 +252,32 @@ def handle_exception(e: Exception, start_time: float) -> StepResult[Any]: raise ValueError(f"Step function {func.__name__} must be async") @wraps(func) - async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> StepResult[Any]: - logger.info("-" * 50) - logger.info(f"Executing step: '{step_name}'...") - start_time = time.time() + async def async_wrapper(*args: Any, **kwargs: Any) -> StepResult[Any]: + step_execution_id = uuid.uuid4().hex + captured_logs: list[str] = [] + + def log_filter(record): + return record["extra"].get("step_execution_id") == step_execution_id + + handler_id = logger.add(lambda msg: captured_logs.append(msg), filter=log_filter, format="{time:HH:mm:ss} | {level} | {message}") + + step_result: StepResult[Any] | None = None try: - result = await func(*args, **kwargs) - return handle_result(result, start_time) - except Exception as e: - return handle_exception(e, start_time) + with logger.contextualize(step_execution_id=step_execution_id): + logger.info("-" * 50) + logger.info(f"Executing step: '{step_name}'...") + start_time = time.time() + try: + result = await func(*args, **kwargs) + step_result = handle_result(result, start_time) + except Exception as e: + step_result = handle_exception(e, start_time) + finally: + logger.remove(handler_id) + if step_result: + step_result.logs = captured_logs + + return step_result # Attach metadata for introspection setattr(async_wrapper, "_is_scenario_step", True) @@ -214,4 +285,20 @@ async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> StepResult[Any]: return async_wrapper - return cast(StepDecorator, decorator) + def __set_name__(self, owner: type, name: str): + STEP_REGISTRY[self.step_name] = StepRegistryEntry( + client_class=owner, + method_name=name, + param_model=self.param_model, + ) + setattr(owner, name, self.wrapper) + + def __call__(self, *args: Any, **kwargs: Any): + return self.wrapper(*args, **kwargs) + + +def scenario_step(step_name: str) -> Callable[[Callable[..., Awaitable[Any]]], Any]: + def decorator(func: Callable[..., Awaitable[Any]]) -> Any: + return ScenarioStepDescriptor(func, step_name) + + return decorator diff --git a/src/openutm_verification/core/reporting/reporting_models.py b/src/openutm_verification/core/reporting/reporting_models.py index 85237e4..ca0966e 100644 --- a/src/openutm_verification/core/reporting/reporting_models.py +++ b/src/openutm_verification/core/reporting/reporting_models.py @@ -23,6 +23,7 @@ class Status(StrEnum): PASS = "PASS" FAIL = "FAIL" + RUNNING = "RUNNING" T = TypeVar("T") @@ -31,11 +32,13 @@ class Status(StrEnum): class StepResult(BaseModel, Generic[T]): """Data model for a single step within a scenario.""" + id: str | None = None name: str status: Status duration: float details: T = None # type: ignore error_message: str | None = None + logs: list[str] = [] class ScenarioResult(BaseModel): diff --git a/src/openutm_verification/scenarios/registry.py b/src/openutm_verification/scenarios/registry.py index 42b1b71..1b0ff4d 100644 --- a/src/openutm_verification/scenarios/registry.py +++ b/src/openutm_verification/scenarios/registry.py @@ -22,6 +22,7 @@ def run_my_scenario(client, scenario_id): from openutm_verification.core.execution.scenario_runner import ( ScenarioContext, ScenarioRegistry, + _scenario_state, ) from openutm_verification.core.reporting.reporting_models import ( ScenarioResult, @@ -37,7 +38,11 @@ def run_my_scenario(client, scenario_id): async def _run_scenario_simple_async(scenario_id: str, func: Callable, args, kwargs) -> ScenarioResult: """Runs a scenario without auto-setup (async).""" try: - with ScenarioContext() as ctx: + # Reuse existing state if available (e.g. from SessionManager) + current_state = _scenario_state.get() + ctx_manager = ScenarioContext(state=current_state) if current_state else ScenarioContext() + + with ctx_manager as ctx: result = await func(*args, **kwargs) if isinstance(result, ScenarioResult): diff --git a/src/openutm_verification/server/introspection.py b/src/openutm_verification/server/introspection.py index cf7d6f6..0f89fe5 100644 --- a/src/openutm_verification/server/introspection.py +++ b/src/openutm_verification/server/introspection.py @@ -63,7 +63,7 @@ def process_parameter(param_name: str, param: inspect.Parameter) -> Dict[str, An return param_info -def process_method(class_name: str, client_class: Type, name: str, method: Any) -> Dict[str, Any] | None: +def process_method(client_class: Type, method: Any) -> Dict[str, Any] | None: """ Extracts metadata from a client method if it's a scenario step. """ @@ -83,11 +83,9 @@ def process_method(class_name: str, client_class: Type, name: str, method: Any) parameters.append(param_info) return { - "id": f"{class_name}.{name}", + "id": step_name, "name": step_name, - "functionName": name, - "className": class_name, + "category": client_class.__name__, "description": inspect.getdoc(method) or "", "parameters": parameters, - "filePath": inspect.getfile(client_class), } diff --git a/src/openutm_verification/server/main.py b/src/openutm_verification/server/main.py index d0e4353..523e02b 100644 --- a/src/openutm_verification/server/main.py +++ b/src/openutm_verification/server/main.py @@ -1,14 +1,31 @@ -import inspect -from typing import Any, Dict, List, Optional, Union +import os +import shutil +import subprocess +from contextlib import asynccontextmanager -from fastapi import Body, FastAPI, HTTPException +from fastapi import Depends, FastAPI, Request from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel, ConfigDict, create_model +from fastapi.staticfiles import StaticFiles +from loguru import logger -from openutm_verification.core.execution.dependency_resolution import DEPENDENCIES -from openutm_verification.server.runner import DynamicRunner, ScenarioDefinition, StepDefinition +# Import dependencies to ensure they are registered and steps are populated +import openutm_verification.core.execution.dependencies # noqa: F401 +from openutm_verification.core.execution.definitions import ScenarioDefinition +from openutm_verification.server.router import scenario_router +from openutm_verification.server.runner import SessionManager -app = FastAPI() + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + session_manager = SessionManager() + app.state.runner = session_manager + yield + # Shutdown + await session_manager.close_session() + + +app = FastAPI(lifespan=lifespan) # Configure CORS app.add_middleware( @@ -19,11 +36,15 @@ allow_headers=["*"], ) -runner = DynamicRunner() +app.include_router(scenario_router) + +def get_session_manager(request: Request) -> SessionManager: + return request.app.state.runner -@app.get("/") -async def root(): + +@app.get("/api/info") +async def api_info(): return {"message": "OpenUTM Verification API is running"} @@ -33,78 +54,71 @@ async def health_check(): @app.get("/operations") -async def get_operations(): +async def get_operations(runner: SessionManager = Depends(get_session_manager)): return runner.get_available_operations() @app.post("/session/reset") -async def reset_session(): +async def reset_session(runner: SessionManager = Depends(get_session_manager)): await runner.close_session() await runner.initialize_session() return {"status": "session_reset"} @app.post("/run-scenario") -async def run_scenario(scenario: ScenarioDefinition): - # For full scenario run, we might want a fresh runner or use the session one? - # The original code created a new runner. Let's keep it that way for now, - # or use the global one but reset session. - # But DynamicRunner() creates a new instance. - local_runner = DynamicRunner() +async def run_scenario(scenario: ScenarioDefinition, runner: SessionManager = Depends(get_session_manager)): + return await runner.run_scenario(scenario) + + +# Mount static files for web-editor +# Calculate path relative to this file +# src/openutm_verification/server/main.py -> ../../../web-editor/dist +web_editor_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../web-editor")) +static_dir = os.path.join(web_editor_dir, "dist") + + +def build_frontend(): + """Attempt to build the web-editor frontend using npm.""" + if not os.path.exists(web_editor_dir): + logger.warning(f"Web editor directory not found at {web_editor_dir}") + return + + npm_cmd = shutil.which("npm") + if not npm_cmd: + logger.warning("npm not found. Skipping web editor build.") + return + try: - results = await local_runner.run_scenario(scenario) - return {"status": "completed", "results": results} + logger.info("Building web editor frontend... This may take a while.") + # Run npm install + logger.info("Running 'npm install'...") + subprocess.run([npm_cmd, "install"], cwd=web_editor_dir, check=True) + + # Run npm run build + logger.info("Running 'npm run build'...") + subprocess.run([npm_cmd, "run", "build"], cwd=web_editor_dir, check=True) + + logger.info("Web editor built successfully.") + except subprocess.CalledProcessError as e: + logger.error(f"Failed to build web editor: {e}") except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -# Dynamic Route Generation -for class_name, client_class in runner.client_map.items(): - for name, method in inspect.getmembers(client_class): - if hasattr(method, "_is_scenario_step"): - step_name = getattr(method, "_step_name") - - # Create Pydantic model for parameters - sig = inspect.signature(method) - fields = {} - for param_name, param in sig.parameters.items(): - if param_name == "self": - continue - - if param.annotation in DEPENDENCIES: - continue - - annotation = param.annotation - if annotation == inspect.Parameter.empty: - annotation = Any - else: - # Allow Dict for references (e.g. {"$ref": "..."}) - annotation = Union[annotation, Dict[str, Any]] - - default = param.default - if default == inspect.Parameter.empty: - fields[param_name] = (annotation, ...) - else: - fields[param_name] = (annotation, default) - - # Create the model - RequestModel = create_model(f"{class_name}_{name}_Request", __config__=ConfigDict(arbitrary_types_allowed=True), **fields) - - # Define the route handler - # We need to capture class_name, name and RequestModel in the closure - def create_handler(req_model, c_name, f_name): - async def handler(body: req_model, run_in_background: bool = False, step_id: Optional[str] = None): - step_def = StepDefinition( - id=step_id, className=c_name, functionName=f_name, parameters=body.model_dump(), run_in_background=run_in_background - ) - return await runner.execute_single_step(step_def) - - return handler - - handler = create_handler(RequestModel, class_name, name) - - # Register the route - app.post(f"/api/{class_name}/{name}", response_model=Dict[str, Any], tags=[class_name], summary=step_name)(handler) + logger.error(f"An unexpected error occurred during web editor build: {e}") + + +if not os.path.exists(static_dir): + build_frontend() + +if os.path.exists(static_dir): + app.mount("/", StaticFiles(directory=static_dir, html=True), name="static") +else: + + @app.get("/") + async def root(): + return { + "message": "OpenUTM Verification API is running.", + "hint": "To use the web editor, run 'npm run build' in the web-editor directory. Automatic build failed or npm was not found.", + } + if __name__ == "__main__": import uvicorn diff --git a/src/openutm_verification/server/router.py b/src/openutm_verification/server/router.py new file mode 100644 index 0000000..52446b1 --- /dev/null +++ b/src/openutm_verification/server/router.py @@ -0,0 +1,28 @@ +from typing import Any, Type, TypeVar + +from fastapi import APIRouter, Depends, Request + +from openutm_verification.core.execution.definitions import StepDefinition + +T = TypeVar("T") + +scenario_router = APIRouter() + + +def get_runner(request: Request) -> Any: + return request.app.state.runner + + +def get_dependency(dep_type: Type[T]): + async def dependency(runner: Any = Depends(get_runner)) -> T: + # Ensure session is initialized + if not runner.session_resolver: + await runner.initialize_session() + return await runner.session_resolver.resolve(dep_type) + + return dependency + + +@scenario_router.post("/api/step") +async def execute_step(step: StepDefinition, runner: Any = Depends(get_runner)): + return await runner.execute_single_step(step) diff --git a/src/openutm_verification/server/runner.py b/src/openutm_verification/server/runner.py index 965f1b5..716386c 100644 --- a/src/openutm_verification/server/runner.py +++ b/src/openutm_verification/server/runner.py @@ -1,44 +1,36 @@ -import inspect -import json -import re +import asyncio from contextlib import AsyncExitStack -from enum import Enum from pathlib import Path -from typing import Any, Dict, List, Type, cast +from typing import Any, Callable, Coroutine, Dict, List, Type, TypeVar, cast import yaml from loguru import logger from pydantic import BaseModel -# Import dependencies to ensure decorators run -import openutm_verification.core.execution.dependencies # noqa: F401 -from openutm_verification.core.clients.air_traffic.air_traffic_client import AirTrafficClient -from openutm_verification.core.clients.flight_blender.flight_blender_client import FlightBlenderClient -from openutm_verification.core.clients.opensky.opensky_client import OpenSkyClient -from openutm_verification.core.clients.system.system_client import SystemClient from openutm_verification.core.execution.config_models import AppConfig, ConfigProxy, DataFiles -from openutm_verification.core.execution.dependency_resolution import CONTEXT, DEPENDENCIES, DependencyResolver -from openutm_verification.core.execution.scenario_runner import ScenarioState, _scenario_state +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, scenario_step from openutm_verification.core.reporting.reporting_models import Status, StepResult -from openutm_verification.models import OperationState from openutm_verification.scenarios.common import generate_flight_declaration, generate_telemetry from openutm_verification.server.introspection import process_method +T = TypeVar("T") -class StepDefinition(BaseModel): - id: str | None = None - className: str - functionName: str - parameters: Dict[str, Any] - run_in_background: bool = False +class SessionManager: + _instance = None + _initialized: bool -class ScenarioDefinition(BaseModel): - steps: List[StepDefinition] + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance - -class DynamicRunner: def __init__(self, config_path: str = "config/default.yaml"): + if hasattr(self, "_initialized") and self._initialized: + return + self.config_path = Path(config_path) self.config = self._load_config() self.client_map: Dict[str, Type] = {} @@ -48,7 +40,25 @@ def __init__(self, config_path: str = "config/default.yaml"): self.session_stack: AsyncExitStack | None = None self.session_resolver: DependencyResolver | None = None - self.session_context: Dict[str, Any] = {} + self.session_context: ScenarioContext | None = None + self.session_tasks: Dict[str, asyncio.Task] = {} + self._initialized = True + + @scenario_step("Join Background Task") + async def join_task(self, task_id: str) -> Any: + """Wait for a background task to complete and return its result. + + Args: + task_id: The ID of the background task to join. + Returns: + The result of the background task. + """ + if task_id not in self.session_tasks: + raise ValueError(f"Task ID '{task_id}' not found in session tasks") + + task = self.session_tasks[task_id] + result = await task + return result async def initialize_session(self): logger.info("Initializing new session") @@ -58,7 +68,6 @@ async def initialize_session(self): self.session_stack = AsyncExitStack() self.session_resolver = DependencyResolver(self.session_stack) - # Set up context for dependencies # We use a default context so dependencies like DataFiles can be resolved suite_name = next(iter(self.config.suites.keys()), "default") @@ -76,15 +85,13 @@ async def initialize_session(self): data_files = cast(DataFiles, await self.session_resolver.resolve(DataFiles)) flight_declaration, telemetry_states = self._generate_data(data_files) - scenario_state = ScenarioState(active=True, flight_declaration_data=flight_declaration, telemetry_data=telemetry_states) + self.session_context = ScenarioContext() + with self.session_context: + if flight_declaration: + ScenarioContext.set_flight_declaration_data(flight_declaration) + if telemetry_states: + ScenarioContext.set_telemetry_data(telemetry_states) - self.session_context = { - "operation_id": None, - "flight_declaration": flight_declaration, - "telemetry_states": telemetry_states, - "step_results": {}, - "scenario_state": scenario_state, - } except Exception as e: logger.error(f"Data generation failed: {e}") @@ -94,41 +101,23 @@ async def close_session(self): await self.session_stack.aclose() self.session_stack = None self.session_resolver = None - self.session_context = {} - - async def execute_single_step(self, step: StepDefinition) -> Dict[str, Any]: - if not self.session_resolver: - logger.info("Session resolver not found, initializing session") - await self.initialize_session() - - assert self.session_resolver is not None - - # Set scenario state context for this execution - token = None - if "scenario_state" in self.session_context: - token = _scenario_state.set(self.session_context["scenario_state"]) - - try: - return await self._execute_step(step, self.session_resolver, self.session_context) - except Exception as e: - logger.error(f"Error executing step {step.functionName}: {e}") - return {"step": f"{step.className}.{step.functionName}", "status": "error", "error": str(e)} - finally: - if token: - _scenario_state.reset(token) + self.session_context = None + _scenario_state.set(None) - def _resolve_ref(self, ref: str, context: Dict[str, Any]) -> Any: + def _resolve_ref(self, ref: str) -> Any: # ref format: "step_id.field.subfield" or just "step_id" parts = ref.split(".") step_id = parts[0] - if "step_results" not in context: - raise ValueError("No step results available for reference resolution") + if not self.session_context or not self.session_context.state: + raise ValueError("No active scenario context or state available") + + state = self.session_context.state - if step_id not in context["step_results"]: + if step_id not in state.step_results: raise ValueError(f"Referenced step '{step_id}' not found in results") - current_value = context["step_results"][step_id] + current_value = state.step_results[step_id] # Traverse the rest of the path for part in parts[1:]: @@ -146,99 +135,23 @@ def _resolve_ref(self, ref: str, context: Dict[str, Any]) -> Any: return current_value - def _resolve_references_in_params(self, params: Dict[str, Any], context: Dict[str, Any]) -> None: + def resolve_references_in_params(self, params: Dict[str, Any]) -> None: for key, value in params.items(): if isinstance(value, dict) and "$ref" in value: try: - params[key] = self._resolve_ref(value["$ref"], context) + params[key] = self._resolve_ref(value["$ref"]) logger.info(f"Resolved reference {value['$ref']} to {params[key]}") except Exception as e: logger.error(f"Failed to resolve reference {value['$ref']}: {e}") raise - def _prepare_params(self, step: StepDefinition, context: Dict[str, Any]) -> Dict[str, Any]: - params = step.parameters.copy() - - # Resolve references - self._resolve_references_in_params(params, context) - - # Special handling for submit_telemetry - if step.functionName == "submit_telemetry" and "states" not in params: - params["states"] = context["telemetry_states"] - - # Special handling for update_operation_state - if step.functionName == "update_operation_state" and "new_state" in params: - if isinstance(params["new_state"], int): - try: - params["new_state"] = OperationState(params["new_state"]) - except ValueError: - pass - - return params - - def _serialize_result(self, result: Any) -> Any: - if hasattr(result, "to_dict"): - return getattr(result, "to_dict")() - elif isinstance(result, BaseModel): - return result.model_dump() - else: - return result - - def _determine_status(self, result: Any) -> str: - if hasattr(result, "status"): - status_val = getattr(result, "status") - if status_val == Status.FAIL: - return "failure" - elif status_val == Status.PASS: - return "success" - return "success" - - async def _execute_step(self, step: StepDefinition, resolver: DependencyResolver, context: Dict[str, Any]) -> Dict[str, Any]: - client_class = self.client_map[step.className] - client = await resolver.resolve(client_class) - - method = getattr(client, step.functionName) - - # Prepare parameters (resolve refs, inject context) - kwargs = self._prepare_params(step, context) - - # Inject dependencies if missing - sig = inspect.signature(method) - for name, param in sig.parameters.items(): - if name == "self" or name in kwargs: - continue - - if param.annotation in DEPENDENCIES: - kwargs[name] = await resolver.resolve(param.annotation) - - result = await method(**kwargs) - - # Serialize result if it's an object - result_data = self._serialize_result(result) - - # Store result for linking - if step.id: - if "step_results" not in context: - context["step_results"] = {} - context["step_results"][step.id] = result - - # Determine overall status based on result content - status_str = self._determine_status(result) - - # Add to scenario state if not already added by decorator - # The decorator adds it, but if the method wasn't decorated, we might want to add it here? - # Most client methods are decorated. If we add it again, we might duplicate. - # Let's assume the decorator handles it if present. - - return {"id": step.id, "step": f"{step.className}.{step.functionName}", "status": status_str, "result": result_data} - def get_available_operations(self) -> List[Dict[str, Any]]: operations = [] - for class_name, client_class in self.client_map.items(): - for name, method in inspect.getmembers(client_class): - op_info = process_method(class_name, client_class, name, method) - if op_info: - operations.append(op_info) + for entry in STEP_REGISTRY.values(): + method = getattr(entry.client_class, entry.method_name) + op_info = process_method(entry.client_class, method) + if op_info: + operations.append(op_info) return operations def _load_config(self) -> AppConfig: @@ -282,6 +195,103 @@ def _generate_data(self, data_files: DataFiles): return flight_declaration, telemetry_states + def validate_params(self, params: Dict[str, Any], step_name: str) -> None: + if step_name not in STEP_REGISTRY: + raise ValueError(f"Step '{step_name}' not found in registry") + + entry = STEP_REGISTRY[step_name] + DynamicModel = entry.param_model + + # Create a dynamic Pydantic model for validation + try: + validated_data = DynamicModel(**params) + # Update params with validated data (coerced types, defaults) + params.update(validated_data.model_dump()) + except Exception as e: + logger.error(f"Validation error for step '{step_name}': {e}") + raise ValueError(f"Invalid parameters for step '{step_name}': {e}") + + def _prepare_params(self, step: StepDefinition) -> Dict[str, Any]: + params = step.parameters.copy() + + # Resolve references + self.resolve_references_in_params(params) + self.validate_params(params, step.name) + + return params + + def _serialize_result(self, result: Any) -> Any: + if isinstance(result, BaseModel): + return result.model_dump() + else: + return result + + def _determine_status(self, result: Any) -> str: + if hasattr(result, "status"): + status_val = getattr(result, "status") + if status_val == Status.FAIL: + return "failure" + elif status_val == Status.PASS: + return "success" + return "success" + + async def _execute_step(self, step: StepDefinition) -> Dict[str, Any]: + assert self.session_resolver is not None and self.session_context is not None + if step.name not in STEP_REGISTRY: + raise ValueError(f"Step '{step.name}' not found in registry") + + entry = STEP_REGISTRY[step.name] + client = await self.session_resolver.resolve(entry.client_class) + + method = getattr(client, entry.method_name) + + # Prepare parameters (resolve refs, inject context) + kwargs = self._prepare_params(step) + + if step.run_in_background: + logger.info(f"Executing step '{step.name}' in background") + task = asyncio.create_task(call_with_dependencies(method, resolver=self.session_resolver, **kwargs)) + self.session_tasks[step.id] = task + self.session_context.add_result(StepResult(id=step.id, name=step.name, status=Status.RUNNING, details={"task_id": step.id}, duration=0.0)) + return {"id": step.id, "step": step.name, "status": "running", "task_id": step.id} + # Execute with dependencies + result = await call_with_dependencies(method, resolver=self.session_resolver, **kwargs) + + # 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 + + # Serialize result if it's an object + result_data = self._serialize_result(result) + + # Determine overall status based on result content + status_str = self._determine_status(result) + + return {"id": step.id, "step": step.name, "status": status_str, "result": result_data} + + async def execute_single_step(self, step: StepDefinition) -> Dict[str, Any]: + if not self.session_resolver: + logger.info("Session resolver not found, initializing session") + await self.initialize_session() + + assert self.session_resolver is not None + + # Set scenario state context for this execution + if not self.session_context: + raise ValueError("Session context not initialized") + + try: + with self.session_context: + # Ensure state is available after entering context + if not self.session_context.state: + raise ValueError("Scenario state not initialized") + return await self._execute_step(step) + except Exception as e: + logger.error(f"Error executing step {step.name}: {e}") + raise + return {"step": step.name, "status": "error", "error": str(e)} + async def run_scenario(self, scenario: ScenarioDefinition) -> List[Dict[str, Any]]: results = [] if not self.session_resolver: @@ -293,3 +303,15 @@ async def run_scenario(self, scenario: ScenarioDefinition) -> List[Dict[str, Any if result.get("status") == "error": break return results + + async def execute_function(self, func: Callable[..., Coroutine[Any, Any, T]]) -> T: + if not self.session_resolver: + await self.initialize_session() + + assert self.session_resolver is not None + + if not self.session_context: + raise ValueError("Session context not initialized") + + with self.session_context: + return await call_with_dependencies(func, resolver=self.session_resolver) diff --git a/tests/test_server_main.py b/tests/test_server_main.py new file mode 100644 index 0000000..6fb51f9 --- /dev/null +++ b/tests/test_server_main.py @@ -0,0 +1,59 @@ +from unittest.mock import AsyncMock, MagicMock + +from fastapi.testclient import TestClient + +from openutm_verification.server.main import app, get_session_manager + +client = TestClient(app) + + +def test_read_root(): + response = client.get("/") + assert response.status_code == 200 + + # If static files are mounted, we get HTML. If not, we get JSON. + content_type = response.headers.get("content-type", "") + if "text/html" in content_type: + assert "" in response.text.lower() + else: + json_response = response.json() + assert "message" in json_response + assert "OpenUTM Verification API is running" in json_response["message"] + + +def test_health_check(): + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_get_operations(): + mock_runner = MagicMock() + mock_runner.get_available_operations.return_value = ["op1", "op2"] + + app.dependency_overrides[get_session_manager] = lambda: mock_runner + + try: + response = client.get("/operations") + assert response.status_code == 200 + assert response.json() == ["op1", "op2"] + finally: + app.dependency_overrides = {} + + +def test_reset_session(): + mock_runner = MagicMock() + mock_runner.close_session = AsyncMock() + mock_runner.initialize_session = AsyncMock() + + app.dependency_overrides[get_session_manager] = lambda: mock_runner + + try: + response = client.post("/session/reset") + assert response.status_code == 200 + assert response.json() == {"status": "session_reset"} + + mock_runner.close_session.assert_called_once() + mock_runner.initialize_session.assert_called_once() + finally: + app.dependency_overrides = {} diff --git a/web-editor/src/components/ScenarioEditor.tsx b/web-editor/src/components/ScenarioEditor.tsx index 8c13bb4..c26ba4f 100644 --- a/web-editor/src/components/ScenarioEditor.tsx +++ b/web-editor/src/components/ScenarioEditor.tsx @@ -17,10 +17,10 @@ import type { Operation, OperationParam, NodeData } from '../types/scenario'; import { CustomNode } from './ScenarioEditor/CustomNode'; import { Toolbox } from './ScenarioEditor/Toolbox'; import { PropertiesPanel } from './ScenarioEditor/PropertiesPanel'; -import { ResultPanel } from './ScenarioEditor/ResultPanel'; +import { BottomPanel } from './ScenarioEditor/BottomPanel'; import { Header } from './ScenarioEditor/Header'; -import { useScenarioGraph } from '../hooks/useScenarioGraph'; +import { useScenarioGraph, generateNodeId } from '../hooks/useScenarioGraph'; import { useScenarioRunner } from '../hooks/useScenarioRunner'; import { useScenarioFile } from '../hooks/useScenarioFile'; @@ -39,15 +39,20 @@ const updateParameterInList = (params: OperationParam[], paramName: string, valu // Memoize child components to prevent unnecessary re-renders const MemoizedToolbox = React.memo(Toolbox); const MemoizedPropertiesPanel = React.memo(PropertiesPanel); -const MemoizedResultPanel = React.memo(ResultPanel); +const MemoizedBottomPanel = React.memo(BottomPanel); const MemoizedHeader = React.memo(Header); const ScenarioEditorContent = () => { const reactFlowWrapper = useRef(null); - const [theme, setTheme] = useState<'light' | 'dark'>('light'); + const [theme, setTheme] = useState<'light' | 'dark'>(() => { + if (typeof window !== 'undefined') { + const saved = sessionStorage.getItem('editor-theme'); + if (saved === 'light' || saved === 'dark') return saved; + } + return 'light'; + }); const [selectedNode, setSelectedNode] = useState | null>(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); - const [resultToDisplay, setResultToDisplay] = useState(null); const [operations, setOperations] = useState([]); const [isConnected, setIsConnected] = useState(false); @@ -91,7 +96,9 @@ const ScenarioEditorContent = () => { onLayout, clearGraph, setReactFlowInstance, - reactFlowInstance + reactFlowInstance, + onGraphDragStart, + onGraphDragStop } = useScenarioGraph(); // Refs to keep track of latest nodes/edges without triggering re-renders in callbacks @@ -114,6 +121,7 @@ const ScenarioEditorContent = () => { useEffect(() => { document.documentElement.dataset.theme = theme; + sessionStorage.setItem('editor-theme', theme); }, [theme]); // Update edge styles when selection changes @@ -134,19 +142,21 @@ const ScenarioEditorContent = () => { const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => { setSelectedNode(node as Node); setSelectedEdgeId(null); - setResultToDisplay(null); }, []); const onNodeDragStart = useCallback((_event: React.MouseEvent, node: Node) => { setSelectedNode(node as Node); setSelectedEdgeId(null); - setResultToDisplay(null); - }, []); + onGraphDragStart(); + }, [onGraphDragStart]); + + const onNodeDragStop = useCallback(() => { + onGraphDragStop(); + }, [onGraphDragStop]); const onPaneClick = useCallback(() => { setSelectedNode(null); setSelectedEdgeId(null); - setResultToDisplay(null); }, []); const onEdgeClick = useCallback((_event: React.MouseEvent, edge: Edge) => { @@ -168,14 +178,9 @@ const ScenarioEditorContent = () => { clearGraph(); setSelectedNode(null); setSelectedEdgeId(null); - setResultToDisplay(null); } }, [clearGraph]); - const handleShowResult = useCallback((res: unknown) => { - setResultToDisplay(res); - }, []); - const updateNodesWithResults = useCallback((currentNodes: Node[], results: { id: string; status: 'success' | 'failure' | 'error'; result?: unknown }[]) => { return currentNodes.map(node => { const stepResult = results.find((r) => r.id === node.id); @@ -186,17 +191,15 @@ const ScenarioEditorContent = () => { ...node.data, status: stepResult.status, result: stepResult.result, - onShowResult: handleShowResult } }; } return node; }); - }, [handleShowResult]); + }, []); const handleRun = useCallback(async () => { // Clear previous results/errors from the UI immediately - setResultToDisplay(null); setNodes((nds) => nds.map(node => ({ ...node, data: { @@ -213,7 +216,41 @@ const ScenarioEditorContent = () => { // Pass a callback to update nodes incrementally const onStepComplete = (stepResult: { id: string; status: 'success' | 'failure' | 'error'; result?: unknown }) => { - setNodes((nds) => updateNodesWithResults(nds, [stepResult])); + setNodes((nds) => { + const updatedNodes = updateNodesWithResults(nds, [stepResult]); + + // Check if this was a Join Background Task step + const completedNode = updatedNodes.find(n => n.id === stepResult.id); + if (completedNode && completedNode.data.label === "Join Background Task" && stepResult.status === 'success') { + // Find the task_id parameter + const taskIdParam = completedNode.data.parameters?.find(p => p.name === "task_id"); + const taskIdValue = taskIdParam?.default; + + // If it's a reference, extract the node ID + if (taskIdValue && typeof taskIdValue === 'object' && '$ref' in taskIdValue) { + const ref = (taskIdValue as { $ref: string }).$ref; + // Assuming ref format is "nodeId.task_id" + const targetNodeId = ref.split('.')[0]; + + // Update the target node with the result + return updatedNodes.map(node => { + if (node.id === targetNodeId) { + return { + ...node, + data: { + ...node.data, + status: 'success', // Or inherit status from result? + result: stepResult.result + } + }; + } + return node; + }); + } + } + + return updatedNodes; + }); }; const onStepStart = (nodeId: string) => { @@ -232,7 +269,7 @@ const ScenarioEditorContent = () => { }; await runScenario(currentNodes, currentEdges, onStepComplete, onStepStart); - }, [runScenario, setNodes, updateNodesWithResults, reactFlowInstance, setResultToDisplay]); + }, [runScenario, setNodes, updateNodesWithResults, reactFlowInstance]); const updateNodeParameter = useCallback((nodeId: string, paramName: string, value: unknown) => { setNodes((nds) => @@ -267,8 +304,12 @@ const ScenarioEditorContent = () => { }, [setNodes]); const updateNodeRunInBackground = useCallback((nodeId: string, value: boolean) => { - setNodes((nds) => - nds.map((node) => { + const joinOp = operations.find(op => op.name === "Join Background Task"); + const shouldCreateJoinNode = value && !!joinOp; + const newNodeId = shouldCreateJoinNode && joinOp ? generateNodeId(nodes, joinOp.name) : null; + + setNodes((nds) => { + const updatedNodes = nds.map((node) => { if (node.id === nodeId) { return { ...node, @@ -276,8 +317,50 @@ const ScenarioEditorContent = () => { }; } return node; - }) - ); + }); + + if (shouldCreateJoinNode && newNodeId && joinOp) { + // Create a new node + const newNode: Node = { + id: newNodeId, + type: 'custom', + position: { x: 0, y: 0 }, // Position will be adjusted by layout or user + data: { + label: joinOp.name, + operationId: joinOp.name, // Using name as ID based on runner.py + description: joinOp.description, + parameters: joinOp.parameters.map(p => ({ + ...p, + default: p.name === 'task_id' ? { $ref: `${nodeId}.id` } : p.default + })) + } + }; + + // Place it near the original node if possible + const originalNode = nds.find(n => n.id === nodeId); + if (originalNode) { + newNode.position = { + x: originalNode.position.x, + y: originalNode.position.y + 150 + }; + } + + return [...updatedNodes, newNode]; + } + + return updatedNodes; + }); + + if (shouldCreateJoinNode && newNodeId) { + setEdges((eds) => [ + ...eds, + { + id: `e${nodeId}-${newNodeId}`, + source: nodeId, + target: newNodeId, + } + ]); + } setSelectedNode((prev) => { if (!prev || prev.id !== nodeId) return prev; @@ -286,7 +369,7 @@ const ScenarioEditorContent = () => { data: { ...prev.data, runInBackground: value }, }; }); - }, [setNodes]); + }, [setNodes, setEdges, operations, nodes]); const getConnectedSourceNodes = useCallback((targetNodeId: string) => { const sourceNodeIds = new Set(edges @@ -300,24 +383,6 @@ const ScenarioEditorContent = () => { [selectedNode, getConnectedSourceNodes] ); - // Inject onShowResult handler into nodes whenever they change or are loaded - // This is a bit of a hack to ensure the callback is present after serialization/deserialization - // Ideally, we wouldn't store functions in node data. - useEffect(() => { - setNodes(nds => nds.map(node => { - if (!node.data.onShowResult) { - return { - ...node, - data: { - ...node.data, - onShowResult: handleShowResult - } - }; - } - return node; - })); - }, [setNodes, nodes.length, handleShowResult]); // Only run when node count changes (added/loaded) - return (
{
-
- , Edge> - nodes={nodes} - edges={edges} - nodeTypes={nodeTypes} - onNodesChange={onNodesChange} - onEdgesChange={onEdgesChange} - onConnect={onConnect} - onInit={setReactFlowInstance} - onDrop={handleDrop} - onDragOver={onDragOver} - onNodeClick={onNodeClick} - onNodeDragStart={onNodeDragStart} - onPaneClick={onPaneClick} - onEdgeClick={onEdgeClick} - fitView - className={theme === 'dark' ? "dark-flow" : ""} - colorMode={theme} - > - - - -
-
-
- {isConnected ? 'Connected' : 'Disconnected'} +
+
+ , Edge> + nodes={nodes} + edges={edges} + nodeTypes={nodeTypes} + onNodesChange={onNodesChange} + onEdgesChange={onEdgesChange} + onConnect={onConnect} + onInit={setReactFlowInstance} + onDrop={handleDrop} + onDragOver={onDragOver} + onNodeClick={onNodeClick} + onNodeDragStart={onNodeDragStart} + onNodeDragStop={onNodeDragStop} + onPaneClick={onPaneClick} + onEdgeClick={onEdgeClick} + fitView + className={theme === 'dark' ? "dark-flow" : ""} + colorMode={theme} + > + + + +
+
+
+ {isConnected ? 'Connected' : 'Disconnected'} +
-
- - + + +
+ + {selectedNode && ( + setSelectedNode(null)} + /> + )}
- {resultToDisplay !== null && resultToDisplay !== undefined && ( - setResultToDisplay(null)} - /> - )} - - {selectedNode && !resultToDisplay && ( + {selectedNode && ( { + const jsonString = JSON.stringify(data, null, 2); + + const html = jsonString.replace(/("[^"]*":?|\btrue\b|\bfalse\b|\bnull\b|-?\d+(?:\.\d+)?)/g, (match) => { + let cls = styles.jsonNumber; + if (match.startsWith('"')) { + if (match.endsWith(':')) { + cls = styles.jsonKey; + } else { + cls = styles.jsonString; + } + } else if (/true|false/.test(match)) { + cls = styles.jsonBoolean; + } else if (/null/.test(match)) { + cls = styles.jsonNull; + } + return `${match}`; + }); + + return
; +}; + +const parseLog = (log: string) => { + const parts = log.split(' | '); + if (parts.length >= 3) { + return { + time: parts[0], + level: parts[1].trim(), + message: parts.slice(2).join(' | ') + }; + } + return { time: '', level: 'UNKNOWN', message: log }; +}; + +interface BottomPanelProps { + selectedNode: Node | null; + onClose: () => void; +} + +export const BottomPanel = ({ selectedNode, onClose }: BottomPanelProps) => { + const { panelHeight, isResizing, startResizing } = useBottomPanelResize(); + const [activeTab, setActiveTab] = useState<'output' | 'logs'>('output'); + const [logLevel, setLogLevel] = useState('ALL'); + + const result = selectedNode?.data?.result; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const logs = (result as any)?.logs as string[] | undefined; + + const filteredLogs = useMemo(() => { + if (!logs) return []; + if (logLevel === 'ALL') return logs; + return logs.filter(log => { + const { level } = parseLog(log); + return level === logLevel; + }); + }, [logs, logLevel]); + + if (!selectedNode) return null; + + const { status, label } = selectedNode.data; + + return ( +
+ +
+ +
+ + +
+ +
+ {result ? ( +
+ {activeTab === 'output' && ( +
+ +
+ )} + + {activeTab === 'logs' && ( +
+
+ + + {filteredLogs.length} lines + +
+ {filteredLogs.length > 0 ? ( +
+ {filteredLogs.map((log, i) => ( +
{log}
+ ))} +
+ ) : ( +
+ No logs available. +
+ )} +
+ )} +
+ ) : ( +
+ No results available for this step. Run the scenario to generate results. +
+ )} +
+
+
+ ); +}; diff --git a/web-editor/src/components/ScenarioEditor/CustomNode.tsx b/web-editor/src/components/ScenarioEditor/CustomNode.tsx index 6b0826c..01683ba 100644 --- a/web-editor/src/components/ScenarioEditor/CustomNode.tsx +++ b/web-editor/src/components/ScenarioEditor/CustomNode.tsx @@ -19,43 +19,28 @@ export const CustomNode = ({ data, selected }: NodeProps>) => { {data.status && (
{data.status === 'success' && ( - +
)} {data.status === 'failure' && ( - +
)} {data.status === 'error' && ( - +
)} {data.status === 'running' && ( diff --git a/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx b/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx index e5dbd9d..b932e62 100644 --- a/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx +++ b/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { X, Link as LinkIcon, Unlink } from 'lucide-react'; import type { Node } from '@xyflow/react'; import layoutStyles from '../../styles/EditorLayout.module.css'; @@ -42,6 +42,39 @@ interface PropertiesPanelProps { } export const PropertiesPanel = ({ selectedNode, connectedNodes, onClose, onUpdateParameter, onUpdateRunInBackground }: PropertiesPanelProps) => { + const [width, setWidth] = useState(480); + const [isResizing, setIsResizing] = useState(false); + + const startResizing = useCallback((mouseDownEvent: React.MouseEvent) => { + mouseDownEvent.preventDefault(); + setIsResizing(true); + }, []); + + const stopResizing = useCallback(() => { + setIsResizing(false); + }, []); + + const resize = useCallback( + (mouseMoveEvent: MouseEvent) => { + if (isResizing) { + const newWidth = window.innerWidth - mouseMoveEvent.clientX; + if (newWidth > 300 && newWidth < 800) { + setWidth(newWidth); + } + } + }, + [isResizing] + ); + + useEffect(() => { + window.addEventListener("mousemove", resize); + window.addEventListener("mouseup", stopResizing); + return () => { + window.removeEventListener("mousemove", resize); + window.removeEventListener("mouseup", stopResizing); + }; + }, [resize, stopResizing]); + const formatParamValue = (value: unknown): string => { if (value === null || value === undefined) { return ''; @@ -65,7 +98,21 @@ export const PropertiesPanel = ({ selectedNode, connectedNodes, onClose, onUpdat }; return ( -
- {groupedOperations.sortedKeys.map(className => ( + {groupedOperations.sortedKeys.map(category => ( ))}
diff --git a/web-editor/src/components/ScenarioEditor/__tests__/PropertiesPanel.test.tsx b/web-editor/src/components/ScenarioEditor/__tests__/PropertiesPanel.test.tsx index 90747b9..3821a24 100644 --- a/web-editor/src/components/ScenarioEditor/__tests__/PropertiesPanel.test.tsx +++ b/web-editor/src/components/ScenarioEditor/__tests__/PropertiesPanel.test.tsx @@ -23,6 +23,7 @@ describe('PropertiesPanel', () => { connectedNodes: [], onClose: vi.fn(), onUpdateParameter: vi.fn(), + onUpdateRunInBackground: vi.fn(), }; it('renders correctly', () => { diff --git a/web-editor/src/components/ScenarioEditor/__tests__/ResultPanel.test.tsx b/web-editor/src/components/ScenarioEditor/__tests__/ResultPanel.test.tsx deleted file mode 100644 index 5d83027..0000000 --- a/web-editor/src/components/ScenarioEditor/__tests__/ResultPanel.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import { describe, it, expect, vi } from 'vitest'; -import { ResultPanel } from '../ResultPanel'; - -// Mock useSidebarResize -vi.mock('../../hooks/useSidebarResize', () => ({ - useSidebarResize: () => ({ - sidebarWidth: 300, - isResizing: false, - startResizing: vi.fn(), - }) -})); - -describe('ResultPanel', () => { - const defaultProps = { - result: { key: 'value', number: 123, boolean: true }, - onClose: vi.fn(), - }; - - it('renders correctly', () => { - render(); - expect(screen.getByText('Step Result')).toBeInTheDocument(); - }); - - it('displays JSON result', () => { - render(); - // Since JsonViewer uses dangerouslySetInnerHTML and splits strings, we might need to check for parts - expect(screen.getByText('"key":')).toBeInTheDocument(); - expect(screen.getByText('"value"')).toBeInTheDocument(); - expect(screen.getByText('123')).toBeInTheDocument(); - expect(screen.getByText('true')).toBeInTheDocument(); - }); - - it('calls onClose when close button is clicked', () => { - render(); - // The close button is in the header. - // Let's find it by role or class. - // It has className={propStyles.closeButton} - // But we can't query by class name easily with testing-library. - // It has an X icon. - // Let's try to find the button in the header. - - const buttons = screen.getAllByRole('button'); - // The first button is the resize handle (it has type="button" and aria-label="Resize sidebar") - // The second button should be the close button. - - // Let's find by aria-label if possible, but it doesn't have one in the code I read. - // I should add aria-label to the close button in ResultPanel.tsx as well. - - // For now, let's assume it's the button that is NOT the resize handle. - const closeButton = buttons.find(b => b.getAttribute('aria-label') !== 'Resize sidebar'); - - if (closeButton) { - fireEvent.click(closeButton); - expect(defaultProps.onClose).toHaveBeenCalled(); - } else { - throw new Error('Close button not found'); - } - }); -}); diff --git a/web-editor/src/components/ScenarioEditor/__tests__/Toolbox.test.tsx b/web-editor/src/components/ScenarioEditor/__tests__/Toolbox.test.tsx index d4de40a..b2fdae2 100644 --- a/web-editor/src/components/ScenarioEditor/__tests__/Toolbox.test.tsx +++ b/web-editor/src/components/ScenarioEditor/__tests__/Toolbox.test.tsx @@ -1,53 +1,48 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import { Toolbox } from '../Toolbox'; +import type { Operation } from '../../../types/scenario'; -// Mock the operations data -vi.mock('../../../data/operations.json', () => ({ - default: [ - { - id: 'op1', - name: 'Operation 1', - className: 'ClassA', - functionName: 'op1', - description: 'Docstring A', - parameters: [] - }, - { - id: 'op2', - name: 'Operation 2', - className: 'ClassA', - functionName: 'op2', - description: 'Docstring B', - parameters: [] - }, - { - id: 'op3', - name: 'Operation 3', - className: 'ClassB', - functionName: 'op3', - description: 'Docstring C', - parameters: [] - } - ] -})); +const mockOperations: Operation[] = [ + { + id: 'op1', + name: 'Operation 1', + category: 'ClassA', + description: 'Docstring A', + parameters: [] + }, + { + id: 'op2', + name: 'Operation 2', + category: 'ClassA', + description: 'Docstring B', + parameters: [] + }, + { + id: 'op3', + name: 'Operation 3', + category: 'ClassB', + description: 'Docstring C', + parameters: [] + } +]; describe('Toolbox', () => { it('renders correctly', () => { - render(); + render(); expect(screen.getByText('ClassA')).toBeInTheDocument(); expect(screen.getByText('ClassB')).toBeInTheDocument(); }); it('displays operations under groups', () => { - render(); + render(); expect(screen.getByText('Operation 1')).toBeInTheDocument(); expect(screen.getByText('Operation 2')).toBeInTheDocument(); expect(screen.getByText('Operation 3')).toBeInTheDocument(); }); it('collapses and expands groups', () => { - render(); + render(); const groupHeader = screen.getByText('ClassA'); // Initially expanded @@ -63,7 +58,7 @@ describe('Toolbox', () => { }); it('sets data transfer on drag start', () => { - render(); + render(); const operationItem = screen.getByText('Operation 1'); const dataTransfer = { diff --git a/web-editor/src/hooks/useBottomPanelResize.ts b/web-editor/src/hooks/useBottomPanelResize.ts new file mode 100644 index 0000000..b107692 --- /dev/null +++ b/web-editor/src/hooks/useBottomPanelResize.ts @@ -0,0 +1,34 @@ +import { useState, useCallback, useEffect } from 'react'; + +export const useBottomPanelResize = (initialHeight = 300, minHeight = 100, maxHeight = 600) => { + const [panelHeight, setPanelHeight] = useState(initialHeight); + const [isResizing, setIsResizing] = useState(false); + + const startResizing = useCallback(() => { + setIsResizing(true); + }, []); + + const stopResizing = useCallback(() => { + setIsResizing(false); + }, []); + + const resize = useCallback((mouseMoveEvent: MouseEvent) => { + if (isResizing) { + const newHeight = document.body.clientHeight - mouseMoveEvent.clientY; + if (newHeight > minHeight && newHeight < maxHeight) { + setPanelHeight(newHeight); + } + } + }, [isResizing, minHeight, maxHeight]); + + useEffect(() => { + globalThis.addEventListener("mousemove", resize); + globalThis.addEventListener("mouseup", stopResizing); + return () => { + globalThis.removeEventListener("mousemove", resize); + globalThis.removeEventListener("mouseup", stopResizing); + }; + }, [resize, stopResizing]); + + return { panelHeight, isResizing, startResizing }; +}; diff --git a/web-editor/src/hooks/useScenarioGraph.ts b/web-editor/src/hooks/useScenarioGraph.ts index 4cf9f54..a66f7ed 100644 --- a/web-editor/src/hooks/useScenarioGraph.ts +++ b/web-editor/src/hooks/useScenarioGraph.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useState, useEffect } from 'react'; import { useNodesState, useEdgesState, @@ -44,13 +44,60 @@ const getLayoutedElements = (nodes: Node[], edges: Edge[], direction = return { nodes: layoutedNodes, edges }; }; -let id = 0; -const getId = () => `dndnode_${id++}`; +export const generateNodeId = (nodes: Node[], baseName: string) => { + const slug = baseName.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, ''); + let counter = 1; + let newId = `${slug}_${counter}`; + while (nodes.some(n => n.id === newId)) { + counter++; + newId = `${slug}_${counter}`; + } + return newId; +}; + +const safeParse = (key: string, fallback: T): T => { + if (typeof window === 'undefined') return fallback; + try { + const item = window.sessionStorage.getItem(key); + return item ? JSON.parse(item) : fallback; + } catch (e) { + console.warn(`Failed to parse ${key} from sessionStorage`, e); + return fallback; + } +}; export const useScenarioGraph = () => { - const [nodes, setNodes, onNodesChange] = useNodesState>([]); - const [edges, setEdges, onEdgesChange] = useEdgesState([]); + // Initialize state from sessionStorage if available + // We use useState to ensure we only read from storage once on mount + const [initialNodes] = useState[]>(() => safeParse('scenario-nodes', [])); + const [initialEdges] = useState(() => safeParse('scenario-edges', [])); + + const [nodes, setNodes, onNodesChange] = useNodesState>(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); const [reactFlowInstance, setReactFlowInstance] = useState, Edge> | null>(null); + const [isDragging, setIsDragging] = useState(false); + + const onGraphDragStart = useCallback(() => { + setIsDragging(true); + }, []); + + const onGraphDragStop = useCallback(() => { + setIsDragging(false); + }, []); + + // Persist changes to sessionStorage + // We skip saving while dragging to improve performance + useEffect(() => { + if (isDragging) return; + + if (nodes.length > 0 || edges.length > 0) { + sessionStorage.setItem('scenario-nodes', JSON.stringify(nodes)); + sessionStorage.setItem('scenario-edges', JSON.stringify(edges)); + } else { + sessionStorage.setItem('scenario-nodes', JSON.stringify(nodes)); + sessionStorage.setItem('scenario-edges', JSON.stringify(edges)); + } + }, [nodes, edges, isDragging]); const onConnect = useCallback( (params: Connection) => setEdges((eds) => addEdge({ @@ -80,21 +127,21 @@ export const useScenarioGraph = () => { const operation = operations.find(op => op.id === opId); - const newNode: Node = { - id: getId(), - type: 'custom', - position, - data: { - label: type, - operationId: opId, - className: operation?.className, - functionName: operation?.functionName, - description: operation?.description, - parameters: operation?.parameters ? JSON.parse(JSON.stringify(operation.parameters)) : [], // Deep copy parameters - }, - }; - - setNodes((nds) => nds.concat(newNode)); + setNodes((nds) => { + const newId = generateNodeId(nds, type); + const newNode: Node = { + id: newId, + type: 'custom', + position, + data: { + label: type, + operationId: opId, + description: operation?.description, + parameters: operation?.parameters ? JSON.parse(JSON.stringify(operation.parameters)) : [], // Deep copy parameters + }, + }; + return nds.concat(newNode); + }); }, [reactFlowInstance, setNodes], ); @@ -117,6 +164,8 @@ export const useScenarioGraph = () => { const clearGraph = useCallback(() => { setNodes([]); setEdges([]); + sessionStorage.removeItem('scenario-nodes'); + sessionStorage.removeItem('scenario-edges'); }, [setNodes, setEdges]); return { @@ -132,5 +181,7 @@ export const useScenarioGraph = () => { clearGraph, reactFlowInstance, setReactFlowInstance, + onGraphDragStart, + onGraphDragStop, }; }; diff --git a/web-editor/src/hooks/useScenarioRunner.ts b/web-editor/src/hooks/useScenarioRunner.ts index 601f98d..63ecc32 100644 --- a/web-editor/src/hooks/useScenarioRunner.ts +++ b/web-editor/src/hooks/useScenarioRunner.ts @@ -64,34 +64,24 @@ export const useScenarioRunner = () => { return acc; }, {} as Record); - let className = node.data.className as string; - let functionName = node.data.functionName as string; - - if ((!className || !functionName) && typeof node.data.operationId === 'string') { - const parts = node.data.operationId.split('.'); - if (parts.length === 2) { - [className, functionName] = parts; - } - } - - // Construct URL with query param for background execution - const url = new URL(`http://localhost:8989/api/${className}/${functionName}`); - if (node.data.runInBackground) { - url.searchParams.append('run_in_background', 'true'); - } - url.searchParams.append('step_id', node.id); + const stepDefinition = { + id: node.id, + name: node.data.label, // Assuming label is the step name + parameters: params, + run_in_background: !!node.data.runInBackground + }; - console.log(`Executing step ${node.id}: ${className}.${functionName}`, params); + console.log(`Executing step ${node.id}: ${node.data.label}`, stepDefinition); - const response = await fetch(url.toString(), { + const response = await fetch('http://localhost:8989/api/step', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(params) + body: JSON.stringify(stepDefinition) }); if (!response.ok) { const errorText = await response.text(); - throw new Error(`Step ${className}.${functionName} failed: ${response.status} ${errorText}`); + throw new Error(`Step ${node.data.label} failed: ${response.status} ${errorText}`); } const result = await response.json(); diff --git a/web-editor/src/styles/BottomPanel.module.css b/web-editor/src/styles/BottomPanel.module.css new file mode 100644 index 0000000..aec57e3 --- /dev/null +++ b/web-editor/src/styles/BottomPanel.module.css @@ -0,0 +1,130 @@ +.resizeHandle { + height: 4px; + cursor: row-resize; + background-color: transparent; + transition: background-color 0.2s; + position: absolute; + left: 0; + right: 0; + top: 0; + z-index: 20; + border: none; + padding: 0; + width: 100%; +} + +.resizeHandle:hover, +.resizeHandleActive { + background-color: var(--accent-primary); +} + +.content { + padding: 0; + overflow: hidden; + height: 100%; + display: flex; + flex-direction: column; +} + +.tabContainer { + display: flex; + border-bottom: 1px solid var(--border-color); + background-color: var(--bg-primary); + padding: 0 16px; +} + +.tab { + padding: 8px 16px; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s; + background: none; + border: none; /* Reset default button border */ + border-bottom: 2px solid transparent; /* Re-apply bottom border */ +} + +.tab:hover { + color: var(--text-primary); +} + +.activeTab { + color: var(--accent-primary); + border-bottom-color: var(--accent-primary); +} + +.tabContent { + flex: 1; + overflow: auto; + padding: 16px; + display: flex; + flex-direction: column; +} + +.toolbar { + display: flex; + gap: 8px; + margin-bottom: 12px; + align-items: center; +} + +.filterSelect { + padding: 4px 8px; + border-radius: 4px; + border: 1px solid var(--border-color); + background-color: var(--bg-primary); + color: var(--text-primary); + font-size: 12px; + outline: none; +} + +.filterSelect:focus { + border-color: var(--accent-primary); +} + +.jsonContainer { + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + white-space: pre-wrap; + word-break: break-word; + background-color: var(--bg-secondary); + padding: 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + overflow: auto; + flex: 1; + line-height: 1.5; + color: var(--text-primary); +} + +.jsonKey { + color: var(--text-primary); + font-weight: 600; +} + +.jsonString { + color: var(--success); +} + +.jsonNumber { + color: var(--accent-primary); +} + +.jsonBoolean { + color: var(--danger); +} + +.jsonNull { + color: var(--text-secondary); +} + +.sectionTitle { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} diff --git a/web-editor/src/styles/EditorLayout.module.css b/web-editor/src/styles/EditorLayout.module.css index d450274..74265f5 100644 --- a/web-editor/src/styles/EditorLayout.module.css +++ b/web-editor/src/styles/EditorLayout.module.css @@ -12,7 +12,14 @@ overflow: hidden; position: relative; background-color: var(--bg-secondary); - /* Slightly different bg for contrast */ +} + +.centerPane { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; } .sidebar { @@ -25,6 +32,16 @@ transition: background-color 0.3s ease, border-color 0.3s ease; } +.bottomPanel { + height: 300px; + background-color: var(--bg-primary); + border-top: 1px solid var(--border-color); + display: flex; + flex-direction: column; + z-index: 10; + transition: background-color 0.3s ease, border-color 0.3s ease; +} + .rightSidebar { width: 320px; /* Slightly wider for better readability */ diff --git a/web-editor/src/styles/ResultPanel.module.css b/web-editor/src/styles/ResultPanel.module.css deleted file mode 100644 index 5d45bce..0000000 --- a/web-editor/src/styles/ResultPanel.module.css +++ /dev/null @@ -1,55 +0,0 @@ -.resizeHandle { - width: 4px; - cursor: col-resize; - background-color: transparent; - transition: background-color 0.2s; - position: absolute; - left: 0; - top: 0; - bottom: 0; - z-index: 20; - border: none; - padding: 0; -} - -.resizeHandle:hover, -.resizeHandleActive { - background-color: var(--accent-primary); -} - -.jsonContainer { - font-family: 'JetBrains Mono', monospace; - font-size: 13px; - white-space: pre-wrap; - word-break: break-word; - background-color: var(--bg-primary); - padding: 16px; - border-radius: var(--radius-md); - border: 1px solid var(--border-color); - overflow: auto; - height: 100%; - line-height: 1.5; - color: var(--text-primary); -} - -.jsonKey { - color: var(--text-primary); - font-weight: 600; -} - -.jsonString { - color: var(--success); -} - -.jsonNumber { - color: var(--accent-primary); -} - -.jsonBoolean { - color: var(--danger); -} - -.jsonNull { - color: var(--text-secondary); - font-style: italic; -} diff --git a/web-editor/src/types/scenario.ts b/web-editor/src/types/scenario.ts index 0690ace..567b717 100644 --- a/web-editor/src/types/scenario.ts +++ b/web-editor/src/types/scenario.ts @@ -9,29 +9,24 @@ export interface OperationParam { export interface Operation { id: string; name: string; - functionName: string; - className: string; description: string; parameters: OperationParam[]; + category?: string; } export interface NodeData extends Record { label: string; operationId?: string; - className?: string; - functionName?: string; description?: string; parameters?: OperationParam[]; status?: 'success' | 'failure' | 'error' | 'running'; result?: unknown; - onShowResult?: (result: unknown) => void; runInBackground?: boolean; } export interface ScenarioStep { id: string; - className: string; - functionName: string; + name: string; parameters: Record; run_in_background?: boolean; } diff --git a/web-editor/vite.config.ts b/web-editor/vite.config.ts index aa458c6..6019f4e 100644 --- a/web-editor/vite.config.ts +++ b/web-editor/vite.config.ts @@ -1,5 +1,5 @@ /// -import { defineConfig } from 'vite' +import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' // https://vite.dev/config/ From a3148859938bf9aa065482935204b85297500206 Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Tue, 30 Dec 2025 13:24:09 +0100 Subject: [PATCH 06/20] Integrate the new UI into the docker image and CLI scripts. --- Dockerfile | 15 ++++++ Dockerfile.dev | 15 ++++++ docker-compose.yml | 41 ++++++++++++++++ scripts/run.sh | 36 ++++++++++++++ src/openutm_verification/cli/__init__.py | 5 ++ src/openutm_verification/cli/parser.py | 5 ++ src/openutm_verification/server/main.py | 59 +++++++---------------- src/openutm_verification/server/runner.py | 3 ++ 8 files changed, 137 insertions(+), 42 deletions(-) diff --git a/Dockerfile b/Dockerfile index ebdaa8a..76523c1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,11 @@ +# --- UI Builder Stage --- +FROM node:20-slim AS ui-builder +WORKDIR /app/web-editor +COPY web-editor/package.json web-editor/package-lock.json ./ +RUN npm ci +COPY web-editor/ . +RUN npm run build + # --- Builder Stage --- FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder @@ -64,6 +72,7 @@ RUN apt-get update \ ENV PYTHONUNBUFFERED=1 ENV TZ=UTC ENV PATH="/app/.venv/bin:$PATH" +ENV WEB_EDITOR_PATH=/app/web-editor # Create non-root user and group for enhanced security RUN (getent group "${GID}" || groupadd -g "${GID}" "${APP_GROUP}") \ @@ -72,6 +81,9 @@ RUN (getent group "${GID}" || groupadd -g "${GID}" "${APP_GROUP}") \ # Copy application artifacts from builder stage COPY --chown=${UID}:${GID} --from=builder /app /app +# Copy UI artifacts from ui-builder stage +COPY --chown=${UID}:${GID} --from=ui-builder /app/web-editor/dist /app/web-editor/dist + # Set working directory WORKDIR /app @@ -82,6 +94,9 @@ RUN mkdir -p /app/config /app/reports \ # Switch to non-root user USER ${UID}:${GID} +# Expose the server port +EXPOSE 8989 + # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD python -c "import sys; print('OK'); sys.exit(0)" || exit 1 diff --git a/Dockerfile.dev b/Dockerfile.dev index b377dc3..9ae3ef9 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,3 +1,11 @@ +# --- UI Builder Stage --- +FROM node:20-slim AS ui-builder +WORKDIR /app/web-editor +COPY web-editor/package.json web-editor/package-lock.json ./ +RUN npm ci +COPY web-editor/ . +RUN npm run build + # Development Dockerfile for hot reload and debugging FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS development @@ -30,6 +38,7 @@ ENV UV_COMPILE_BYTECODE=0 ENV UV_LINK_MODE=copy ENV PYTHONUNBUFFERED=1 ENV PYTHONPATH=/app/src +ENV WEB_EDITOR_PATH=/app/web-editor # Copy dependency files COPY --chown=${UID}:${GID} LICENSE README.md pyproject.toml uv.lock ./ @@ -43,10 +52,16 @@ RUN --mount=type=cache,target=/home/${APP_USER}/.cache/uv,uid=${UID},gid=${GID} # Copy source code COPY --chown=${UID}:${GID} . . +# Copy UI artifacts from ui-builder stage +COPY --chown=${UID}:${GID} --from=ui-builder /app/web-editor/dist /app/web-editor/dist + # Create directories RUN mkdir -p /app/reports /app/config \ && chown -R ${UID}:${GID} /app/reports /app/config +# Expose the server port +EXPOSE 8989 + # Switch to non-root user USER ${UID}:${GID} diff --git a/docker-compose.yml b/docker-compose.yml index 27ae745..a0a15d2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,3 +74,44 @@ services: network_mode: host profiles: - dev + + # Server mode service + verification-server: + image: openutm/verification:latest + container_name: openutm-verification-server + build: + context: . + dockerfile: Dockerfile + args: + UV_COMPILE_BYTECODE: 1 + UV_LINK_MODE: copy + APP_USER: appuser + APP_GROUP: appgrp + UID: ${HOST_UID:-1000} + GID: ${HOST_GID:-1000} + environment: + - PYTHONUNBUFFERED=1 + - TZ=UTC + - FLIGHT_BLENDER_URL=${FLIGHT_BLENDER_URL:-http://host.docker.internal:8000} + - LOG_LEVEL=${LOG_LEVEL:-INFO} + volumes: + - ./config:/app/config:Z + - ./reports:/app/reports:Z + ports: + - "8989:8989" + extra_hosts: + - "host.docker.internal:host-gateway" + command: ["--server", "--config", "config/default.yaml"] + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8989/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + restart: unless-stopped + labels: + - "project=openutm-verification" + - "component=verification-server" + - "environment=${ENVIRONMENT:-production}" + profiles: + - server diff --git a/scripts/run.sh b/scripts/run.sh index a65cae2..c72e692 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -13,6 +13,7 @@ source "$SCRIPT_DIR/common.sh" readonly COMPOSE_FILE="docker-compose.yml" readonly SERVICE_NAME="verification-tool" readonly DEV_SERVICE_NAME="verification-dev" +readonly SERVER_SERVICE_NAME="verification-server" # Check if Docker and Docker Compose are available # Note: check_dependencies is now sourced from common.sh @@ -49,6 +50,7 @@ Usage: $0 [OPTIONS] [ARGS...] Options: -d, --dev Run in development mode with hot reload -p, --production Run in production mode (default) + -s, --server Run in server mode (starts API and UI) -b, --build Build images before running --clean Clean up containers and images after run -v, --verbose Enable verbose output @@ -60,6 +62,7 @@ Arguments: Examples: $0 # Run with default config $0 --config config/custom.yaml # Run with custom config + $0 --server # Run in server mode $0 --debug # Run with debug logging $0 --dev --build # Build and run in development mode $0 --clean --config config/test.yaml # Clean up after run @@ -120,6 +123,32 @@ run_development() { -e HOST_UID="${HOST_UID}" -e HOST_GID="${HOST_GID}" \ "${DEV_SERVICE_NAME}" "$@" } +# Run in server mode +run_server() { + log_info "Running verification tool in server mode..." + + local build_opts=() + if [[ "${VERBOSE}" == "true" ]]; then + log_info "Verbose mode enabled - additional logging will be shown" + build_opts+=("-v") + fi + + if [[ "${BUILD_FIRST}" == "true" ]]; then + log_info "Building production image first..." + ./scripts/build.sh ${build_opts[@]+"${build_opts[@]}"} production + fi + + local compose_opts=() + if [[ "${VERBOSE}" == "true" ]]; then + log_info "Starting container with verbose output..." + compose_opts+=("--verbose") + fi + + # For server mode, we use 'up' instead of 'run' to keep the service running + # and we don't use --rm because we might want to inspect logs + docker compose ${compose_opts[@]+"${compose_opts[@]}"} --profile server up \ + "${SERVER_SERVICE_NAME}" +} # Cleanup function for run script run_cleanup() { @@ -159,6 +188,10 @@ main() { VERBOSE="true" shift ;; + -s|--server) + RUN_MODE="server" + shift + ;; -h|--help) show_usage exit 0 @@ -190,6 +223,9 @@ main() { "development") run_development "$@" ;; + "server") + run_server "$@" + ;; *) log_error "Invalid run mode: ${RUN_MODE}" exit 1 diff --git a/src/openutm_verification/cli/__init__.py b/src/openutm_verification/cli/__init__.py index 2a77b27..fccd6e0 100644 --- a/src/openutm_verification/cli/__init__.py +++ b/src/openutm_verification/cli/__init__.py @@ -12,6 +12,7 @@ from openutm_verification.cli.parser import create_parser from openutm_verification.core.execution.config_models import AppConfig, ConfigProxy from openutm_verification.core.execution.execution import run_verification_scenarios +from openutm_verification.server.main import start_server_mode from openutm_verification.utils.logging import setup_logging @@ -22,6 +23,10 @@ def main(): parser = create_parser() args = parser.parse_args() + if args.server: + start_server_mode(config_path=args.config) + sys.exit(0) + # Load configuration config_path = Path(args.config) with open(config_path, "r", encoding="utf-8") as f: diff --git a/src/openutm_verification/cli/parser.py b/src/openutm_verification/cli/parser.py index 94f0374..1b3fd3e 100644 --- a/src/openutm_verification/cli/parser.py +++ b/src/openutm_verification/cli/parser.py @@ -27,4 +27,9 @@ def create_parser() -> argparse.ArgumentParser: action="append", help="Name of the test suite to run (overrides individual scenarios). Can be specified multiple times.", ) + parser.add_argument( + "--server", + action="store_true", + help="Run in server mode (starts the API and Web UI).", + ) return parser diff --git a/src/openutm_verification/server/main.py b/src/openutm_verification/server/main.py index 523e02b..035f267 100644 --- a/src/openutm_verification/server/main.py +++ b/src/openutm_verification/server/main.py @@ -1,12 +1,10 @@ import os -import shutil -import subprocess from contextlib import asynccontextmanager +import uvicorn from fastapi import Depends, FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from loguru import logger # Import dependencies to ensure they are registered and steps are populated import openutm_verification.core.execution.dependencies # noqa: F401 @@ -43,6 +41,17 @@ def get_session_manager(request: Request) -> SessionManager: return request.app.state.runner +def start_server_mode(config_path: str | None = None, reload: bool = False): + if config_path: + os.environ["OPENUTM_CONFIG_PATH"] = str(config_path) + uvicorn.run( + "openutm_verification.server.main:app", + host="0.0.0.0", + port=8989, + reload=reload, + ) + + @app.get("/api/info") async def api_info(): return {"message": "OpenUTM Verification API is running"} @@ -74,39 +83,13 @@ async def run_scenario(scenario: ScenarioDefinition, runner: SessionManager = De # Calculate path relative to this file # src/openutm_verification/server/main.py -> ../../../web-editor/dist web_editor_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../web-editor")) -static_dir = os.path.join(web_editor_dir, "dist") - - -def build_frontend(): - """Attempt to build the web-editor frontend using npm.""" - if not os.path.exists(web_editor_dir): - logger.warning(f"Web editor directory not found at {web_editor_dir}") - return - npm_cmd = shutil.which("npm") - if not npm_cmd: - logger.warning("npm not found. Skipping web editor build.") - return - - try: - logger.info("Building web editor frontend... This may take a while.") - # Run npm install - logger.info("Running 'npm install'...") - subprocess.run([npm_cmd, "install"], cwd=web_editor_dir, check=True) - - # Run npm run build - logger.info("Running 'npm run build'...") - subprocess.run([npm_cmd, "run", "build"], cwd=web_editor_dir, check=True) - - logger.info("Web editor built successfully.") - except subprocess.CalledProcessError as e: - logger.error(f"Failed to build web editor: {e}") - except Exception as e: - logger.error(f"An unexpected error occurred during web editor build: {e}") +# Allow override via environment variable (e.g. for Docker production builds) +if os.environ.get("WEB_EDITOR_PATH"): + web_editor_dir = os.environ.get("WEB_EDITOR_PATH") +static_dir = os.path.join(web_editor_dir, "dist") -if not os.path.exists(static_dir): - build_frontend() if os.path.exists(static_dir): app.mount("/", StaticFiles(directory=static_dir, html=True), name="static") @@ -121,12 +104,4 @@ async def root(): if __name__ == "__main__": - import uvicorn - - uvicorn.run( - "openutm_verification.server.main:app", - host="0.0.0.0", - port=8989, - reload=True, - reload_includes=["*.py"], - ) + start_server_mode(reload=True) diff --git a/src/openutm_verification/server/runner.py b/src/openutm_verification/server/runner.py index 716386c..4d44a3e 100644 --- a/src/openutm_verification/server/runner.py +++ b/src/openutm_verification/server/runner.py @@ -1,4 +1,5 @@ import asyncio +import os from contextlib import AsyncExitStack from pathlib import Path from typing import Any, Callable, Coroutine, Dict, List, Type, TypeVar, cast @@ -31,6 +32,8 @@ def __init__(self, config_path: str = "config/default.yaml"): if hasattr(self, "_initialized") and self._initialized: return + config_path = os.environ.get("OPENUTM_CONFIG_PATH", config_path) + self.config_path = Path(config_path) self.config = self._load_config() self.client_map: Dict[str, Type] = {} From 9d4873e8483b2015d231bcff2dfbe2da018ebdae Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Tue, 30 Dec 2025 13:26:45 +0100 Subject: [PATCH 07/20] Fix webpage title --- web-editor/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-editor/index.html b/web-editor/index.html index bba9739..7667156 100644 --- a/web-editor/index.html +++ b/web-editor/index.html @@ -4,7 +4,7 @@ - web-editor + OpenUTM Scenario Designer From 8e6b1416880bcf1208775eff3467413cba9f7971 Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Tue, 30 Dec 2025 13:27:29 +0100 Subject: [PATCH 08/20] typing error fix --- src/openutm_verification/server/main.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/openutm_verification/server/main.py b/src/openutm_verification/server/main.py index 035f267..c324a0d 100644 --- a/src/openutm_verification/server/main.py +++ b/src/openutm_verification/server/main.py @@ -85,8 +85,7 @@ async def run_scenario(scenario: ScenarioDefinition, runner: SessionManager = De web_editor_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../web-editor")) # Allow override via environment variable (e.g. for Docker production builds) -if os.environ.get("WEB_EDITOR_PATH"): - web_editor_dir = os.environ.get("WEB_EDITOR_PATH") +web_editor_dir = os.environ.get("WEB_EDITOR_PATH", web_editor_dir) static_dir = os.path.join(web_editor_dir, "dist") From b8dd3b43bdd758d214351a5c4d3f9dd8ce8ea192 Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Tue, 30 Dec 2025 16:31:14 +0100 Subject: [PATCH 09/20] Convert scenarios to yaml --- config/default.yaml | 2 +- .../F1_flow_no_telemetry_with_user_input.yaml | 18 + scenarios/F1_happy_path.yaml | 15 + scenarios/F2_contingent_path.yaml | 20 + scenarios/F3_non_conforming_path.yaml | 22 + scenarios/F5_non_conforming_path.yaml | 23 + scenarios/add_flight_declaration.yaml | 16 + scenarios/geo_fence_upload.yaml | 6 + scenarios/opensky_live_data.yaml | 22 + scenarios/openutm_sim_air_traffic_data.yaml | 9 + scenarios/sdsp_heartbeat.yaml | 31 + scenarios/sdsp_track.yaml | 44 + scripts/generate_editor_metadata.py | 220 -- .../clients/air_traffic/air_traffic_client.py | 2 +- .../core/clients/air_traffic/base_client.py | 4 +- .../core/clients/common/common_client.py | 21 + .../flight_blender/flight_blender_client.py | 87 +- .../core/clients/opensky/base_client.py | 4 +- .../core/execution/config_models.py | 9 +- .../core/execution/definitions.py | 13 +- .../core/execution/dependencies.py | 19 +- .../core/execution/dependency_resolution.py | 9 +- .../core/execution/execution.py | 6 +- .../core/execution/scenario_runner.py | 9 +- .../core/reporting/reporting_models.py | 4 +- .../core/templates/report_template.html | 4 +- src/openutm_verification/models.py | 14 +- src/openutm_verification/scenarios/common.py | 8 + .../scenarios/registry.py | 2 +- .../scenarios/test_add_flight_declaration.py | 6 +- .../scenarios/test_f1_flow.py | 6 +- .../test_f1_no_telemetry_with_user_input.py | 9 +- .../scenarios/test_f2_flow.py | 8 +- .../scenarios/test_f3_flow.py | 13 +- .../scenarios/test_f5_flow.py | 10 +- .../scenarios/test_sdsp_heartbeat.py | 7 +- .../scenarios/test_sdsp_track.py | 7 +- src/openutm_verification/server/router.py | 53 +- src/openutm_verification/server/runner.py | 185 +- src/openutm_verification/utils/time_utils.py | 42 + tests/test_client_steps.py | 17 +- tests/test_scenarios.py | 65 +- tests/test_yaml_scenarios.py | 122 ++ web-editor/async_implementation_guide.md | 53 +- .../scenario_2025-12-24T01-15-16-238Z.json | 1906 ----------------- web-editor/examples/sdsp_track_async.json | 20 +- web-editor/package-lock.json | 11 +- web-editor/package.json | 2 + web-editor/src/components/ScenarioEditor.tsx | 26 +- .../src/components/ScenarioEditor/Header.tsx | 28 +- .../ScenarioEditor/ScenarioList.tsx | 117 + .../src/components/ScenarioEditor/Toolbox.tsx | 38 +- .../ScenarioEditor/__tests__/Header.test.tsx | 21 +- web-editor/src/data/operations.json | 469 ---- .../hooks/__tests__/useScenarioFile.test.ts | 93 +- .../hooks/__tests__/useScenarioRunner.test.ts | 3 +- web-editor/src/hooks/useScenarioFile.ts | 103 +- web-editor/src/hooks/useScenarioRunner.ts | 10 +- web-editor/src/styles/Toolbox.module.css | 30 + web-editor/src/types/scenario.ts | 14 +- web-editor/src/utils/scenarioConversion.ts | 165 ++ web-editor/vite.config.ts | 9 + 62 files changed, 1305 insertions(+), 3026 deletions(-) create mode 100644 scenarios/F1_flow_no_telemetry_with_user_input.yaml create mode 100644 scenarios/F1_happy_path.yaml create mode 100644 scenarios/F2_contingent_path.yaml create mode 100644 scenarios/F3_non_conforming_path.yaml create mode 100644 scenarios/F5_non_conforming_path.yaml create mode 100644 scenarios/add_flight_declaration.yaml create mode 100644 scenarios/geo_fence_upload.yaml create mode 100644 scenarios/opensky_live_data.yaml create mode 100644 scenarios/openutm_sim_air_traffic_data.yaml create mode 100644 scenarios/sdsp_heartbeat.yaml create mode 100644 scenarios/sdsp_track.yaml delete mode 100644 scripts/generate_editor_metadata.py create mode 100644 src/openutm_verification/core/clients/common/common_client.py create mode 100644 src/openutm_verification/utils/time_utils.py create mode 100644 tests/test_yaml_scenarios.py delete mode 100644 web-editor/examples/scenario_2025-12-24T01-15-16-238Z.json create mode 100644 web-editor/src/components/ScenarioEditor/ScenarioList.tsx delete mode 100644 web-editor/src/data/operations.json create mode 100644 web-editor/src/utils/scenarioConversion.ts diff --git a/config/default.yaml b/config/default.yaml index 84318b4..6c8f05d 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -27,7 +27,7 @@ opensky: # Air traffic data configuration air_traffic_simulator_settings: number_of_aircraft: 3 - simulation_duration_seconds: 10 + simulation_duration: 10 single_or_multiple_sensors: "multiple" # this setting specifiies if the traffic data is submitted from a single sensor or multiple sensors sensor_ids: ["a0b7d47e5eac45dc8cbaf47e6fe0e558"] # List of sensor IDs to use when 'multiple' is selected diff --git a/scenarios/F1_flow_no_telemetry_with_user_input.yaml b/scenarios/F1_flow_no_telemetry_with_user_input.yaml new file mode 100644 index 0000000..c2b0733 --- /dev/null +++ b/scenarios/F1_flow_no_telemetry_with_user_input.yaml @@ -0,0 +1,18 @@ +name: F1_flow_no_telemetry_with_user_input +description: Runs the F1 no telemetry with user input scenario. +steps: +- step: Setup Flight Declaration +- step: Wait X seconds + arguments: + duration: 5 +- step: Update Operation State + arguments: + state: ACTIVATED +- step: Wait for User Input + arguments: + prompt: Press Enter to end the operation... +- id: update_state_ended + step: Update Operation State + arguments: + state: ENDED +- step: Teardown Flight Declaration diff --git a/scenarios/F1_happy_path.yaml b/scenarios/F1_happy_path.yaml new file mode 100644 index 0000000..2d208f0 --- /dev/null +++ b/scenarios/F1_happy_path.yaml @@ -0,0 +1,15 @@ +name: F1_happy_path +description: Runs the F1 happy path scenario. +steps: +- step: Setup Flight Declaration +- step: Update Operation State + arguments: + state: ACTIVATED +- step: Submit Telemetry + arguments: + duration: 30 +- id: update_state_ended + step: Update Operation State + arguments: + state: ENDED +- step: Teardown Flight Declaration diff --git a/scenarios/F2_contingent_path.yaml b/scenarios/F2_contingent_path.yaml new file mode 100644 index 0000000..503754b --- /dev/null +++ b/scenarios/F2_contingent_path.yaml @@ -0,0 +1,20 @@ +name: F2_contingent_path +description: Runs the F2 contingent path scenario. +steps: +- step: Setup Flight Declaration +- step: Update Operation State + arguments: + state: ACTIVATED +- step: Submit Telemetry + arguments: + duration: 10 +- id: update_state_contingent + step: Update Operation State + arguments: + state: CONTINGENT + duration: 7 +- id: update_state_ended + step: Update Operation State + arguments: + state: ENDED +- step: Teardown Flight Declaration diff --git a/scenarios/F3_non_conforming_path.yaml b/scenarios/F3_non_conforming_path.yaml new file mode 100644 index 0000000..66f984c --- /dev/null +++ b/scenarios/F3_non_conforming_path.yaml @@ -0,0 +1,22 @@ +name: F3_non_conforming_path +description: Runs the F3 non-conforming path scenario. +steps: +- step: Setup Flight Declaration +- step: Update Operation State + arguments: + state: ACTIVATED +- step: Wait X seconds + arguments: + duration: 5 +- step: Submit Telemetry + arguments: + duration: 20 +- step: Check Operation State + arguments: + expected_state: NONCONFORMING + duration: 5 +- id: update_state_ended + step: Update Operation State + arguments: + state: ENDED +- step: Teardown Flight Declaration diff --git a/scenarios/F5_non_conforming_path.yaml b/scenarios/F5_non_conforming_path.yaml new file mode 100644 index 0000000..793cd08 --- /dev/null +++ b/scenarios/F5_non_conforming_path.yaml @@ -0,0 +1,23 @@ +name: F5_non_conforming_path +description: Runs the F5 non-conforming path scenario. +steps: +- step: Setup Flight Declaration +- step: Update Operation State + arguments: + state: ACTIVATED +- step: Submit Telemetry + arguments: + duration: 20 +- step: Check Operation State Connected + arguments: + expected_state: NONCONFORMING + duration: 5 +- id: update_state_contingent + step: Update Operation State + arguments: + state: CONTINGENT +- id: update_state_ended + step: Update Operation State + arguments: + state: ENDED +- step: Teardown Flight Declaration diff --git a/scenarios/add_flight_declaration.yaml b/scenarios/add_flight_declaration.yaml new file mode 100644 index 0000000..b10b3dc --- /dev/null +++ b/scenarios/add_flight_declaration.yaml @@ -0,0 +1,16 @@ +name: add_flight_declaration +description: Runs the add flight declaration scenario. +steps: +- step: Setup Flight Declaration +- step: Update Operation State + arguments: + state: ACTIVATED + duration: 20 +- step: Submit Telemetry + arguments: + duration: 30 +- id: update_state_ended + step: Update Operation State + arguments: + state: ENDED +- step: Teardown Flight Declaration diff --git a/scenarios/geo_fence_upload.yaml b/scenarios/geo_fence_upload.yaml new file mode 100644 index 0000000..e956292 --- /dev/null +++ b/scenarios/geo_fence_upload.yaml @@ -0,0 +1,6 @@ +name: geo_fence_upload +description: Upload a geo-fence (Area of Interest) and then delete it (teardown). + +steps: + - step: Upload Geo Fence + - step: Get Geo Fence diff --git a/scenarios/opensky_live_data.yaml b/scenarios/opensky_live_data.yaml new file mode 100644 index 0000000..eb9961b --- /dev/null +++ b/scenarios/opensky_live_data.yaml @@ -0,0 +1,22 @@ +name: opensky_live_data +description: Fetch live flight data from OpenSky and submit to Flight Blender. + +steps: + - step: Fetch OpenSky Data + + - step: Submit Air Traffic + arguments: + observations: ${{ steps.Fetch OpenSky Data.result }} + + - step: Wait X seconds + arguments: + duration: 3 + + # Iteration 2 + - id: fetch_2 + step: Fetch OpenSky Data + + - id: submit_2 + step: Submit Air Traffic + arguments: + observations: ${{ steps.fetch_2.result }} diff --git a/scenarios/openutm_sim_air_traffic_data.yaml b/scenarios/openutm_sim_air_traffic_data.yaml new file mode 100644 index 0000000..c3e6352 --- /dev/null +++ b/scenarios/openutm_sim_air_traffic_data.yaml @@ -0,0 +1,9 @@ +name: openutm_sim_air_traffic_data +description: Generate simulated air traffic data using OpenSky client and submit to Flight Blender. + +steps: + - step: Generate Simulated Air Traffic Data + + - step: Submit Simulated Air Traffic + arguments: + observations: ${{ steps.Generate Simulated Air Traffic Data.result }} diff --git a/scenarios/sdsp_heartbeat.yaml b/scenarios/sdsp_heartbeat.yaml new file mode 100644 index 0000000..e34fa1d --- /dev/null +++ b/scenarios/sdsp_heartbeat.yaml @@ -0,0 +1,31 @@ +name: sdsp_heartbeat +description: Runs the SDSP heartbeat scenario. + +steps: + - step: Generate UUID + + - id: start_session + step: Start / Stop SDSP Session + arguments: + action: START + session_id: ${{ steps.Generate UUID.result }} + + - step: Wait X seconds + arguments: + duration: 2 + + - step: Verify SDSP Heartbeat + arguments: + session_id: ${{ steps.Generate UUID.result }} + expected_heartbeat_interval_seconds: 1 + expected_heartbeat_count: 3 + + - id: wait_verification + step: Wait X seconds + arguments: + duration: 5 + + - step: Start / Stop SDSP Session + arguments: + action: STOP + session_id: ${{ steps.Generate UUID.result }} diff --git a/scenarios/sdsp_track.yaml b/scenarios/sdsp_track.yaml new file mode 100644 index 0000000..64eb86e --- /dev/null +++ b/scenarios/sdsp_track.yaml @@ -0,0 +1,44 @@ +name: sdsp_track +description: Runs the SDSP track scenario. + +steps: + - step: Generate UUID + + - id: start_session + step: Start / Stop SDSP Session + arguments: + action: START + session_id: ${{ steps.Generate UUID.result }} + + - step: Generate Simulated Air Traffic Data + + - step: Submit Simulated Air Traffic + arguments: + observations: ${{ steps.Generate Simulated Air Traffic Data.result }} + background: true + + - id: wait_initial + step: Wait X seconds + arguments: + duration: 2 + + - step: Verify SDSP Track + arguments: + session_id: ${{ steps.Generate UUID.result }} + expected_track_interval_seconds: 1 + expected_track_count: 3 + + - id: wait_verification + step: Wait X seconds + arguments: + duration: 5 + + - id: stop_session + step: Start / Stop SDSP Session + arguments: + action: STOP + session_id: ${{ steps.Generate UUID.result }} + + - step: Join Background Task + arguments: + task_id: Submit Simulated Air Traffic diff --git a/scripts/generate_editor_metadata.py b/scripts/generate_editor_metadata.py deleted file mode 100644 index 10e73e8..0000000 --- a/scripts/generate_editor_metadata.py +++ /dev/null @@ -1,220 +0,0 @@ -import ast -import json -import os -from pathlib import Path -from typing import Any, Dict, List, Optional, Union - - -def parse_type_annotation(annotation: Any) -> str: - """Helper to convert AST type annotation to string representation.""" - if annotation is None: - return "Any" - if isinstance(annotation, ast.Name): - return annotation.id - elif isinstance(annotation, ast.Subscript): - value = parse_type_annotation(annotation.value) - slice_val = parse_type_annotation(annotation.slice) - return f"{value}[{slice_val}]" - elif isinstance(annotation, ast.Constant): - return str(annotation.value) - elif isinstance(annotation, ast.Attribute): - return annotation.attr - elif isinstance(annotation, ast.BinOp): - # Handle Union types like str | int - left = parse_type_annotation(annotation.left) - right = parse_type_annotation(annotation.right) - return f"{left} | {right}" - return "Any" - - -def parse_default_value(node: Any) -> Any: - """Helper to convert AST default value to python object.""" - if node is None: - return None - if isinstance(node, ast.Constant): - return node.value - if isinstance(node, ast.Name): - return node.id - if isinstance(node, ast.Attribute): - return f"{parse_default_value(node.value)}.{node.attr}" - # Handle other types if necessary - return str(node) - - -def get_step_name_from_decorator(decorator: Any) -> Optional[str]: - if isinstance(decorator, ast.Call): - func = decorator.func - is_scenario_step = False - if isinstance(func, ast.Name) and func.id == "scenario_step": - is_scenario_step = True - elif isinstance(func, ast.Attribute) and func.attr == "scenario_step": - is_scenario_step = True - - if is_scenario_step and decorator.args: - arg = decorator.args[0] - if isinstance(arg, ast.Constant): - return str(arg.value) - # Handle older python versions or string literals - elif isinstance(arg, ast.Str): - return str(arg.s) - elif isinstance(decorator, ast.Name) and decorator.id == "scenario_step": - return "Scenario Step" - return None - - -def extract_enums(file_path: Path) -> Dict[str, List[Dict[str, Any]]]: - """Extract Enum definitions from a file.""" - enums = {} - if not file_path.exists(): - return enums - - with open(file_path, "r", encoding="utf-8") as f: - try: - tree = ast.parse(f.read(), filename=str(file_path)) - except SyntaxError: - return enums - - for node in ast.walk(tree): - if isinstance(node, ast.ClassDef): - is_enum = False - for base in node.bases: - if isinstance(base, ast.Name) and base.id == "Enum": - is_enum = True - elif isinstance(base, ast.Attribute) and base.attr == "Enum": - is_enum = True - - if is_enum: - values = [] - for item in node.body: - if isinstance(item, ast.Assign): - for target in item.targets: - if isinstance(target, ast.Name): - # We found an enum member - name = target.id - value = None - if isinstance(item.value, ast.Constant): - value = item.value.value - elif isinstance(item.value, ast.Str): # Python < 3.8 - value = item.value.s - elif isinstance(item.value, ast.Num): # Python < 3.8 - value = item.value.n - - if value is not None: - values.append({"name": name, "value": value}) - enums[node.name] = values - return enums - - -def extract_args(function_node: Union[ast.FunctionDef, ast.AsyncFunctionDef], known_enums: Dict[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]: - args = [] - - # Calculate defaults mapping - defaults = function_node.args.defaults - # defaults correspond to the last n arguments - args_with_defaults = function_node.args.args[-len(defaults) :] if defaults else [] - default_map = {} - for arg, default in zip(args_with_defaults, defaults): - default_map[arg.arg] = parse_default_value(default) - - for arg in function_node.args.args: - if arg.arg == "self": - continue - - arg_type = parse_type_annotation(arg.annotation) - arg_data = {"name": arg.arg, "type": arg_type} - - if arg.arg in default_map: - arg_data["default"] = default_map[arg.arg] - - if arg_type in known_enums: - arg_data["options"] = known_enums[arg_type] - arg_data["isEnum"] = True - - args.append(arg_data) - return args - - -def process_class_node(class_node: ast.ClassDef, file_path_str: str, known_enums: Dict[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]: - steps = [] - class_name = class_node.name - for item in class_node.body: - if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): - step_name = None - # Check decorators - for decorator in item.decorator_list: - step_name = get_step_name_from_decorator(decorator) - if step_name: - break - - if step_name: - args = extract_args(item, known_enums) - docstring = ast.get_docstring(item) - - steps.append( - { - "id": f"{class_name}.{item.name}", - "name": step_name, - "functionName": item.name, - "className": class_name, - "description": docstring, - "parameters": args, - "filePath": file_path_str, - } - ) - return steps - - -def extract_scenario_steps(file_path: Path, project_root: Path, known_enums: Dict[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]: - with open(file_path, "r", encoding="utf-8") as f: - try: - tree = ast.parse(f.read(), filename=str(file_path)) - except SyntaxError: - print(f"Syntax error in {file_path}") - return [] - - try: - relative_path = file_path.relative_to(project_root) - except ValueError: - relative_path = file_path - - steps = [] - for node in ast.walk(tree): - if isinstance(node, ast.ClassDef): - steps.extend(process_class_node(node, str(relative_path), known_enums)) - return steps - - -def main(): - # Resolve paths relative to the script location - script_dir = Path(__file__).parent - project_root = script_dir.parent - - base_dir = project_root / "src/openutm_verification/core/clients" - models_file = project_root / "src/openutm_verification/models.py" - output_file = project_root / "web-editor/src/data/operations.json" - - # Extract Enums first - print(f"Extracting enums from {models_file}...") - known_enums = extract_enums(models_file) - print(f"Found enums: {list(known_enums.keys())}") - - all_steps = [] - - for root, _, files in os.walk(base_dir): - for file in files: - if file.endswith(".py") and not file.startswith("__"): - file_path = Path(root) / file - print(f"Processing {file_path}...") - steps = extract_scenario_steps(file_path, project_root, known_enums) - all_steps.extend(steps) - - print(f"Found {len(all_steps)} scenario steps.") - - with open(output_file, "w", encoding="utf-8") as f: - json.dump(all_steps, f, indent=2) - - print(f"Written metadata to {output_file}") - - -if __name__ == "__main__": - main() diff --git a/src/openutm_verification/core/clients/air_traffic/air_traffic_client.py b/src/openutm_verification/core/clients/air_traffic/air_traffic_client.py index d6a2f0f..d7813ff 100644 --- a/src/openutm_verification/core/clients/air_traffic/air_traffic_client.py +++ b/src/openutm_verification/core/clients/air_traffic/air_traffic_client.py @@ -50,7 +50,7 @@ async def generate_simulated_air_traffic_data( List of simulated flight observation dictionaries, or None if generation fails. """ config_path = config_path or self.settings.simulation_config_path - duration = duration or self.settings.simulation_duration_seconds + duration = duration or self.settings.simulation_duration number_of_aircraft = self.settings.number_of_aircraft session_ids = self.settings.sensor_ids diff --git a/src/openutm_verification/core/clients/air_traffic/base_client.py b/src/openutm_verification/core/clients/air_traffic/base_client.py index e12677c..1002ebb 100644 --- a/src/openutm_verification/core/clients/air_traffic/base_client.py +++ b/src/openutm_verification/core/clients/air_traffic/base_client.py @@ -14,7 +14,7 @@ class AirTrafficSettings(BaseSettings): # Simulation settings simulation_config_path: str - simulation_duration_seconds: int = 30 + simulation_duration: int = 30 number_of_aircraft: int = 2 single_or_multiple_sensors: str = "single" sensor_ids: list[str] = [] @@ -24,7 +24,7 @@ def create_air_traffic_settings() -> AirTrafficSettings: """Factory function to create AirTrafficSettings from config after initialization.""" return AirTrafficSettings( simulation_config_path=config.data_files.trajectory or "", - simulation_duration_seconds=config.air_traffic_simulator_settings.simulation_duration_seconds or 30, + simulation_duration=config.air_traffic_simulator_settings.simulation_duration or 30, number_of_aircraft=config.air_traffic_simulator_settings.number_of_aircraft or 2, single_or_multiple_sensors=config.air_traffic_simulator_settings.single_or_multiple_sensors or "single", sensor_ids=config.air_traffic_simulator_settings.sensor_ids or [], diff --git a/src/openutm_verification/core/clients/common/common_client.py b/src/openutm_verification/core/clients/common/common_client.py new file mode 100644 index 0000000..1c30e6b --- /dev/null +++ b/src/openutm_verification/core/clients/common/common_client.py @@ -0,0 +1,21 @@ +import asyncio +import uuid + +from loguru import logger + +from openutm_verification.core.execution.scenario_runner import scenario_step + + +class CommonClient: + @scenario_step("Generate UUID") + async def generate_uuid(self) -> str: + """Generates a random UUID.""" + return str(uuid.uuid4()) + + @scenario_step("Wait X seconds") + async def wait(self, duration: int = 5) -> str: + """Wait for a specified number of seconds.""" + logger.info(f"Waiting for {duration} seconds...") + await asyncio.sleep(duration) + logger.info(f"Waited for {duration} seconds.") + return f"Waited for Flight Blender to process {duration} seconds." 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 1bd41f3..201f4f4 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 @@ -43,6 +43,7 @@ from openutm_verification.simulator.models.flight_data_types import ( FlightObservationSchema, ) +from openutm_verification.utils.time_utils import parse_duration def _create_rid_operator_details(operation_id: str) -> RIDOperatorDetails: @@ -86,7 +87,15 @@ class FlightBlenderClient(BaseBlenderAPIClient): latest_flight_declaration_id: The ID of the most recently uploaded flight declaration. """ - def __init__(self, base_url: str, credentials: dict, request_timeout: int = 10) -> None: + def __init__( + self, + base_url: str, + credentials: dict, + request_timeout: int = 10, + flight_declaration_path: str | None = None, + trajectory_path: str | None = None, + geo_fence_path: str | None = None, + ) -> None: super().__init__(base_url=base_url, credentials=credentials, request_timeout=request_timeout) # Context: store the most recently created geo-fence id for teardown convenience self.latest_geo_fence_id: str | None = None @@ -94,6 +103,11 @@ def __init__(self, base_url: str, credentials: dict, request_timeout: int = 10) self.latest_flight_declaration_id: str | None = None # Context: store the generated telemetry states for the current scenario self.telemetry_states: list[RIDAircraftState] | None = None + + self.flight_declaration_path = flight_declaration_path + self.trajectory_path = trajectory_path + self.geo_fence_path = geo_fence_path + logger.debug(f"Initialized FlightBlenderClient with base_url={base_url}, request_timeout={request_timeout}") async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: @@ -121,8 +135,9 @@ async def upload_geo_fence(self, filename: str | None = None) -> dict[str, Any]: FlightBlenderError: If the upload request fails. json.JSONDecodeError: If the file content is invalid JSON. """ + filename = filename or self.geo_fence_path if filename is None: - raise ValueError("filename parameter is required for upload_geo_fence") + raise ValueError("filename parameter is required for upload_geo_fence and no default found in config") endpoint = "/geo_fence_ops/set_geo_fence" logger.debug(f"Uploading geo fence from {filename}") with open(filename, "r", encoding="utf-8") as geo_fence_json_file: @@ -324,14 +339,14 @@ async def wait_for_user_input(self, prompt: str = "Press Enter to continue...") return input(prompt) @scenario_step("Update Operation State") - async def update_operation_state(self, new_state: OperationState, duration_seconds: int = 0) -> dict[str, Any]: + async def update_operation_state(self, state: OperationState, duration: str | int | float = 0) -> dict[str, Any]: """Update the state of a flight operation. Posts the new state and optionally waits for the specified duration. Args: - new_state: The new OperationState to set. - duration_seconds: Optional seconds to sleep after update (default 0). + state: The new OperationState to set. + duration: Optional duration to sleep after update (default 0). Can be a number (seconds) or a string (e.g., "5s", "1m"). Returns: The JSON response from the API. @@ -339,12 +354,13 @@ async def update_operation_state(self, new_state: OperationState, duration_secon Raises: FlightBlenderError: If the update request fails. """ + duration_seconds = parse_duration(duration) endpoint = f"/flight_declaration_ops/flight_declaration_state/{self.latest_flight_declaration_id}" - logger.debug(f"Updating operation {self.latest_flight_declaration_id} to state {new_state.name}") - payload = {"state": new_state.value, "submitted_by": "hh@auth.com"} + logger.debug(f"Updating operation {self.latest_flight_declaration_id} to state {state.name}") + payload = {"state": state.value, "submitted_by": "hh@auth.com"} response = await self.put(endpoint, json=payload) - logger.info(f"Operation state updated for {self.latest_flight_declaration_id} to {new_state.name}") + logger.info(f"Operation state updated for {self.latest_flight_declaration_id} to {state.name}") if duration_seconds > 0: logger.debug(f"Sleeping for {duration_seconds} seconds after state update") await asyncio.sleep(duration_seconds) @@ -367,12 +383,13 @@ def _load_telemetry_file(self, filename: str) -> list[RIDAircraftState]: rid_json = json.loads(rid_json_file.read()) return [RIDAircraftState(**state) for state in rid_json["current_states"]] - async def _submit_telemetry_states_impl(self, states: list[RIDAircraftState], duration_seconds: int = 0) -> dict[str, Any] | None: + async def _submit_telemetry_states_impl(self, states: list[RIDAircraftState], duration: str | int | float = 0) -> dict[str, Any] | None: """Internal implementation for submitting telemetry states. Args: states: List of telemetry state dictionaries. - duration_seconds: Optional maximum duration in seconds to submit telemetry (default 0 for unlimited). + duration: Optional maximum duration to submit telemetry (default 0 for unlimited). Can be a number (seconds) or a string + (e.g., "5s", "1m"). Returns: The JSON response from the last telemetry submission, or None if no submissions occurred. @@ -380,6 +397,7 @@ async def _submit_telemetry_states_impl(self, states: list[RIDAircraftState], du Raises: FlightBlenderError: If maximum waiting time is exceeded due to rate limits. """ + duration_seconds = parse_duration(duration) endpoint = "/flight_stream/set_telemetry" assert self.latest_flight_declaration_id is not None, "Latest flight declaration ID must be set" logger.debug(f"Submitting telemetry for operation {self.latest_flight_declaration_id}") @@ -422,7 +440,7 @@ async def _submit_telemetry_states_impl(self, states: list[RIDAircraftState], du return last_response @scenario_step("Submit Telemetry (from file)") - async def submit_telemetry_from_file(self, filename: str, duration_seconds: int = 0) -> dict[str, Any] | None: + async def submit_telemetry_from_file(self, filename: str, duration: str | int | float = 0) -> dict[str, Any] | None: """Submit telemetry data for a flight operation. Loads telemetry states from file and submits them sequentially, with optional @@ -430,7 +448,8 @@ async def submit_telemetry_from_file(self, filename: str, duration_seconds: int Args: filename: Path to the JSON file containing telemetry data. - duration_seconds: Optional maximum duration in seconds to submit telemetry (default 0 for unlimited). + duration: Optional maximum duration to submit telemetry (default 0 for unlimited). Can be a number (seconds) or a string + (e.g., "5s", "1m"). Returns: The JSON response from the last telemetry submission, or None if no submissions occurred. @@ -439,18 +458,10 @@ async def submit_telemetry_from_file(self, filename: str, duration_seconds: int FlightBlenderError: If maximum waiting time is exceeded due to rate limits. """ states = self._load_telemetry_file(filename) - return await self._submit_telemetry_states_impl(states, duration_seconds) - - @scenario_step("Wait X seconds") - async def wait_x_seconds(self, wait_time_seconds: int = 5) -> str: - """Wait for a specified number of seconds.""" - logger.info(f"Waiting for {wait_time_seconds} seconds...") - await asyncio.sleep(wait_time_seconds) - logger.info(f"Waited for {wait_time_seconds} seconds.") - return f"Waited for Flight Blender to process {wait_time_seconds} seconds." + return await self._submit_telemetry_states_impl(states, duration) @scenario_step("Submit Telemetry") - async def submit_telemetry(self, states: list[RIDAircraftState] | None = None, duration_seconds: int = 0) -> dict[str, Any] | None: + async def submit_telemetry(self, states: list[RIDAircraftState] | None = None, duration: str | int | float = 0) -> dict[str, Any] | None: """Submit telemetry data for a flight operation from in-memory states. Submits telemetry states sequentially from the provided list, with optional @@ -458,7 +469,8 @@ async def submit_telemetry(self, states: list[RIDAircraftState] | None = None, d Args: states: List of telemetry state dictionaries. If None, uses the generated telemetry states from context. - duration_seconds: Optional maximum duration in seconds to submit telemetry (default 0 for unlimited). + duration: Optional maximum duration to submit telemetry (default 0 for unlimited). Can be a number (seconds) or a string + (e.g., "5s", "1m"). Returns: The JSON response from the last telemetry submission, or None if no submissions occurred. @@ -470,13 +482,13 @@ async def submit_telemetry(self, states: list[RIDAircraftState] | None = None, d if telemetry_states is None: raise ValueError("Telemetry states are required and could not be resolved from context.") - return await self._submit_telemetry_states_impl(telemetry_states, duration_seconds) + return await self._submit_telemetry_states_impl(telemetry_states, duration) @scenario_step("Check Operation State") async def check_operation_state( self, expected_state: OperationState, - duration_seconds: int = 0, + duration: str | int | float = 0, ) -> str: """Check the operation state (simulated). @@ -485,11 +497,12 @@ async def check_operation_state( Args: expected_state: The expected OperationState. - duration_seconds: Seconds to wait for processing. + duration: Duration to wait for processing. Can be a number (seconds) or a string (e.g., "5s", "1m"). Returns: A dictionary with the check result. """ + duration_seconds = parse_duration(duration) logger.info(f"Checking operation state for {self.latest_flight_declaration_id} (simulated)...") logger.info(f"Waiting for {duration_seconds} seconds for Flight Blender to process state...") await asyncio.sleep(duration_seconds) @@ -500,13 +513,13 @@ async def check_operation_state( async def check_operation_state_connected( self, expected_state: OperationState, - duration_seconds: int = 0, + duration: str | int | float = 0, ) -> dict[str, Any]: """Check the operation state by polling the API until the expected state is reached. Args: expected_state: The expected OperationState. - duration_seconds: Maximum seconds to poll for the state. + duration: Maximum duration to poll for the state. Can be a number (seconds) or a string (e.g., "5s", "1m"). Returns: The JSON response from the API when the state is reached. @@ -514,6 +527,7 @@ async def check_operation_state_connected( Raises: FlightBlenderError: If the expected state is not reached within the timeout. """ + duration_seconds = parse_duration(duration) endpoint = f"/flight_declaration_ops/flight_declaration/{self.latest_flight_declaration_id}" logger.info(f"Checking operation state for {self.latest_flight_declaration_id}, expecting {expected_state.name}") start_time = time.time() @@ -847,6 +861,7 @@ async def setup_flight_declaration_via_operational_intent( generate_flight_declaration_via_operational_intent, generate_telemetry, ) + flight_declaration = generate_flight_declaration_via_operational_intent(flight_declaration_via_operational_intent_path) telemetry_states = generate_telemetry(trajectory_path) @@ -864,7 +879,11 @@ async def setup_flight_declaration_via_operational_intent( raise FlightBlenderError("Failed to upload flight declaration during setup_flight_declaration_via_operational_intent") @scenario_step("Setup Flight Declaration") - async def setup_flight_declaration(self, flight_declaration_path: str, trajectory_path: str) -> None: + async def setup_flight_declaration( + self, + flight_declaration_path: str | None = None, + trajectory_path: str | None = None, + ) -> None: """Generates data and uploads flight declaration.""" from openutm_verification.scenarios.common import ( @@ -872,6 +891,16 @@ async def setup_flight_declaration(self, flight_declaration_path: str, trajector generate_telemetry, ) + # Use instance attributes if arguments are not provided + flight_declaration_path = flight_declaration_path or self.flight_declaration_path + trajectory_path = trajectory_path or self.trajectory_path + + if not flight_declaration_path: + raise ValueError("flight_declaration_path not provided and not found in config") + + if not trajectory_path: + raise ValueError("trajectory_path not provided and not found in config") + flight_declaration = generate_flight_declaration(flight_declaration_path) telemetry_states = generate_telemetry(trajectory_path) diff --git a/src/openutm_verification/core/clients/opensky/base_client.py b/src/openutm_verification/core/clients/opensky/base_client.py index 5273d51..4e3d378 100644 --- a/src/openutm_verification/core/clients/opensky/base_client.py +++ b/src/openutm_verification/core/clients/opensky/base_client.py @@ -29,7 +29,7 @@ class OpenSkySettings(BaseSettings): # Simulation settings simulation_config_path: str - simulation_duration_seconds: int = 30 + simulation_duration: int = 30 def create_opensky_settings() -> OpenSkySettings: @@ -38,7 +38,7 @@ def create_opensky_settings() -> OpenSkySettings: opensky_client_id=config.opensky.auth.client_id or "", opensky_client_secret=config.opensky.auth.client_secret or "", simulation_config_path=config.data_files.trajectory or "", - simulation_duration_seconds=30, + simulation_duration=30, ) diff --git a/src/openutm_verification/core/execution/config_models.py b/src/openutm_verification/core/execution/config_models.py index d0dc55a..66658a7 100644 --- a/src/openutm_verification/core/execution/config_models.py +++ b/src/openutm_verification/core/execution/config_models.py @@ -9,6 +9,8 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator +from openutm_verification.utils.time_utils import parse_duration + class StrictBaseModel(BaseModel): model_config = ConfigDict(extra="forbid") @@ -35,10 +37,15 @@ class FlightBlenderConfig(StrictBaseModel): class AirTrafficSimulatorSettings(StrictBaseModel): number_of_aircraft: int - simulation_duration_seconds: int + simulation_duration: int | str single_or_multiple_sensors: Literal["single", "multiple"] = "single" sensor_ids: list[str] = Field(default_factory=list) + @field_validator("simulation_duration") + @classmethod + def validate_duration(cls, v: int | str) -> int: + return int(parse_duration(v)) + class OpenSkyConfig(StrictBaseModel): """OpenSky Network connection details.""" diff --git a/src/openutm_verification/core/execution/definitions.py b/src/openutm_verification/core/execution/definitions.py index 043edfe..d75eea6 100644 --- a/src/openutm_verification/core/execution/definitions.py +++ b/src/openutm_verification/core/execution/definitions.py @@ -1,15 +1,18 @@ from typing import Any, Dict, List -from uuid import uuid4 from pydantic import BaseModel, Field class StepDefinition(BaseModel): - id: str = Field(default_factory=lambda: uuid4().hex) - name: str - parameters: Dict[str, Any] - run_in_background: bool = False + id: str | None = Field(None, description="Unique identifier for the step. If not provided, it defaults to the step name.") + step: str = Field(..., description="The operation/function to execute (human-readable name)") + arguments: Dict[str, Any] = Field(default_factory=dict, description="Arguments for the operation") + needs: List[str] = Field(default_factory=list, description="List of step IDs this step depends on") + background: bool = Field(False, description="Whether to run this step in the background") + description: str | None = None class ScenarioDefinition(BaseModel): + name: str + description: str | None = None steps: List[StepDefinition] diff --git a/src/openutm_verification/core/execution/dependencies.py b/src/openutm_verification/core/execution/dependencies.py index 2a9b0a8..71d18b9 100644 --- a/src/openutm_verification/core/execution/dependencies.py +++ b/src/openutm_verification/core/execution/dependencies.py @@ -18,6 +18,7 @@ from openutm_verification.core.clients.air_traffic.base_client import ( create_air_traffic_settings, ) +from openutm_verification.core.clients.common.common_client import CommonClient from openutm_verification.core.clients.flight_blender.flight_blender_client import ( FlightBlenderClient, ) @@ -162,13 +163,12 @@ def app_config() -> Generator[AppConfig, None, None]: @dependency(FlightBlenderClient) -async def flight_blender_client( - config: AppConfig, -) -> AsyncGenerator[FlightBlenderClient, None]: +async def flight_blender_client(config: AppConfig, data_files: DataFiles) -> AsyncGenerator[FlightBlenderClient, None]: """Provides a FlightBlenderClient instance for dependency injection. Args: config: The application configuration containing Flight Blender settings. + data_files: The data files configuration. Returns: An instance of FlightBlenderClient. """ @@ -177,7 +177,13 @@ async def flight_blender_client( audience=config.flight_blender.auth.audience or "", scopes=config.flight_blender.auth.scopes or [], ) - async with FlightBlenderClient(base_url=config.flight_blender.url, credentials=credentials) as fb_client: + async with FlightBlenderClient( + base_url=config.flight_blender.url, + credentials=credentials, + flight_declaration_path=data_files.flight_declaration, + trajectory_path=data_files.trajectory, + geo_fence_path=data_files.geo_fence, + ) as fb_client: yield fb_client @@ -202,3 +208,8 @@ async def air_traffic_client( @dependency(SessionManager) async def session_manager() -> AsyncGenerator[SessionManager, None]: yield SessionManager() + + +@dependency(CommonClient) +async def common_client() -> AsyncGenerator[CommonClient, None]: + yield CommonClient() diff --git a/src/openutm_verification/core/execution/dependency_resolution.py b/src/openutm_verification/core/execution/dependency_resolution.py index 96727ba..12cd2a8 100644 --- a/src/openutm_verification/core/execution/dependency_resolution.py +++ b/src/openutm_verification/core/execution/dependency_resolution.py @@ -2,6 +2,9 @@ from contextlib import AsyncExitStack, asynccontextmanager, contextmanager from contextvars import ContextVar from typing import Any, AsyncContextManager, AsyncGenerator, Callable, ContextManager, Coroutine, Generator, TypeVar, cast +from unittest.mock import Mock + +from pydantic import ConfigDict, validate_call from openutm_verification.core.execution.config_models import RunContext @@ -109,7 +112,11 @@ async def call_with_dependencies(func: Callable[..., Coroutine[Any, Any, T]], re call_kwargs[name] = await resolver.resolve(param.annotation) if inspect.iscoroutinefunction(func): - return await func(**call_kwargs) + if isinstance(func, Mock): + validated_func = func + else: + validated_func = validate_call(func, config=ConfigDict(arbitrary_types_allowed=True)) # type: ignore + return await validated_func(**call_kwargs) raise ValueError(f"Function {func.__name__} must be async") else: async with AsyncExitStack() as stack: diff --git a/src/openutm_verification/core/execution/execution.py b/src/openutm_verification/core/execution/execution.py index 95ebfad..f0f32e3 100644 --- a/src/openutm_verification/core/execution/execution.py +++ b/src/openutm_verification/core/execution/execution.py @@ -89,7 +89,7 @@ async def run_verification_scenarios(config: AppConfig, config_path: Path): result = ScenarioResult( name=scenario_id, status=Status.FAIL, - duration_seconds=0, + duration=0, steps=[], error_message=str(e), docs=None, @@ -108,7 +108,7 @@ async def run_verification_scenarios(config: AppConfig, config_path: Path): end_time_obj = datetime.now(timezone.utc) end_time_utc = end_time_obj.isoformat() - total_duration_seconds = (end_time_obj - start_time_obj).total_seconds() + total_duration = (end_time_obj - start_time_obj).total_seconds() failed_scenarios = sum(1 for r in scenario_results if r.status == Status.FAIL) overall_status = Status.FAIL if failed_scenarios > 0 else Status.PASS @@ -120,7 +120,7 @@ async def run_verification_scenarios(config: AppConfig, config_path: Path): tool_version=version("openutm-verification"), start_time_utc=start_time_utc, end_time_utc=end_time_utc, - total_duration_seconds=total_duration_seconds, + total_duration=total_duration, overall_status=overall_status, flight_blender_url=config.flight_blender.url, deployment_details=config.reporting.deployment_details, diff --git a/src/openutm_verification/core/execution/scenario_runner.py b/src/openutm_verification/core/execution/scenario_runner.py index 5e11d5c..5c6fa87 100644 --- a/src/openutm_verification/core/execution/scenario_runner.py +++ b/src/openutm_verification/core/execution/scenario_runner.py @@ -261,7 +261,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] | None = None + step_result: StepResult[Any] try: with logger.contextualize(step_execution_id=step_execution_id): logger.info("-" * 50) @@ -286,7 +286,12 @@ def log_filter(record): return async_wrapper def __set_name__(self, owner: type, name: str): - STEP_REGISTRY[self.step_name] = StepRegistryEntry( + # Register using the human-readable step name + registry_key = self.step_name + if registry_key in STEP_REGISTRY: + logger.warning(f"Overwriting step registry for '{registry_key}'. Ensure step names are unique.") + + STEP_REGISTRY[registry_key] = StepRegistryEntry( client_class=owner, method_name=name, param_model=self.param_model, diff --git a/src/openutm_verification/core/reporting/reporting_models.py b/src/openutm_verification/core/reporting/reporting_models.py index ca0966e..ba35a76 100644 --- a/src/openutm_verification/core/reporting/reporting_models.py +++ b/src/openutm_verification/core/reporting/reporting_models.py @@ -49,7 +49,7 @@ class ScenarioResult(BaseModel): name: str suite_name: str | None = None status: Status - duration_seconds: float + duration: float steps: list[StepResult[Any]] error_message: str | None = None flight_declaration_filename: str | None = None @@ -78,7 +78,7 @@ class ReportData(BaseModel): tool_version: str start_time_utc: str end_time_utc: str - total_duration_seconds: float + total_duration: float overall_status: Status flight_blender_url: str deployment_details: DeploymentDetails diff --git a/src/openutm_verification/core/templates/report_template.html b/src/openutm_verification/core/templates/report_template.html index 23c9058..ebab426 100644 --- a/src/openutm_verification/core/templates/report_template.html +++ b/src/openutm_verification/core/templates/report_template.html @@ -32,7 +32,7 @@

Run Summary

Tool Version{{ report_data.tool_version }} Start Time (UTC){{ report_data.start_time_utc }} End Time (UTC){{ report_data.end_time_utc }} - Duration{{ "%.2f"|format(report_data.total_duration_seconds) }} seconds + Duration{{ "%.2f"|format(report_data.total_duration) }} seconds Overall Status{{ report_data.overall_status }} Config File{{ report_data.config_file }} @@ -73,7 +73,7 @@

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

{{ result.status }}
-

Duration: {{ "%.2f"|format(result.duration_seconds) }} seconds

+

Duration: {{ "%.2f"|format(result.duration) }} seconds

{% if result.visualization_2d_path or result.visualization_3d_path %}

Flight Visualizations: {% if result.visualization_2d_path %} diff --git a/src/openutm_verification/models.py b/src/openutm_verification/models.py index f89e73c..38d55ab 100644 --- a/src/openutm_verification/models.py +++ b/src/openutm_verification/models.py @@ -6,7 +6,17 @@ class FlightBlenderError(Exception): """Custom exception for Flight Blender API errors.""" -class OperationState(int, Enum): +class CaseInsensitiveEnum(Enum): + @classmethod + def _missing_(cls, value): + if isinstance(value, str): + for member in cls: + if member.name.lower() == value.lower(): + return member + return super()._missing_(value) + + +class OperationState(int, CaseInsensitiveEnum): """An enumeration for the state of a flight operation.""" PROCESSING = 0 @@ -20,7 +30,7 @@ class OperationState(int, Enum): REJECTED = 8 -class SDSPSessionAction(str, Enum): +class SDSPSessionAction(str, CaseInsensitiveEnum): START = "start" STOP = "stop" diff --git a/src/openutm_verification/scenarios/common.py b/src/openutm_verification/scenarios/common.py index 396f1f0..9a06199 100644 --- a/src/openutm_verification/scenarios/common.py +++ b/src/openutm_verification/scenarios/common.py @@ -1,9 +1,11 @@ import json +import uuid from pathlib import Path from loguru import logger from uas_standards.astm.f3411.v22a.api import RIDAircraftState +from openutm_verification.core.execution.scenario_runner import scenario_step from openutm_verification.simulator.flight_declaration import FlightDeclarationGenerator from openutm_verification.simulator.geo_json_telemetry import GeoJSONFlightsSimulator from openutm_verification.simulator.models.declaration_models import FlightDeclaration, FlightDeclarationViaOperationalIntent @@ -55,3 +57,9 @@ def get_geo_fence_path(geo_fence_filename: str) -> str: """Helper to get the full path to a geo-fence file.""" parent_dir = Path(__file__).parent.resolve() return str(parent_dir / f"../assets/aoi_geo_fence_samples/{geo_fence_filename}") + + +@scenario_step("Generate UUID") +async def generate_uuid() -> str: + """Generates a random UUID.""" + return str(uuid.uuid4()) diff --git a/src/openutm_verification/scenarios/registry.py b/src/openutm_verification/scenarios/registry.py index 1b0ff4d..f58a79d 100644 --- a/src/openutm_verification/scenarios/registry.py +++ b/src/openutm_verification/scenarios/registry.py @@ -59,7 +59,7 @@ async def _run_scenario_simple_async(scenario_id: str, func: Callable, args, kwa return ScenarioResult( name=scenario_id, status=final_status, - duration_seconds=total_duration, + duration=total_duration, steps=steps, flight_declaration_data=flight_declaration_data, flight_declaration_via_operational_intent_data=flight_declaration_via_operational_intent_data, diff --git a/src/openutm_verification/scenarios/test_add_flight_declaration.py b/src/openutm_verification/scenarios/test_add_flight_declaration.py index 3415149..b6d6bf1 100644 --- a/src/openutm_verification/scenarios/test_add_flight_declaration.py +++ b/src/openutm_verification/scenarios/test_add_flight_declaration.py @@ -23,6 +23,6 @@ async def test_add_flight_declaration(fb_client: FlightBlenderClient, data_files A ScenarioResult object containing the results of the scenario execution. """ async with fb_client.create_flight_declaration(data_files): - await fb_client.update_operation_state(new_state=OperationState.ACTIVATED, duration_seconds=20) - await fb_client.submit_telemetry(duration_seconds=30) - await fb_client.update_operation_state(new_state=OperationState.ENDED) + await fb_client.update_operation_state(state=OperationState.ACTIVATED, duration=20) + await fb_client.submit_telemetry(duration=30) + await fb_client.update_operation_state(state=OperationState.ENDED) diff --git a/src/openutm_verification/scenarios/test_f1_flow.py b/src/openutm_verification/scenarios/test_f1_flow.py index 02dffb6..44076b3 100644 --- a/src/openutm_verification/scenarios/test_f1_flow.py +++ b/src/openutm_verification/scenarios/test_f1_flow.py @@ -21,8 +21,8 @@ async def test_f1_happy_path(fb_client: FlightBlenderClient, data_files: DataFil A ScenarioResult object containing the results of the scenario execution. """ async with fb_client.create_flight_declaration(data_files): - await fb_client.update_operation_state(new_state=OperationState.ACTIVATED) - await fb_client.submit_telemetry(duration_seconds=30) - await fb_client.update_operation_state(new_state=OperationState.ENDED) + await fb_client.update_operation_state(state=OperationState.ACTIVATED) + await fb_client.submit_telemetry(duration=30) + await fb_client.update_operation_state(state=OperationState.ENDED) await fb_client.teardown_flight_declaration() diff --git a/src/openutm_verification/scenarios/test_f1_no_telemetry_with_user_input.py b/src/openutm_verification/scenarios/test_f1_no_telemetry_with_user_input.py index fe118d2..0cb60b0 100644 --- a/src/openutm_verification/scenarios/test_f1_no_telemetry_with_user_input.py +++ b/src/openutm_verification/scenarios/test_f1_no_telemetry_with_user_input.py @@ -1,3 +1,4 @@ +from openutm_verification.core.clients.common.common_client import CommonClient from openutm_verification.core.clients.flight_blender.flight_blender_client import FlightBlenderClient from openutm_verification.core.execution.config_models import DataFiles from openutm_verification.models import OperationState @@ -5,7 +6,7 @@ @register_scenario("F1_flow_no_telemetry_with_user_input") -async def test_f1_no_telemetry_with_user_input(fb_client: FlightBlenderClient, data_files: DataFiles): +async def test_f1_no_telemetry_with_user_input(fb_client: FlightBlenderClient, data_files: DataFiles, common_client: CommonClient): """Runs the F1 no telemetry with user input scenario. This scenario simulates a complete, successful flight operation: @@ -21,8 +22,8 @@ async def test_f1_no_telemetry_with_user_input(fb_client: FlightBlenderClient, d A ScenarioResult object containing the results of the scenario execution. """ async with fb_client.create_flight_declaration(data_files): - await fb_client.wait_x_seconds(wait_time_seconds=5) - await fb_client.update_operation_state(new_state=OperationState.ACTIVATED) + await common_client.wait(duration=5) + await fb_client.update_operation_state(state=OperationState.ACTIVATED) await fb_client.wait_for_user_input(prompt="Press Enter to end the operation...") - await fb_client.update_operation_state(new_state=OperationState.ENDED) + await fb_client.update_operation_state(state=OperationState.ENDED) await fb_client.teardown_flight_declaration() diff --git a/src/openutm_verification/scenarios/test_f2_flow.py b/src/openutm_verification/scenarios/test_f2_flow.py index 8acd8a9..af2ea6c 100644 --- a/src/openutm_verification/scenarios/test_f2_flow.py +++ b/src/openutm_verification/scenarios/test_f2_flow.py @@ -22,9 +22,9 @@ async def test_f2_contingent_path(fb_client: FlightBlenderClient, data_files: Da A ScenarioResult object containing the results of the scenario execution. """ async with fb_client.create_flight_declaration(data_files): - await fb_client.update_operation_state(new_state=OperationState.ACTIVATED) - await fb_client.submit_telemetry(duration_seconds=10) - await fb_client.update_operation_state(new_state=OperationState.CONTINGENT, duration_seconds=7) - await fb_client.update_operation_state(new_state=OperationState.ENDED) + await fb_client.update_operation_state(state=OperationState.ACTIVATED) + await fb_client.submit_telemetry(duration=10) + await fb_client.update_operation_state(state=OperationState.CONTINGENT, duration=7) + await fb_client.update_operation_state(state=OperationState.ENDED) await fb_client.teardown_flight_declaration() diff --git a/src/openutm_verification/scenarios/test_f3_flow.py b/src/openutm_verification/scenarios/test_f3_flow.py index 686338a..26deff5 100644 --- a/src/openutm_verification/scenarios/test_f3_flow.py +++ b/src/openutm_verification/scenarios/test_f3_flow.py @@ -1,3 +1,4 @@ +from openutm_verification.core.clients.common.common_client import CommonClient from openutm_verification.core.clients.flight_blender.flight_blender_client import FlightBlenderClient from openutm_verification.core.execution.config_models import DataFiles from openutm_verification.models import OperationState @@ -5,7 +6,7 @@ @register_scenario("F3_non_conforming_path") -async def test_f3_non_conforming_path(fb_client: FlightBlenderClient, data_files: DataFiles): +async def test_f3_non_conforming_path(fb_client: FlightBlenderClient, data_files: DataFiles, common_client: CommonClient): """Runs the F3 non-conforming path scenario. This scenario simulates a flight that deviates from its declared flight plan, @@ -23,10 +24,10 @@ async def test_f3_non_conforming_path(fb_client: FlightBlenderClient, data_files A ScenarioResult object containing the results of the scenario execution. """ async with fb_client.create_flight_declaration(data_files): - await fb_client.update_operation_state(new_state=OperationState.ACTIVATED) - await fb_client.wait_x_seconds(5) - await fb_client.submit_telemetry(duration_seconds=20) - await fb_client.check_operation_state(expected_state=OperationState.NONCONFORMING, duration_seconds=5) - await fb_client.update_operation_state(new_state=OperationState.ENDED) + await fb_client.update_operation_state(state=OperationState.ACTIVATED) + await common_client.wait(5) + await fb_client.submit_telemetry(duration=20) + await fb_client.check_operation_state(expected_state=OperationState.NONCONFORMING, duration=5) + await fb_client.update_operation_state(state=OperationState.ENDED) await fb_client.teardown_flight_declaration() diff --git a/src/openutm_verification/scenarios/test_f5_flow.py b/src/openutm_verification/scenarios/test_f5_flow.py index 232ffea..e23b9af 100644 --- a/src/openutm_verification/scenarios/test_f5_flow.py +++ b/src/openutm_verification/scenarios/test_f5_flow.py @@ -7,10 +7,10 @@ @register_scenario("F5_non_conforming_path") async def test_f5_non_conforming_contingent_path(fb_client: FlightBlenderClient, data_files: DataFiles) -> None: async with fb_client.create_flight_declaration(data_files): - await fb_client.update_operation_state(new_state=OperationState.ACTIVATED) - await fb_client.submit_telemetry(duration_seconds=20) - await fb_client.check_operation_state_connected(expected_state=OperationState.NONCONFORMING, duration_seconds=5) - await fb_client.update_operation_state(new_state=OperationState.CONTINGENT) - await fb_client.update_operation_state(new_state=OperationState.ENDED) + await fb_client.update_operation_state(state=OperationState.ACTIVATED) + await fb_client.submit_telemetry(duration=20) + await fb_client.check_operation_state_connected(expected_state=OperationState.NONCONFORMING, duration=5) + await fb_client.update_operation_state(state=OperationState.CONTINGENT) + await fb_client.update_operation_state(state=OperationState.ENDED) await fb_client.teardown_flight_declaration() diff --git a/src/openutm_verification/scenarios/test_sdsp_heartbeat.py b/src/openutm_verification/scenarios/test_sdsp_heartbeat.py index fa8f917..06ac1a3 100644 --- a/src/openutm_verification/scenarios/test_sdsp_heartbeat.py +++ b/src/openutm_verification/scenarios/test_sdsp_heartbeat.py @@ -2,6 +2,7 @@ from loguru import logger +from openutm_verification.core.clients.common.common_client import CommonClient from openutm_verification.core.clients.flight_blender.flight_blender_client import ( FlightBlenderClient, ) @@ -10,7 +11,7 @@ @register_scenario("sdsp_heartbeat") -async def sdsp_heartbeat(fb_client: FlightBlenderClient): +async def sdsp_heartbeat(fb_client: FlightBlenderClient, common_client: CommonClient): """Runs the SDSP heartbeat scenario. This scenario """ @@ -22,7 +23,7 @@ async def sdsp_heartbeat(fb_client: FlightBlenderClient): session_id=session_id, ) # Wait for some time to simulate heartbeat period - await fb_client.wait_x_seconds(wait_time_seconds=2) + await common_client.wait(duration=2) await fb_client.initialize_verify_sdsp_heartbeat( session_id=session_id, @@ -30,7 +31,7 @@ async def sdsp_heartbeat(fb_client: FlightBlenderClient): expected_heartbeat_count=3, ) - await fb_client.wait_x_seconds(wait_time_seconds=5) + await common_client.wait(duration=5) await fb_client.start_stop_sdsp_session( action=SDSPSessionAction.STOP, diff --git a/src/openutm_verification/scenarios/test_sdsp_track.py b/src/openutm_verification/scenarios/test_sdsp_track.py index 8e65864..a123c14 100644 --- a/src/openutm_verification/scenarios/test_sdsp_track.py +++ b/src/openutm_verification/scenarios/test_sdsp_track.py @@ -4,6 +4,7 @@ from loguru import logger from openutm_verification.core.clients.air_traffic.air_traffic_client import AirTrafficClient +from openutm_verification.core.clients.common.common_client import CommonClient from openutm_verification.core.clients.flight_blender.flight_blender_client import ( FlightBlenderClient, ) @@ -12,7 +13,7 @@ @register_scenario("sdsp_track") -async def sdsp_track(fb_client: FlightBlenderClient, air_traffic_client: AirTrafficClient) -> None: +async def sdsp_track(fb_client: FlightBlenderClient, air_traffic_client: AirTrafficClient, common_client: CommonClient) -> None: """Runs the SDSP track scenario. This scenario """ @@ -29,7 +30,7 @@ async def sdsp_track(fb_client: FlightBlenderClient, air_traffic_client: AirTraf task = asyncio.create_task(fb_client.submit_simulated_air_traffic(observations=observations)) # Task is now running, concurrently while any other `async await` calls are done. # Wait for some time to simulate track period - await fb_client.wait_x_seconds(wait_time_seconds=2) + await common_client.wait(duration=2) await fb_client.initialize_verify_sdsp_track( session_id=session_id, @@ -37,7 +38,7 @@ async def sdsp_track(fb_client: FlightBlenderClient, air_traffic_client: AirTraf expected_track_count=3, ) - await fb_client.wait_x_seconds(wait_time_seconds=5) + await common_client.wait(duration=5) await fb_client.start_stop_sdsp_session( action=SDSPSessionAction.STOP, diff --git a/src/openutm_verification/server/router.py b/src/openutm_verification/server/router.py index 52446b1..b87d521 100644 --- a/src/openutm_verification/server/router.py +++ b/src/openutm_verification/server/router.py @@ -1,13 +1,19 @@ +from pathlib import Path from typing import Any, Type, TypeVar -from fastapi import APIRouter, Depends, Request +import yaml +from fastapi import APIRouter, Depends, HTTPException, Request -from openutm_verification.core.execution.definitions import StepDefinition +from openutm_verification.core.execution.definitions import ScenarioDefinition, StepDefinition T = TypeVar("T") scenario_router = APIRouter() +# Define the scenarios directory relative to this file +# src/openutm_verification/server/router.py -> .../scenarios +SCENARIOS_DIR = Path(__file__).parents[3] / "scenarios" + def get_runner(request: Request) -> Any: return request.app.state.runner @@ -26,3 +32,46 @@ async def dependency(runner: Any = Depends(get_runner)) -> T: @scenario_router.post("/api/step") async def execute_step(step: StepDefinition, runner: Any = Depends(get_runner)): return await runner.execute_single_step(step) + + +@scenario_router.get("/api/scenarios") +async def list_scenarios(): + """List all available scenarios.""" + if not SCENARIOS_DIR.exists(): + return [] + return [f.stem for f in SCENARIOS_DIR.glob("*.yaml")] + + +@scenario_router.get("/api/scenarios/{scenario}") +async def get_scenario(scenario: str): + """Get the content of a specific scenario.""" + file_path = (SCENARIOS_DIR / scenario).with_suffix(".yaml") + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Scenario not found") + + with open(file_path, "r") as f: + try: + content = yaml.safe_load(f) + return content + except yaml.YAMLError as e: + raise HTTPException(status_code=500, detail=f"Invalid YAML: {e}") + + +@scenario_router.post("/api/scenarios/{name}") +async def save_scenario(name: str, scenario: ScenarioDefinition): + """Save a scenario to a YAML file.""" + file_path = (SCENARIOS_DIR / name).with_suffix(".yaml") + + # Ensure directory exists + SCENARIOS_DIR.mkdir(parents=True, exist_ok=True) + + try: + # Convert Pydantic model to dict, excluding None values to keep YAML clean + data = scenario.model_dump(exclude_none=True, exclude_defaults=True) + + with open(file_path, "w") as f: + yaml.dump(data, f, sort_keys=False, default_flow_style=False) + + return {"message": f"Scenario saved to {file_path.name}"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to save scenario: {e}") diff --git a/src/openutm_verification/server/runner.py b/src/openutm_verification/server/runner.py index 4d44a3e..81271a2 100644 --- a/src/openutm_verification/server/runner.py +++ b/src/openutm_verification/server/runner.py @@ -1,5 +1,6 @@ import asyncio import os +import re from contextlib import AsyncExitStack from pathlib import Path from typing import Any, Callable, Coroutine, Dict, List, Type, TypeVar, cast @@ -45,6 +46,7 @@ def __init__(self, config_path: str = "config/default.yaml"): self.session_resolver: DependencyResolver | None = None self.session_context: ScenarioContext | None = None self.session_tasks: Dict[str, asyncio.Task] = {} + self.data_files: DataFiles | None = None self._initialized = True @scenario_step("Join Background Task") @@ -85,8 +87,8 @@ async def initialize_session(self): # Pre-generate data using resolved DataFiles try: - data_files = cast(DataFiles, await self.session_resolver.resolve(DataFiles)) - flight_declaration, telemetry_states = self._generate_data(data_files) + self.data_files = cast(DataFiles, await self.session_resolver.resolve(DataFiles)) + flight_declaration, telemetry_states = self._generate_data(self.data_files) self.session_context = ScenarioContext() with self.session_context: @@ -108,22 +110,50 @@ async def close_session(self): _scenario_state.set(None) def _resolve_ref(self, ref: str) -> Any: - # ref format: "step_id.field.subfield" or just "step_id" + if ref.startswith("data_files."): + if not self.data_files: + raise ValueError("Data files not initialized") + parts = ref.split(".") + if len(parts) != 2: + raise ValueError(f"Invalid data_files reference: {ref}") + attr = parts[1] + if not hasattr(self.data_files, attr): + raise ValueError(f"Data file '{attr}' not found") + return getattr(self.data_files, attr) + + if not ref.startswith("steps."): + # Fallback for legacy refs if any, or just error + raise ValueError(f"Invalid reference format: {ref}. Expected 'steps..result...' or 'data_files.'") + parts = ref.split(".") - step_id = parts[0] + if len(parts) < 3: + raise ValueError(f"Invalid reference format: {ref}. Expected 'steps..result...'") + + step_name = parts[1] + # parts[2] is likely "result" or "details" if not self.session_context or not self.session_context.state: raise ValueError("No active scenario context or state available") state = self.session_context.state - if step_id not in state.step_results: - raise ValueError(f"Referenced step '{step_id}' not found in results") + if step_name not in state.step_results: + logger.error(f"Step '{step_name}' not found in results. Available steps: {list(state.step_results.keys())}") + raise ValueError(f"Referenced step '{step_name}' not found in results") + + step_result = state.step_results[step_name] - current_value = state.step_results[step_id] + # Start traversing from the step result object + current_value = step_result - # Traverse the rest of the path - for part in parts[1:]: + # Skip "steps" and "step_name" + remaining_parts = parts[2:] + + # Handle "result" alias for "details" + if remaining_parts[0] == "result": + remaining_parts[0] = "details" + + for part in remaining_parts: if not part: continue if isinstance(current_value, dict): @@ -133,20 +163,41 @@ def _resolve_ref(self, ref: str) -> Any: else: raise ValueError( f"Could not resolve '{part}' in '{ref}'." - f"Available keys: {list(current_value.keys()) if isinstance(current_value, dict) else 'Not a dict'}" + f"Available keys: {list(current_value.keys()) if isinstance(current_value, dict) else dir(current_value)}" ) return current_value def resolve_references_in_params(self, params: Dict[str, Any]) -> None: + # Regex to find ${{ ... }} patterns + pattern = re.compile(r"\$\{\{\s*(.*?)\s*\}\}") + + def resolve_value(value: Any) -> Any: + if isinstance(value, str): + match = pattern.fullmatch(value) + if match: + # Entire string is a reference + ref = match.group(1) + try: + resolved = self._resolve_ref(ref) + logger.info(f"Resolved reference {ref} to {resolved}") + return resolved + except Exception as e: + logger.error(f"Failed to resolve reference {ref}: {e}") + raise + + # Check for partial matches (string interpolation) + # For now, let's only support full matches for simplicity and type safety + # If we need interpolation "Session ID: ${{ ... }}", we can add it later. + return value + elif isinstance(value, dict): + return {k: resolve_value(v) for k, v in value.items()} + elif isinstance(value, list): + return [resolve_value(v) for v in value] + return value + for key, value in params.items(): - if isinstance(value, dict) and "$ref" in value: - try: - params[key] = self._resolve_ref(value["$ref"]) - logger.info(f"Resolved reference {value['$ref']} to {params[key]}") - except Exception as e: - logger.error(f"Failed to resolve reference {value['$ref']}: {e}") - raise + params[key] = resolve_value(value) def get_available_operations(self) -> List[Dict[str, Any]]: operations = [] @@ -176,7 +227,8 @@ def _load_config(self) -> AppConfig: except TypeError: # If already initialized, we can optionally override it or just ignore # For now, let's override to ensure we have the latest config - ConfigProxy.override(config) + ConfigProxy._config = None + ConfigProxy.initialize(config) return config @@ -198,52 +250,30 @@ def _generate_data(self, data_files: DataFiles): return flight_declaration, telemetry_states - def validate_params(self, params: Dict[str, Any], step_name: str) -> None: - if step_name not in STEP_REGISTRY: - raise ValueError(f"Step '{step_name}' not found in registry") - - entry = STEP_REGISTRY[step_name] - DynamicModel = entry.param_model - - # Create a dynamic Pydantic model for validation - try: - validated_data = DynamicModel(**params) - # Update params with validated data (coerced types, defaults) - params.update(validated_data.model_dump()) - except Exception as e: - logger.error(f"Validation error for step '{step_name}': {e}") - raise ValueError(f"Invalid parameters for step '{step_name}': {e}") - def _prepare_params(self, step: StepDefinition) -> Dict[str, Any]: - params = step.parameters.copy() - - # Resolve references + params = step.arguments.copy() if step.arguments else {} self.resolve_references_in_params(params) - self.validate_params(params, step.name) - return params def _serialize_result(self, result: Any) -> Any: if isinstance(result, BaseModel): return result.model_dump() - else: - return result + return result def _determine_status(self, result: Any) -> str: - if hasattr(result, "status"): - status_val = getattr(result, "status") - if status_val == Status.FAIL: + if isinstance(result, StepResult): + if result.status == Status.FAIL: return "failure" - elif status_val == Status.PASS: + elif result.status == Status.PASS: return "success" return "success" async def _execute_step(self, step: StepDefinition) -> Dict[str, Any]: assert self.session_resolver is not None and self.session_context is not None - if step.name not in STEP_REGISTRY: - raise ValueError(f"Step '{step.name}' not found in registry") + if step.step not in STEP_REGISTRY: + raise ValueError(f"Step '{step.step}' not found in registry") - entry = STEP_REGISTRY[step.name] + entry = STEP_REGISTRY[step.step] client = await self.session_resolver.resolve(entry.client_class) method = getattr(client, entry.method_name) @@ -251,12 +281,14 @@ async def _execute_step(self, step: StepDefinition) -> Dict[str, Any]: # Prepare parameters (resolve refs, inject context) kwargs = self._prepare_params(step) - if step.run_in_background: - logger.info(f"Executing step '{step.name}' in background") + if step.background: + step_id = step.id or step.step + logger.info(f"Executing step '{step_id}' ({step.step}) in background") task = asyncio.create_task(call_with_dependencies(method, resolver=self.session_resolver, **kwargs)) - self.session_tasks[step.id] = task - self.session_context.add_result(StepResult(id=step.id, name=step.name, status=Status.RUNNING, details={"task_id": step.id}, duration=0.0)) - return {"id": step.id, "step": step.name, "status": "running", "task_id": step.id} + self.session_tasks[step_id] = task + self.session_context.add_result(StepResult(id=step_id, name=step.step, status=Status.RUNNING, details={"task_id": step_id}, duration=0.0)) + return {"id": step_id, "step": step.step, "status": "running", "task_id": step_id} + # Execute with dependencies result = await call_with_dependencies(method, resolver=self.session_resolver, **kwargs) @@ -271,7 +303,34 @@ async def _execute_step(self, step: StepDefinition) -> Dict[str, Any]: # Determine overall status based on result content status_str = self._determine_status(result) - return {"id": step.id, "step": step.name, "status": status_str, "result": result_data} + # 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 + if isinstance(result, StepResult): + result.id = step_id + # We don't need to add it again if it was already added by the decorator, + # but the decorator adds it with a generated ID or no ID if not running in full scenario context? + # Actually, the decorator adds it to ScenarioContext.add_result(step_result). + # Let's check if we need to update it or add it. + + # The decorator adds the result to the context. + # If we add it again here, we might duplicate it or overwrite it. + # However, the decorator doesn't know the 'step.id' from the YAML, it only knows the function execution. + # So we should probably update the existing result in the context if possible, or ensure the ID matches. + + # Let's just ensure the result in the context has the correct ID. + # The decorator calls ScenarioContext.add_result(step_result). + # step_result.id might be None or something else. + + # Since we are in the runner, we want to ensure the result is stored with the step_id we expect. + # We can remove the old one (if any) and add the updated one. + self.session_context.add_result(result) + else: + self.session_context.add_result(StepResult(id=step_id, name=step.step, status=Status.PASS, details=result_data, duration=0.0)) + + return {"id": step.id, "step": step.step, "status": status_str, "result": result_data} async def execute_single_step(self, step: StepDefinition) -> Dict[str, Any]: if not self.session_resolver: @@ -291,15 +350,25 @@ async def execute_single_step(self, step: StepDefinition) -> Dict[str, Any]: raise ValueError("Scenario state not initialized") return await self._execute_step(step) except Exception as e: - logger.error(f"Error executing step {step.name}: {e}") + step_id = step.id or step.step + logger.error(f"Error executing step {step_id}: {e}") raise - return {"step": step.name, "status": "error", "error": str(e)} async def run_scenario(self, scenario: ScenarioDefinition) -> List[Dict[str, Any]]: results = [] if not self.session_resolver: await self.initialize_session() + # Validate and prepare steps + seen_ids = set() + for step in scenario.steps: + if not step.id: + step.id = step.step + + if step.id in seen_ids: + raise ValueError(f"Duplicate step ID found: '{step.id}'. Step IDs must be unique within a scenario.") + seen_ids.add(step.id) + for step in scenario.steps: result = await self.execute_single_step(step) results.append(result) @@ -318,3 +387,7 @@ async def execute_function(self, func: Callable[..., Coroutine[Any, Any, T]]) -> with self.session_context: return await call_with_dependencies(func, resolver=self.session_resolver) + + +# Import dependencies to ensure they are registered +import openutm_verification.core.execution.dependencies # noqa: E402, F401 diff --git a/src/openutm_verification/utils/time_utils.py b/src/openutm_verification/utils/time_utils.py new file mode 100644 index 0000000..eb81f53 --- /dev/null +++ b/src/openutm_verification/utils/time_utils.py @@ -0,0 +1,42 @@ +import re + + +def parse_duration(duration: str | int | float) -> float: + """ + Parses a duration string (e.g., "5s", "10m", "1h") into seconds. + If no suffix is provided, defaults to seconds. + """ + if isinstance(duration, (int, float)): + return float(duration) + + if not isinstance(duration, str): + raise ValueError(f"Invalid duration type: {type(duration)}") + + duration = duration.strip().lower() + if not duration: + return 0.0 + + # Check for simple number string + try: + return float(duration) + except ValueError: + pass + + # Parse with suffix + match = re.match(r"^(\d+(?:\.\d+)?)\s*([a-z]+)$", duration) + if not match: + raise ValueError(f"Invalid duration format: {duration}") + + value, unit = match.groups() + value = float(value) + + if unit in ("s", "sec", "seconds"): + return value + elif unit in ("m", "min", "minutes"): + return value * 60 + elif unit in ("h", "hr", "hours"): + return value * 3600 + elif unit in ("d", "day", "days"): + return value * 86400 + else: + raise ValueError(f"Unknown time unit: {unit}") diff --git a/tests/test_client_steps.py b/tests/test_client_steps.py index c9a34d2..f0a6f42 100644 --- a/tests/test_client_steps.py +++ b/tests/test_client_steps.py @@ -4,6 +4,7 @@ import pytest from openutm_verification.core.clients.air_traffic.air_traffic_client import AirTrafficClient +from openutm_verification.core.clients.common.common_client import CommonClient from openutm_verification.core.clients.flight_blender.flight_blender_client import FlightBlenderClient from openutm_verification.core.clients.opensky.opensky_client import OpenSkyClient from openutm_verification.core.reporting.reporting_models import Status @@ -42,6 +43,12 @@ def os_client(): return client +@pytest.fixture +def common_client(): + client = CommonClient() + return client + + # FlightBlenderClient Tests @@ -104,7 +111,7 @@ async def test_update_operation_state(fb_client): mock_response.json.return_value = {"status": "success"} fb_client.put.return_value = mock_response - result = await fb_client.update_operation_state(new_state=OperationState.ACTIVATED) + result = await fb_client.update_operation_state(state=OperationState.ACTIVATED) assert result.status == Status.PASS fb_client.put.assert_called_once() @@ -147,9 +154,9 @@ async def test_submit_telemetry_from_file(fb_client): fb_client.put.assert_called_once() -async def test_wait_x_seconds(fb_client): +async def test_wait_x_seconds(common_client): with patch("asyncio.sleep", AsyncMock()) as mock_sleep: - result = await fb_client.wait_x_seconds(wait_time_seconds=2) + result = await common_client.wait(duration=2) assert "Waited for Flight Blender to process 2 seconds" in result.details mock_sleep.assert_called_once_with(2) @@ -201,7 +208,7 @@ async def test_submit_telemetry(fb_client): async def test_check_operation_state(fb_client): with patch("asyncio.sleep", AsyncMock()) as mock_sleep: - result = await fb_client.check_operation_state(expected_state=OperationState.ACTIVATED, duration_seconds=1) + result = await fb_client.check_operation_state(expected_state=OperationState.ACTIVATED, duration=1) assert "Waited for Flight Blender to process OperationState.ACTIVATED state" in result.details mock_sleep.assert_called_once_with(1) @@ -213,7 +220,7 @@ async def test_check_operation_state_connected(fb_client): mock_response.json.return_value = {"state": OperationState.ACTIVATED.value} fb_client.get.return_value = mock_response - result = await fb_client.check_operation_state_connected(expected_state=OperationState.ACTIVATED, duration_seconds=5) + result = await fb_client.check_operation_state_connected(expected_state=OperationState.ACTIVATED, duration=5) assert result.details["state"] == OperationState.ACTIVATED.value fb_client.get.assert_called() diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 5cd66a7..d02a958 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -34,54 +34,59 @@ def data_files(): return MagicMock(spec=DataFiles) +@pytest.fixture +def common_client(): + return AsyncMock() + + async def test_add_flight_declaration_scenario(fb_client, data_files): await scenario_add_flight_declaration(fb_client, data_files) fb_client.create_flight_declaration.assert_called_once_with(data_files) - fb_client.update_operation_state.assert_any_call(new_state=OperationState.ACTIVATED, duration_seconds=20) - fb_client.submit_telemetry.assert_called_once_with(duration_seconds=30) - fb_client.update_operation_state.assert_any_call(new_state=OperationState.ENDED) + fb_client.update_operation_state.assert_any_call(state=OperationState.ACTIVATED, duration=20) + fb_client.submit_telemetry.assert_called_once_with(duration=30) + fb_client.update_operation_state.assert_any_call(state=OperationState.ENDED) async def test_f1_happy_path_scenario(fb_client, data_files): await scenario_f1_happy_path(fb_client, data_files) fb_client.create_flight_declaration.assert_called_once_with(data_files) - fb_client.update_operation_state.assert_any_call(new_state=OperationState.ACTIVATED) - fb_client.submit_telemetry.assert_called_once_with(duration_seconds=30) - fb_client.update_operation_state.assert_any_call(new_state=OperationState.ENDED) + fb_client.update_operation_state.assert_any_call(state=OperationState.ACTIVATED) + fb_client.submit_telemetry.assert_called_once_with(duration=30) + fb_client.update_operation_state.assert_any_call(state=OperationState.ENDED) async def test_f2_contingent_path_scenario(fb_client, data_files): await scenario_f2_contingent_path(fb_client, data_files) fb_client.create_flight_declaration.assert_called_once_with(data_files) - fb_client.update_operation_state.assert_any_call(new_state=OperationState.ACTIVATED) - fb_client.submit_telemetry.assert_called_once_with(duration_seconds=10) - fb_client.update_operation_state.assert_any_call(new_state=OperationState.CONTINGENT, duration_seconds=7) - fb_client.update_operation_state.assert_any_call(new_state=OperationState.ENDED) + fb_client.update_operation_state.assert_any_call(state=OperationState.ACTIVATED) + fb_client.submit_telemetry.assert_called_once_with(duration=10) + fb_client.update_operation_state.assert_any_call(state=OperationState.CONTINGENT, duration=7) + fb_client.update_operation_state.assert_any_call(state=OperationState.ENDED) -async def test_f3_non_conforming_path_scenario(fb_client, data_files): - await scenario_f3_non_conforming_path(fb_client, data_files) +async def test_f3_non_conforming_path_scenario(fb_client, data_files, common_client): + await scenario_f3_non_conforming_path(fb_client, data_files, common_client) fb_client.create_flight_declaration.assert_called_once_with(data_files) - fb_client.update_operation_state.assert_any_call(new_state=OperationState.ACTIVATED) - fb_client.wait_x_seconds.assert_called_once_with(5) - fb_client.submit_telemetry.assert_called_once_with(duration_seconds=20) - fb_client.check_operation_state.assert_called_once_with(expected_state=OperationState.NONCONFORMING, duration_seconds=5) - fb_client.update_operation_state.assert_any_call(new_state=OperationState.ENDED) + fb_client.update_operation_state.assert_any_call(state=OperationState.ACTIVATED) + common_client.wait.assert_called_once_with(5) + fb_client.submit_telemetry.assert_called_once_with(duration=20) + fb_client.check_operation_state.assert_called_once_with(expected_state=OperationState.NONCONFORMING, duration=5) + fb_client.update_operation_state.assert_any_call(state=OperationState.ENDED) async def test_f5_non_conforming_contingent_path_scenario(fb_client, data_files): await scenario_f5_non_conforming_contingent_path(fb_client, data_files) fb_client.create_flight_declaration.assert_called_once_with(data_files) - fb_client.update_operation_state.assert_any_call(new_state=OperationState.ACTIVATED) - fb_client.submit_telemetry.assert_called_once_with(duration_seconds=20) - fb_client.check_operation_state_connected.assert_called_once_with(expected_state=OperationState.NONCONFORMING, duration_seconds=5) - fb_client.update_operation_state.assert_any_call(new_state=OperationState.CONTINGENT) - fb_client.update_operation_state.assert_any_call(new_state=OperationState.ENDED) + fb_client.update_operation_state.assert_any_call(state=OperationState.ACTIVATED) + fb_client.submit_telemetry.assert_called_once_with(duration=20) + fb_client.check_operation_state_connected.assert_called_once_with(expected_state=OperationState.NONCONFORMING, duration=5) + fb_client.update_operation_state.assert_any_call(state=OperationState.CONTINGENT) + fb_client.update_operation_state.assert_any_call(state=OperationState.ENDED) @patch("openutm_verification.scenarios.test_geo_fence_upload.get_geo_fence_path") @@ -108,27 +113,27 @@ async def test_opensky_live_data_scenario(mock_sleep, fb_client): assert mock_sleep.call_count == 4 # Sleeps between iterations -async def test_sdsp_heartbeat_scenario(fb_client): - await scenario_sdsp_heartbeat(fb_client) +async def test_sdsp_heartbeat_scenario(fb_client, common_client): + await scenario_sdsp_heartbeat(fb_client, common_client) fb_client.start_stop_sdsp_session.assert_any_call(action=SDSPSessionAction.START, session_id=ANY) - fb_client.wait_x_seconds.assert_any_call(wait_time_seconds=2) + common_client.wait.assert_any_call(duration=2) fb_client.initialize_verify_sdsp_heartbeat.assert_called_once() - fb_client.wait_x_seconds.assert_any_call(wait_time_seconds=5) + common_client.wait.assert_any_call(duration=5) fb_client.start_stop_sdsp_session.assert_any_call(action=SDSPSessionAction.STOP, session_id=ANY) -async def test_sdsp_track_scenario(fb_client): +async def test_sdsp_track_scenario(fb_client, common_client): air_traffic_client = AsyncMock() step_result = MagicMock() step_result.details = ["obs1"] air_traffic_client.generate_simulated_air_traffic_data.return_value = step_result - await scenario_sdsp_track(fb_client, air_traffic_client) + await scenario_sdsp_track(fb_client, air_traffic_client, common_client) fb_client.start_stop_sdsp_session.assert_any_call(action=SDSPSessionAction.START, session_id=ANY) - fb_client.wait_x_seconds.assert_any_call(wait_time_seconds=2) + common_client.wait.assert_any_call(duration=2) fb_client.initialize_verify_sdsp_track.assert_called_once() - fb_client.wait_x_seconds.assert_any_call(wait_time_seconds=5) + common_client.wait.assert_any_call(duration=5) fb_client.start_stop_sdsp_session.assert_any_call(action=SDSPSessionAction.STOP, session_id=ANY) diff --git a/tests/test_yaml_scenarios.py b/tests/test_yaml_scenarios.py new file mode 100644 index 0000000..45380a8 --- /dev/null +++ b/tests/test_yaml_scenarios.py @@ -0,0 +1,122 @@ +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +import yaml + +from openutm_verification.core.execution.config_models import DataFiles +from openutm_verification.core.execution.definitions import ScenarioDefinition +from openutm_verification.server.runner import SessionManager + +SCENARIOS_DIR = Path(__file__).parent.parent / "scenarios" +YAML_FILES = list(SCENARIOS_DIR.glob("*.yaml")) + + +@pytest.fixture +def mock_clients(): + """Create mocks for all client classes.""" + mocks = {} + + # FlightBlenderClient mock + fb_client = AsyncMock() + # Mock context managers + fb_client.create_flight_declaration = MagicMock() + cm = AsyncMock() + cm.__aenter__ = AsyncMock(return_value=None) + cm.__aexit__ = AsyncMock(return_value=None) + fb_client.create_flight_declaration.return_value = cm + + # Mock methods that return values used in other steps + fb_client.upload_geo_fence.return_value = {"id": "geo_fence_123"} + fb_client.upload_flight_declaration.return_value = {"id": "flight_decl_123", "is_approved": True} + fb_client.start_stop_sdsp_session.return_value = "Session Started" + + # Mock methods that return objects with attributes accessed in YAML + # e.g. ${{ steps.Generate Simulated Air Traffic Data.result.details }} + # But wait, we changed it to just .result in the previous turn. + # Let's check if any other steps return complex objects. + + mocks["FlightBlenderClient"] = fb_client + + # AirTrafficClient mock + at_client = AsyncMock() + # Mock generate_simulated_air_traffic_data to return a list of observations + # The YAML expects .result to be the observations list directly now + at_client.generate_simulated_air_traffic_data.return_value = [{"lat_dd": 47.0, "lon_dd": 7.5}] + mocks["AirTrafficClient"] = at_client + + # OpenSkyClient mock + os_client = AsyncMock() + os_client.fetch_data.return_value = [{"lat_dd": 47.0, "lon_dd": 7.5}] + mocks["OpenSkyClient"] = os_client + + # CommonClient mock + common_client = AsyncMock() + common_client.generate_uuid.return_value = "uuid-1234" + mocks["CommonClient"] = common_client + + return mocks + + +@pytest.fixture +def mock_data_files(): + return DataFiles( + trajectory="path/to/trajectory.json", flight_declaration="path/to/flight_declaration.json", geo_fence="path/to/geo_fence.geojson" + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("yaml_file", YAML_FILES, ids=[f.name for f in YAML_FILES]) +async def test_yaml_scenario_execution(yaml_file, mock_clients, mock_data_files): + """Verify that each YAML scenario can be loaded and executed with mocked clients.""" + + # Load YAML + with open(yaml_file, "r") as f: + scenario_data = yaml.safe_load(f) + scenario = ScenarioDefinition(**scenario_data) + + # Initialize SessionManager + # We mock _load_config to avoid reading real config files + with ( + patch("openutm_verification.server.runner.SessionManager._load_config") as mock_load_config, + patch("openutm_verification.server.runner.SessionManager._generate_data") as mock_gen_data, + ): + mock_load_config.return_value = MagicMock() + mock_load_config.return_value.suites = {"default": {}} + + # Mock _generate_data to return None, None (we don't need real data for this test) + mock_gen_data.return_value = (None, None) + + manager = SessionManager() + + # Initialize session (creates resolver) + # We need to patch DependencyResolver.resolve to return our mocks + with patch("openutm_verification.core.execution.dependency_resolution.DependencyResolver.resolve", new_callable=AsyncMock) as mock_resolve: + + async def resolve_side_effect(dependency_type): + if dependency_type == DataFiles: + return mock_data_files + + client_name = dependency_type.__name__ + if client_name in mock_clients: + return mock_clients[client_name] + + return AsyncMock() + + mock_resolve.side_effect = resolve_side_effect + + # We need to manually set session_resolver because initialize_session creates a new one + # Alternatively, we can mock DependencyResolver class + with patch("openutm_verification.server.runner.DependencyResolver") as MockResolverCls: + mock_resolver_instance = MockResolverCls.return_value + mock_resolver_instance.resolve = mock_resolve + + await manager.initialize_session() + + # Run scenario + results = await manager.run_scenario(scenario) + + # Verify results + for step_result in results: + if step_result["status"] == "running": + continue diff --git a/web-editor/async_implementation_guide.md b/web-editor/async_implementation_guide.md index a1bc665..f901dc8 100644 --- a/web-editor/async_implementation_guide.md +++ b/web-editor/async_implementation_guide.md @@ -25,7 +25,7 @@ The frontend now executes scenarios step-by-step, allowing for dynamic updates a ## 2. Backend Support for Background Tasks -The backend `DynamicRunner` has been updated to support "fire-and-forget" tasks and task joining. +The backend `SessionManager` has been updated to support "fire-and-forget" tasks and task joining. ### Implementation Details (`src/openutm_verification/server/runner.py`) @@ -35,8 +35,8 @@ The backend `DynamicRunner` has been updated to support "fire-and-forget" tasks - The task object is stored in `self.session_context["background_tasks"]` keyed by a UUID. - Returns immediately with `{"task_id": , "status": "running"}`. -2. **`SystemClient.join_task`**: - - A special handling block in `_execute_step` intercepts calls to `SystemClient.join_task`. +2. **`SessionManager.join_task`**: + - A special handling block in `_execute_step` intercepts calls to `SessionManager.join_task`. - It retrieves the `task_id` from parameters (which can be a direct string or a reference to a previous step's result). - It looks up the task in `session_context["background_tasks"]`. - It `await`s the task completion and returns the result. @@ -47,40 +47,21 @@ To create a scenario with async tasks (e.g., `sdsp_track`): 1. **Start Task**: Add a node (e.g., `FlightBlenderClient.submit_simulated_air_traffic`). - Set `Run in Background` to `true` (via properties panel or JSON). -2. **Intermediate Steps**: Add other nodes (e.g., `Wait X Seconds`, `Verify Track`) that run while the background task is active. -3. **Join Task**: Add a `SystemClient.join_task` node. +2. **Intermediate Steps**: Add other nodes (e.g., `CommonClient.wait`, `Verify Track`) that run while the background task is active. +3. **Join Task**: Add a `SessionManager.join_task` node. - Link its `task_id` parameter to the result of the Start Task node. -### Example JSON (`web-editor/examples/sdsp_track_async.json`) - -```json -{ - "nodes": [ - { - "id": "node_3", - "data": { - "label": "Submit Air Traffic (Async)", - "runInBackground": true, - ... - } - }, - ... - { - "id": "node_8", - "data": { - "label": "Join Background Task", - "operationId": "SystemClient.join_task", - "parameters": [ - { "name": "task_id", "default": "" } // Link this in UI - ] - } - } - ] -} -``` +### Example YAML (`scenarios/sdsp_track.yaml`) + +```yaml + - step: Submit Simulated Air Traffic + arguments: + observations: ${{ steps.Generate Simulated Air Traffic Data.result }} + background: true -## 4. Pending / Future Work + # ... other steps running in parallel ... -- **UI Property for Background**: Explicit checkbox in `PropertiesPanel` to toggle `runInBackground` (currently supported in JSON/Logic but needs UI control). -- **SystemClient in Toolbox**: Ensure `SystemClient` operations are exposed in the `/operations` endpoint so `join_task` appears in the toolbox. -- **Task ID Linking**: Improve UX for linking the `task_id` output of one node to the input of `join_task` (currently relies on implicit result passing or manual ID entry). + - step: Join Background Task + arguments: + task_id: Submit Simulated Air Traffic +``` diff --git a/web-editor/examples/scenario_2025-12-24T01-15-16-238Z.json b/web-editor/examples/scenario_2025-12-24T01-15-16-238Z.json deleted file mode 100644 index 5830f0b..0000000 --- a/web-editor/examples/scenario_2025-12-24T01-15-16-238Z.json +++ /dev/null @@ -1,1906 +0,0 @@ -{ - "nodes": [ - { - "id": "dndnode_16", - "type": "custom", - "position": { - "x": 0, - "y": 0 - }, - "data": { - "label": "Setup Flight Declaration", - "operationId": "FlightBlenderClient.setup_flight_declaration", - "className": "FlightBlenderClient", - "functionName": "setup_flight_declaration", - "description": "Generates data and uploads flight declaration.", - "parameters": [ - { - "name": "flight_declaration_path", - "type": "str", - "default": "config/bern/flight_declaration.json" - }, - { - "name": "trajectory_path", - "type": "str", - "default": "config/bern/trajectory_f1.json" - } - ], - "status": "success", - "result": { - "name": "Setup Flight Declaration", - "status": "PASS", - "duration": 0.4281291961669922, - "details": null, - "error_message": null - } - }, - "measured": { - "width": 248, - "height": 56 - }, - "selected": false, - "dragging": false - }, - { - "id": "dndnode_17", - "type": "custom", - "position": { - "x": 0, - "y": 360 - }, - "data": { - "label": "Update Operation State", - "operationId": "FlightBlenderClient.update_operation_state", - "className": "FlightBlenderClient", - "functionName": "update_operation_state", - "description": "Update the state of a flight operation.\n\nPosts the new state and optionally waits for the specified duration.\n\nArgs:\n new_state: The new OperationState to set.\n duration_seconds: Optional seconds to sleep after update (default 0).\n\nReturns:\n The JSON response from the API.\n\nRaises:\n FlightBlenderError: If the update request fails.", - "parameters": [ - { - "name": "new_state", - "type": "OperationState", - "options": [ - { - "name": "PROCESSING", - "value": 0 - }, - { - "name": "ACCEPTED", - "value": 1 - }, - { - "name": "ACTIVATED", - "value": 2 - }, - { - "name": "NONCONFORMING", - "value": 3 - }, - { - "name": "CONTINGENT", - "value": 4 - }, - { - "name": "ENDED", - "value": 5 - }, - { - "name": "WITHDRAWN", - "value": 6 - }, - { - "name": "CANCELLED", - "value": 7 - }, - { - "name": "REJECTED", - "value": 8 - } - ], - "isEnum": true, - "default": 2 - }, - { - "name": "duration_seconds", - "type": "int", - "default": 0 - } - ], - "status": "success", - "result": { - "name": "Update Operation State", - "status": "PASS", - "duration": 0.10649800300598145, - "details": { - "state": 2, - "submitted_by": null - }, - "error_message": null - } - }, - "measured": { - "width": 244, - "height": 56 - }, - "selected": false, - "dragging": false - }, - { - "id": "dndnode_18", - "type": "custom", - "position": { - "x": 0, - "y": 180 - }, - "data": { - "label": "Wait X seconds", - "operationId": "FlightBlenderClient.wait_x_seconds", - "className": "FlightBlenderClient", - "functionName": "wait_x_seconds", - "description": "Wait for a specified number of seconds.", - "parameters": [ - { - "name": "wait_time_seconds", - "type": "int", - "default": 5 - } - ], - "status": "success", - "result": { - "name": "Wait X seconds", - "status": "PASS", - "duration": 5.0012102127075195, - "details": "Waited for Flight Blender to process 5 seconds.", - "error_message": null - } - }, - "measured": { - "width": 191, - "height": 56 - }, - "selected": false, - "dragging": false - }, - { - "id": "dndnode_19", - "type": "custom", - "position": { - "x": 0, - "y": 720 - }, - "data": { - "label": "Submit Telemetry", - "operationId": "FlightBlenderClient.submit_telemetry", - "className": "FlightBlenderClient", - "functionName": "submit_telemetry", - "description": "Submit telemetry data for a flight operation from in-memory states.\n\nSubmits telemetry states sequentially from the provided list, with optional\nduration limiting and error handling for rate limits.\n\nArgs:\n states: List of telemetry state dictionaries. If None, uses the generated telemetry states from context.\n duration_seconds: Optional maximum duration in seconds to submit telemetry (default 0 for unlimited).\n\nReturns:\n The JSON response from the last telemetry submission, or None if no submissions occurred.\n\nRaises:\n FlightBlenderError: If maximum waiting time is exceeded due to rate limits.", - "parameters": [ - { - "name": "states", - "type": "list[RIDAircraftState] | None", - "default": null - }, - { - "name": "duration_seconds", - "type": "int", - "default": 5 - } - ], - "status": "success", - "result": { - "name": "Submit Telemetry", - "status": "PASS", - "duration": 5.538996934890747, - "details": { - "message": "Telemetry data successfully submitted" - }, - "error_message": null - } - }, - "measured": { - "width": 205, - "height": 56 - }, - "selected": false, - "dragging": false - }, - { - "id": "dndnode_20", - "type": "custom", - "position": { - "x": 0, - "y": 900 - }, - "data": { - "label": "Update Operation State", - "operationId": "FlightBlenderClient.update_operation_state", - "className": "FlightBlenderClient", - "functionName": "update_operation_state", - "description": "Update the state of a flight operation.\n\nPosts the new state and optionally waits for the specified duration.\n\nArgs:\n new_state: The new OperationState to set.\n duration_seconds: Optional seconds to sleep after update (default 0).\n\nReturns:\n The JSON response from the API.\n\nRaises:\n FlightBlenderError: If the update request fails.", - "parameters": [ - { - "name": "new_state", - "type": "OperationState", - "options": [ - { - "name": "PROCESSING", - "value": 0 - }, - { - "name": "ACCEPTED", - "value": 1 - }, - { - "name": "ACTIVATED", - "value": 2 - }, - { - "name": "NONCONFORMING", - "value": 3 - }, - { - "name": "CONTINGENT", - "value": 4 - }, - { - "name": "ENDED", - "value": 5 - }, - { - "name": "WITHDRAWN", - "value": 6 - }, - { - "name": "CANCELLED", - "value": 7 - }, - { - "name": "REJECTED", - "value": 8 - } - ], - "isEnum": true, - "default": 5 - }, - { - "name": "duration_seconds", - "type": "int", - "default": 0 - } - ], - "status": "success", - "result": { - "name": "Update Operation State", - "status": "PASS", - "duration": 0.1755518913269043, - "details": { - "state": 5, - "submitted_by": null - }, - "error_message": null - } - }, - "measured": { - "width": 244, - "height": 56 - }, - "selected": false, - "dragging": false - }, - { - "id": "dndnode_21", - "type": "custom", - "position": { - "x": 0, - "y": 1260 - }, - "data": { - "label": "Delete Flight Declaration", - "operationId": "FlightBlenderClient.delete_flight_declaration", - "className": "FlightBlenderClient", - "functionName": "delete_flight_declaration", - "description": "Delete a flight declaration by ID.\n\nReturns:\n A dictionary with deletion status, including whether it was successful.", - "parameters": [], - "status": "success", - "result": { - "name": "Delete Flight Declaration", - "status": "PASS", - "duration": 0.09616422653198242, - "details": { - "deleted": true, - "id": "4f925234-be8e-4480-9ecc-fa87f74843e5" - }, - "error_message": null - } - }, - "measured": { - "width": 251, - "height": 56 - }, - "selected": false, - "dragging": false - }, - { - "id": "dndnode_0", - "type": "custom", - "position": { - "x": 0, - "y": 540 - }, - "data": { - "label": "Check Operation State Connected", - "operationId": "FlightBlenderClient.check_operation_state_connected", - "className": "FlightBlenderClient", - "functionName": "check_operation_state_connected", - "description": "Check the operation state by polling the API until the expected state is reached.\n\nArgs:\n expected_state: The expected OperationState.\n duration_seconds: Maximum seconds to poll for the state.\n\nReturns:\n The JSON response from the API when the state is reached.\n\nRaises:\n FlightBlenderError: If the expected state is not reached within the timeout.", - "parameters": [ - { - "name": "expected_state", - "type": "OperationState", - "default": 2, - "required": true, - "isEnum": true, - "options": [ - { - "name": "PROCESSING", - "value": 0 - }, - { - "name": "ACCEPTED", - "value": 1 - }, - { - "name": "ACTIVATED", - "value": 2 - }, - { - "name": "NONCONFORMING", - "value": 3 - }, - { - "name": "CONTINGENT", - "value": 4 - }, - { - "name": "ENDED", - "value": 5 - }, - { - "name": "WITHDRAWN", - "value": 6 - }, - { - "name": "CANCELLED", - "value": 7 - }, - { - "name": "REJECTED", - "value": 8 - } - ] - }, - { - "name": "duration_seconds", - "type": "int", - "default": 2, - "required": false - } - ], - "status": "success", - "result": { - "name": "Check Operation State Connected", - "status": "PASS", - "duration": 0.09958386421203613, - "details": { - "operational_intent": { - "volumes": [ - { - "volume": { - "outline_polygon": { - "vertices": [ - { - "lat": 46.9794127188804, - "lng": 7.487045772981162 - }, - { - "lat": 46.9794127188804, - "lng": 7.471958949151656 - }, - { - "lat": 46.97941512651706, - "lng": 7.471909940581491 - }, - { - "lat": 46.9794223262402, - "lng": 7.471861403990648 - }, - { - "lat": 46.979434248712536, - "lng": 7.471813806813029 - }, - { - "lat": 46.979450779114146, - "lng": 7.471767607435473 - }, - { - "lat": 46.97947175824822, - "lng": 7.471723250783243 - }, - { - "lat": 46.979496984074245, - "lng": 7.471681164035146 - }, - { - "lat": 46.97952621365372, - "lng": 7.471641752509574 - }, - { - "lat": 46.9795591654898, - "lng": 7.471605395761062 - }, - { - "lat": 46.97959552223832, - "lng": 7.471572443924974 - }, - { - "lat": 46.97963493376389, - "lng": 7.4715432143455045 - }, - { - "lat": 46.979677020511986, - "lng": 7.471517988519482 - }, - { - "lat": 46.97972137716422, - "lng": 7.4714970093854 - }, - { - "lat": 46.979767576541775, - "lng": 7.47148047898379 - }, - { - "lat": 46.979815173719395, - "lng": 7.4714685565114545 - }, - { - "lat": 46.979863710310234, - "lng": 7.4714613567883195 - }, - { - "lat": 46.9799127188804, - "lng": 7.471458949151656 - }, - { - "lat": 46.986538963424294, - "lng": 7.471458949151656 - }, - { - "lat": 46.98658797199446, - "lng": 7.4714613567883195 - }, - { - "lat": 46.9866365085853, - "lng": 7.4714685565114545 - }, - { - "lat": 46.98668410576292, - "lng": 7.47148047898379 - }, - { - "lat": 46.986730305140476, - "lng": 7.4714970093854 - }, - { - "lat": 46.98677466179271, - "lng": 7.471517988519482 - }, - { - "lat": 46.986816748540804, - "lng": 7.4715432143455045 - }, - { - "lat": 46.986856160066374, - "lng": 7.471572443924974 - }, - { - "lat": 46.98689251681489, - "lng": 7.471605395761062 - }, - { - "lat": 46.98692546865097, - "lng": 7.471641752509574 - }, - { - "lat": 46.98695469823045, - "lng": 7.471681164035146 - }, - { - "lat": 46.98697992405647, - "lng": 7.471723250783243 - }, - { - "lat": 46.98700090319055, - "lng": 7.471767607435473 - }, - { - "lat": 46.98701743359216, - "lng": 7.471813806813029 - }, - { - "lat": 46.9870293560645, - "lng": 7.471861403990648 - }, - { - "lat": 46.98703655578763, - "lng": 7.471909940581491 - }, - { - "lat": 46.9870389634243, - "lng": 7.471958949151656 - }, - { - "lat": 46.9870389634243, - "lng": 7.487045772981162 - }, - { - "lat": 46.98703655578763, - "lng": 7.487094781551327 - }, - { - "lat": 46.9870293560645, - "lng": 7.48714331814217 - }, - { - "lat": 46.98701743359216, - "lng": 7.487190915319789 - }, - { - "lat": 46.98700090319055, - "lng": 7.487237114697344 - }, - { - "lat": 46.98697992405647, - "lng": 7.487281471349575 - }, - { - "lat": 46.98695469823045, - "lng": 7.487323558097672 - }, - { - "lat": 46.98692546865097, - "lng": 7.487362969623244 - }, - { - "lat": 46.98689251681489, - "lng": 7.487399326371755 - }, - { - "lat": 46.986856160066374, - "lng": 7.4874322782078435 - }, - { - "lat": 46.986816748540804, - "lng": 7.487461507787313 - }, - { - "lat": 46.98677466179271, - "lng": 7.487486733613336 - }, - { - "lat": 46.986730305140476, - "lng": 7.487507712747417 - }, - { - "lat": 46.98668410576292, - "lng": 7.487524243149028 - }, - { - "lat": 46.9866365085853, - "lng": 7.487536165621363 - }, - { - "lat": 46.98658797199446, - "lng": 7.487543365344498 - }, - { - "lat": 46.986538963424294, - "lng": 7.487545772981162 - }, - { - "lat": 46.9799127188804, - "lng": 7.487545772981162 - }, - { - "lat": 46.979863710310234, - "lng": 7.487543365344498 - }, - { - "lat": 46.979815173719395, - "lng": 7.487536165621363 - }, - { - "lat": 46.979767576541775, - "lng": 7.487524243149028 - }, - { - "lat": 46.97972137716422, - "lng": 7.487507712747417 - }, - { - "lat": 46.979677020511986, - "lng": 7.487486733613336 - }, - { - "lat": 46.97963493376389, - "lng": 7.487461507787313 - }, - { - "lat": 46.97959552223832, - "lng": 7.4874322782078435 - }, - { - "lat": 46.9795591654898, - "lng": 7.487399326371755 - }, - { - "lat": 46.97952621365372, - "lng": 7.487362969623244 - }, - { - "lat": 46.979496984074245, - "lng": 7.487323558097672 - }, - { - "lat": 46.97947175824822, - "lng": 7.487281471349575 - }, - { - "lat": 46.979450779114146, - "lng": 7.487237114697344 - }, - { - "lat": 46.979434248712536, - "lng": 7.487190915319789 - }, - { - "lat": 46.9794223262402, - "lng": 7.48714331814217 - }, - { - "lat": 46.97941512651706, - "lng": 7.487094781551327 - } - ] - }, - "altitude_lower": { - "value": 50, - "reference": "W84", - "units": "M" - }, - "altitude_upper": { - "value": 120, - "reference": "W84", - "units": "M" - }, - "outline_circle": null - }, - "time_start": { - "format": "RFC3339", - "value": "2025-12-24T01:13:58.428252+00:00" - }, - "time_end": { - "format": "RFC3339", - "value": "2025-12-24T01:17:53.428252+00:00" - } - } - ], - "priority": 0, - "state": "Accepted", - "off_nominal_volumes": [] - }, - "originating_party": "Medicine Delivery Company", - "type_of_operation": 0, - "id": "4f925234-be8e-4480-9ecc-fa87f74843e5", - "state": 2, - "is_approved": true, - "start_datetime": "2025-12-24T01:13:58.428252Z", - "end_datetime": "2025-12-24T01:17:53.428252Z", - "flight_declaration_geojson": { - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "properties": { - "time_start": "2025-12-24T01:13:58.428252+00:00", - "time_end": "2025-12-24T01:17:53.428252+00:00" - }, - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [ - 7.487045772981162, - 46.9794127188804 - ], - [ - 7.487094781551327, - 46.97941512651706 - ], - [ - 7.48714331814217, - 46.9794223262402 - ], - [ - 7.487190915319789, - 46.979434248712536 - ], - [ - 7.487237114697344, - 46.979450779114146 - ], - [ - 7.487281471349575, - 46.97947175824822 - ], - [ - 7.487323558097672, - 46.979496984074245 - ], - [ - 7.487362969623244, - 46.97952621365372 - ], - [ - 7.487399326371755, - 46.9795591654898 - ], - [ - 7.4874322782078435, - 46.97959552223832 - ], - [ - 7.487461507787313, - 46.97963493376389 - ], - [ - 7.487486733613336, - 46.979677020511986 - ], - [ - 7.487507712747417, - 46.97972137716422 - ], - [ - 7.487524243149028, - 46.979767576541775 - ], - [ - 7.487536165621363, - 46.979815173719395 - ], - [ - 7.487543365344498, - 46.979863710310234 - ], - [ - 7.487545772981162, - 46.9799127188804 - ], - [ - 7.487545772981162, - 46.986538963424294 - ], - [ - 7.487543365344498, - 46.98658797199446 - ], - [ - 7.487536165621363, - 46.9866365085853 - ], - [ - 7.487524243149028, - 46.98668410576292 - ], - [ - 7.487507712747417, - 46.986730305140476 - ], - [ - 7.487486733613336, - 46.98677466179271 - ], - [ - 7.487461507787313, - 46.986816748540804 - ], - [ - 7.4874322782078435, - 46.986856160066374 - ], - [ - 7.487399326371755, - 46.98689251681489 - ], - [ - 7.487362969623244, - 46.98692546865097 - ], - [ - 7.487323558097672, - 46.98695469823045 - ], - [ - 7.487281471349575, - 46.98697992405647 - ], - [ - 7.487237114697344, - 46.98700090319055 - ], - [ - 7.487190915319789, - 46.98701743359216 - ], - [ - 7.48714331814217, - 46.9870293560645 - ], - [ - 7.487094781551327, - 46.98703655578763 - ], - [ - 7.487045772981162, - 46.9870389634243 - ], - [ - 7.471958949151656, - 46.9870389634243 - ], - [ - 7.471909940581491, - 46.98703655578763 - ], - [ - 7.471861403990648, - 46.9870293560645 - ], - [ - 7.471813806813029, - 46.98701743359216 - ], - [ - 7.471767607435473, - 46.98700090319055 - ], - [ - 7.471723250783243, - 46.98697992405647 - ], - [ - 7.471681164035146, - 46.98695469823045 - ], - [ - 7.471641752509574, - 46.98692546865097 - ], - [ - 7.471605395761062, - 46.98689251681489 - ], - [ - 7.471572443924974, - 46.986856160066374 - ], - [ - 7.4715432143455045, - 46.986816748540804 - ], - [ - 7.471517988519482, - 46.98677466179271 - ], - [ - 7.4714970093854, - 46.986730305140476 - ], - [ - 7.47148047898379, - 46.98668410576292 - ], - [ - 7.4714685565114545, - 46.9866365085853 - ], - [ - 7.4714613567883195, - 46.98658797199446 - ], - [ - 7.471458949151656, - 46.986538963424294 - ], - [ - 7.471458949151656, - 46.9799127188804 - ], - [ - 7.4714613567883195, - 46.979863710310234 - ], - [ - 7.4714685565114545, - 46.979815173719395 - ], - [ - 7.47148047898379, - 46.979767576541775 - ], - [ - 7.4714970093854, - 46.97972137716422 - ], - [ - 7.471517988519482, - 46.979677020511986 - ], - [ - 7.4715432143455045, - 46.97963493376389 - ], - [ - 7.471572443924974, - 46.97959552223832 - ], - [ - 7.471605395761062, - 46.9795591654898 - ], - [ - 7.471641752509574, - 46.97952621365372 - ], - [ - 7.471681164035146, - 46.979496984074245 - ], - [ - 7.471723250783243, - 46.97947175824822 - ], - [ - 7.471767607435473, - 46.979450779114146 - ], - [ - 7.471813806813029, - 46.979434248712536 - ], - [ - 7.471861403990648, - 46.9794223262402 - ], - [ - 7.471909940581491, - 46.97941512651706 - ], - [ - 7.471958949151656, - 46.9794127188804 - ], - [ - 7.487045772981162, - 46.9794127188804 - ] - ] - ] - } - } - ] - }, - "flight_declaration_raw_geojson": { - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [ - 7.487045772981162, - 46.9799127188804 - ], - [ - 7.487045772981162, - 46.986538963424294 - ], - [ - 7.471958949151656, - 46.986538963424294 - ], - [ - 7.471958949151656, - 46.9799127188804 - ], - [ - 7.487045772981162, - 46.9799127188804 - ] - ] - ] - }, - "properties": { - "min_altitude": { - "meters": 50, - "datum": "w84" - }, - "max_altitude": { - "meters": 120, - "datum": "w84" - } - } - } - ] - }, - "bounds": "7.4714589,46.9794127,7.4875458,46.9870390", - "approved_by": null, - "submitted_by": null - }, - "error_message": null - } - }, - "measured": { - "width": 316, - "height": 56 - }, - "selected": false, - "dragging": false - }, - { - "id": "dndnode_1", - "type": "custom", - "position": { - "x": 0, - "y": 1080 - }, - "data": { - "label": "Check Operation State Connected", - "operationId": "FlightBlenderClient.check_operation_state_connected", - "className": "FlightBlenderClient", - "functionName": "check_operation_state_connected", - "description": "Check the operation state by polling the API until the expected state is reached.\n\nArgs:\n expected_state: The expected OperationState.\n duration_seconds: Maximum seconds to poll for the state.\n\nReturns:\n The JSON response from the API when the state is reached.\n\nRaises:\n FlightBlenderError: If the expected state is not reached within the timeout.", - "parameters": [ - { - "name": "expected_state", - "type": "OperationState", - "default": 5, - "required": true, - "isEnum": true, - "options": [ - { - "name": "PROCESSING", - "value": 0 - }, - { - "name": "ACCEPTED", - "value": 1 - }, - { - "name": "ACTIVATED", - "value": 2 - }, - { - "name": "NONCONFORMING", - "value": 3 - }, - { - "name": "CONTINGENT", - "value": 4 - }, - { - "name": "ENDED", - "value": 5 - }, - { - "name": "WITHDRAWN", - "value": 6 - }, - { - "name": "CANCELLED", - "value": 7 - }, - { - "name": "REJECTED", - "value": 8 - } - ] - }, - { - "name": "duration_seconds", - "type": "int", - "default": 2, - "required": false - } - ], - "status": "success", - "result": { - "name": "Check Operation State Connected", - "status": "PASS", - "duration": 0.09664797782897949, - "details": { - "operational_intent": { - "volumes": [ - { - "volume": { - "outline_polygon": { - "vertices": [ - { - "lat": 46.9794127188804, - "lng": 7.487045772981162 - }, - { - "lat": 46.9794127188804, - "lng": 7.471958949151656 - }, - { - "lat": 46.97941512651706, - "lng": 7.471909940581491 - }, - { - "lat": 46.9794223262402, - "lng": 7.471861403990648 - }, - { - "lat": 46.979434248712536, - "lng": 7.471813806813029 - }, - { - "lat": 46.979450779114146, - "lng": 7.471767607435473 - }, - { - "lat": 46.97947175824822, - "lng": 7.471723250783243 - }, - { - "lat": 46.979496984074245, - "lng": 7.471681164035146 - }, - { - "lat": 46.97952621365372, - "lng": 7.471641752509574 - }, - { - "lat": 46.9795591654898, - "lng": 7.471605395761062 - }, - { - "lat": 46.97959552223832, - "lng": 7.471572443924974 - }, - { - "lat": 46.97963493376389, - "lng": 7.4715432143455045 - }, - { - "lat": 46.979677020511986, - "lng": 7.471517988519482 - }, - { - "lat": 46.97972137716422, - "lng": 7.4714970093854 - }, - { - "lat": 46.979767576541775, - "lng": 7.47148047898379 - }, - { - "lat": 46.979815173719395, - "lng": 7.4714685565114545 - }, - { - "lat": 46.979863710310234, - "lng": 7.4714613567883195 - }, - { - "lat": 46.9799127188804, - "lng": 7.471458949151656 - }, - { - "lat": 46.986538963424294, - "lng": 7.471458949151656 - }, - { - "lat": 46.98658797199446, - "lng": 7.4714613567883195 - }, - { - "lat": 46.9866365085853, - "lng": 7.4714685565114545 - }, - { - "lat": 46.98668410576292, - "lng": 7.47148047898379 - }, - { - "lat": 46.986730305140476, - "lng": 7.4714970093854 - }, - { - "lat": 46.98677466179271, - "lng": 7.471517988519482 - }, - { - "lat": 46.986816748540804, - "lng": 7.4715432143455045 - }, - { - "lat": 46.986856160066374, - "lng": 7.471572443924974 - }, - { - "lat": 46.98689251681489, - "lng": 7.471605395761062 - }, - { - "lat": 46.98692546865097, - "lng": 7.471641752509574 - }, - { - "lat": 46.98695469823045, - "lng": 7.471681164035146 - }, - { - "lat": 46.98697992405647, - "lng": 7.471723250783243 - }, - { - "lat": 46.98700090319055, - "lng": 7.471767607435473 - }, - { - "lat": 46.98701743359216, - "lng": 7.471813806813029 - }, - { - "lat": 46.9870293560645, - "lng": 7.471861403990648 - }, - { - "lat": 46.98703655578763, - "lng": 7.471909940581491 - }, - { - "lat": 46.9870389634243, - "lng": 7.471958949151656 - }, - { - "lat": 46.9870389634243, - "lng": 7.487045772981162 - }, - { - "lat": 46.98703655578763, - "lng": 7.487094781551327 - }, - { - "lat": 46.9870293560645, - "lng": 7.48714331814217 - }, - { - "lat": 46.98701743359216, - "lng": 7.487190915319789 - }, - { - "lat": 46.98700090319055, - "lng": 7.487237114697344 - }, - { - "lat": 46.98697992405647, - "lng": 7.487281471349575 - }, - { - "lat": 46.98695469823045, - "lng": 7.487323558097672 - }, - { - "lat": 46.98692546865097, - "lng": 7.487362969623244 - }, - { - "lat": 46.98689251681489, - "lng": 7.487399326371755 - }, - { - "lat": 46.986856160066374, - "lng": 7.4874322782078435 - }, - { - "lat": 46.986816748540804, - "lng": 7.487461507787313 - }, - { - "lat": 46.98677466179271, - "lng": 7.487486733613336 - }, - { - "lat": 46.986730305140476, - "lng": 7.487507712747417 - }, - { - "lat": 46.98668410576292, - "lng": 7.487524243149028 - }, - { - "lat": 46.9866365085853, - "lng": 7.487536165621363 - }, - { - "lat": 46.98658797199446, - "lng": 7.487543365344498 - }, - { - "lat": 46.986538963424294, - "lng": 7.487545772981162 - }, - { - "lat": 46.9799127188804, - "lng": 7.487545772981162 - }, - { - "lat": 46.979863710310234, - "lng": 7.487543365344498 - }, - { - "lat": 46.979815173719395, - "lng": 7.487536165621363 - }, - { - "lat": 46.979767576541775, - "lng": 7.487524243149028 - }, - { - "lat": 46.97972137716422, - "lng": 7.487507712747417 - }, - { - "lat": 46.979677020511986, - "lng": 7.487486733613336 - }, - { - "lat": 46.97963493376389, - "lng": 7.487461507787313 - }, - { - "lat": 46.97959552223832, - "lng": 7.4874322782078435 - }, - { - "lat": 46.9795591654898, - "lng": 7.487399326371755 - }, - { - "lat": 46.97952621365372, - "lng": 7.487362969623244 - }, - { - "lat": 46.979496984074245, - "lng": 7.487323558097672 - }, - { - "lat": 46.97947175824822, - "lng": 7.487281471349575 - }, - { - "lat": 46.979450779114146, - "lng": 7.487237114697344 - }, - { - "lat": 46.979434248712536, - "lng": 7.487190915319789 - }, - { - "lat": 46.9794223262402, - "lng": 7.48714331814217 - }, - { - "lat": 46.97941512651706, - "lng": 7.487094781551327 - } - ] - }, - "altitude_lower": { - "value": 50, - "reference": "W84", - "units": "M" - }, - "altitude_upper": { - "value": 120, - "reference": "W84", - "units": "M" - }, - "outline_circle": null - }, - "time_start": { - "format": "RFC3339", - "value": "2025-12-24T01:13:58.428252+00:00" - }, - "time_end": { - "format": "RFC3339", - "value": "2025-12-24T01:17:53.428252+00:00" - } - } - ], - "priority": 0, - "state": "Accepted", - "off_nominal_volumes": [] - }, - "originating_party": "Medicine Delivery Company", - "type_of_operation": 0, - "id": "4f925234-be8e-4480-9ecc-fa87f74843e5", - "state": 5, - "is_approved": true, - "start_datetime": "2025-12-24T01:13:58.428252Z", - "end_datetime": "2025-12-24T01:17:53.428252Z", - "flight_declaration_geojson": { - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "properties": { - "time_start": "2025-12-24T01:13:58.428252+00:00", - "time_end": "2025-12-24T01:17:53.428252+00:00" - }, - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [ - 7.487045772981162, - 46.9794127188804 - ], - [ - 7.487094781551327, - 46.97941512651706 - ], - [ - 7.48714331814217, - 46.9794223262402 - ], - [ - 7.487190915319789, - 46.979434248712536 - ], - [ - 7.487237114697344, - 46.979450779114146 - ], - [ - 7.487281471349575, - 46.97947175824822 - ], - [ - 7.487323558097672, - 46.979496984074245 - ], - [ - 7.487362969623244, - 46.97952621365372 - ], - [ - 7.487399326371755, - 46.9795591654898 - ], - [ - 7.4874322782078435, - 46.97959552223832 - ], - [ - 7.487461507787313, - 46.97963493376389 - ], - [ - 7.487486733613336, - 46.979677020511986 - ], - [ - 7.487507712747417, - 46.97972137716422 - ], - [ - 7.487524243149028, - 46.979767576541775 - ], - [ - 7.487536165621363, - 46.979815173719395 - ], - [ - 7.487543365344498, - 46.979863710310234 - ], - [ - 7.487545772981162, - 46.9799127188804 - ], - [ - 7.487545772981162, - 46.986538963424294 - ], - [ - 7.487543365344498, - 46.98658797199446 - ], - [ - 7.487536165621363, - 46.9866365085853 - ], - [ - 7.487524243149028, - 46.98668410576292 - ], - [ - 7.487507712747417, - 46.986730305140476 - ], - [ - 7.487486733613336, - 46.98677466179271 - ], - [ - 7.487461507787313, - 46.986816748540804 - ], - [ - 7.4874322782078435, - 46.986856160066374 - ], - [ - 7.487399326371755, - 46.98689251681489 - ], - [ - 7.487362969623244, - 46.98692546865097 - ], - [ - 7.487323558097672, - 46.98695469823045 - ], - [ - 7.487281471349575, - 46.98697992405647 - ], - [ - 7.487237114697344, - 46.98700090319055 - ], - [ - 7.487190915319789, - 46.98701743359216 - ], - [ - 7.48714331814217, - 46.9870293560645 - ], - [ - 7.487094781551327, - 46.98703655578763 - ], - [ - 7.487045772981162, - 46.9870389634243 - ], - [ - 7.471958949151656, - 46.9870389634243 - ], - [ - 7.471909940581491, - 46.98703655578763 - ], - [ - 7.471861403990648, - 46.9870293560645 - ], - [ - 7.471813806813029, - 46.98701743359216 - ], - [ - 7.471767607435473, - 46.98700090319055 - ], - [ - 7.471723250783243, - 46.98697992405647 - ], - [ - 7.471681164035146, - 46.98695469823045 - ], - [ - 7.471641752509574, - 46.98692546865097 - ], - [ - 7.471605395761062, - 46.98689251681489 - ], - [ - 7.471572443924974, - 46.986856160066374 - ], - [ - 7.4715432143455045, - 46.986816748540804 - ], - [ - 7.471517988519482, - 46.98677466179271 - ], - [ - 7.4714970093854, - 46.986730305140476 - ], - [ - 7.47148047898379, - 46.98668410576292 - ], - [ - 7.4714685565114545, - 46.9866365085853 - ], - [ - 7.4714613567883195, - 46.98658797199446 - ], - [ - 7.471458949151656, - 46.986538963424294 - ], - [ - 7.471458949151656, - 46.9799127188804 - ], - [ - 7.4714613567883195, - 46.979863710310234 - ], - [ - 7.4714685565114545, - 46.979815173719395 - ], - [ - 7.47148047898379, - 46.979767576541775 - ], - [ - 7.4714970093854, - 46.97972137716422 - ], - [ - 7.471517988519482, - 46.979677020511986 - ], - [ - 7.4715432143455045, - 46.97963493376389 - ], - [ - 7.471572443924974, - 46.97959552223832 - ], - [ - 7.471605395761062, - 46.9795591654898 - ], - [ - 7.471641752509574, - 46.97952621365372 - ], - [ - 7.471681164035146, - 46.979496984074245 - ], - [ - 7.471723250783243, - 46.97947175824822 - ], - [ - 7.471767607435473, - 46.979450779114146 - ], - [ - 7.471813806813029, - 46.979434248712536 - ], - [ - 7.471861403990648, - 46.9794223262402 - ], - [ - 7.471909940581491, - 46.97941512651706 - ], - [ - 7.471958949151656, - 46.9794127188804 - ], - [ - 7.487045772981162, - 46.9794127188804 - ] - ] - ] - } - } - ] - }, - "flight_declaration_raw_geojson": { - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [ - 7.487045772981162, - 46.9799127188804 - ], - [ - 7.487045772981162, - 46.986538963424294 - ], - [ - 7.471958949151656, - 46.986538963424294 - ], - [ - 7.471958949151656, - 46.9799127188804 - ], - [ - 7.487045772981162, - 46.9799127188804 - ] - ] - ] - }, - "properties": { - "min_altitude": { - "meters": 50, - "datum": "w84" - }, - "max_altitude": { - "meters": 120, - "datum": "w84" - } - } - } - ] - }, - "bounds": "7.4714589,46.9794127,7.4875458,46.9870390", - "approved_by": null, - "submitted_by": null - }, - "error_message": null - } - }, - "measured": { - "width": 316, - "height": 56 - }, - "selected": false, - "dragging": false - } - ], - "edges": [ - { - "source": "dndnode_16", - "target": "dndnode_18", - "animated": true, - "style": { - "stroke": "var(--accent-primary)", - "strokeWidth": 1 - }, - "markerEnd": { - "type": "arrowclosed", - "color": "var(--accent-primary)" - }, - "id": "xy-edge__dndnode_16-dndnode_18" - }, - { - "source": "dndnode_18", - "target": "dndnode_17", - "animated": true, - "style": { - "stroke": "var(--accent-primary)", - "strokeWidth": 1 - }, - "markerEnd": { - "type": "arrowclosed", - "color": "var(--accent-primary)" - }, - "id": "xy-edge__dndnode_18-dndnode_17" - }, - { - "source": "dndnode_19", - "target": "dndnode_20", - "animated": true, - "style": { - "stroke": "var(--accent-primary)", - "strokeWidth": 1 - }, - "markerEnd": { - "type": "arrowclosed", - "color": "var(--accent-primary)" - }, - "id": "xy-edge__dndnode_19-dndnode_20" - }, - { - "source": "dndnode_17", - "target": "dndnode_0", - "animated": true, - "style": { - "stroke": "var(--accent-primary)", - "strokeWidth": 1 - }, - "markerEnd": { - "type": "arrowclosed", - "color": "var(--accent-primary)" - }, - "id": "xy-edge__dndnode_17-dndnode_0" - }, - { - "source": "dndnode_0", - "target": "dndnode_19", - "animated": true, - "style": { - "stroke": "var(--accent-primary)", - "strokeWidth": 1 - }, - "markerEnd": { - "type": "arrowclosed", - "color": "var(--accent-primary)" - }, - "id": "xy-edge__dndnode_0-dndnode_19" - }, - { - "source": "dndnode_20", - "target": "dndnode_1", - "animated": true, - "style": { - "stroke": "var(--accent-primary)", - "strokeWidth": 1 - }, - "markerEnd": { - "type": "arrowclosed", - "color": "var(--accent-primary)" - }, - "id": "xy-edge__dndnode_20-dndnode_1" - }, - { - "source": "dndnode_1", - "target": "dndnode_21", - "animated": true, - "style": { - "stroke": "var(--accent-primary)", - "strokeWidth": 1 - }, - "markerEnd": { - "type": "arrowclosed", - "color": "var(--accent-primary)" - }, - "id": "xy-edge__dndnode_1-dndnode_21" - } - ], - "viewport": { - "x": 577.2942305770323, - "y": -361.29404313600276, - "zoom": 1.0434035118685243 - } -} diff --git a/web-editor/examples/sdsp_track_async.json b/web-editor/examples/sdsp_track_async.json index aa0a975..8dacc50 100644 --- a/web-editor/examples/sdsp_track_async.json +++ b/web-editor/examples/sdsp_track_async.json @@ -71,13 +71,13 @@ "position": { "x": 0, "y": 450 }, "data": { "label": "Wait 2 Seconds", - "operationId": "FlightBlenderClient.wait_x_seconds", - "className": "FlightBlenderClient", - "functionName": "wait_x_seconds", + "operationId": "CommonClient.wait", + "className": "CommonClient", + "functionName": "wait", "description": "Wait for 2 seconds.", "parameters": [ { - "name": "wait_time_seconds", + "name": "duration", "type": "int", "default": 2 } @@ -121,13 +121,13 @@ "position": { "x": 0, "y": 750 }, "data": { "label": "Wait 5 Seconds", - "operationId": "FlightBlenderClient.wait_x_seconds", - "className": "FlightBlenderClient", - "functionName": "wait_x_seconds", + "operationId": "CommonClient.wait", + "className": "CommonClient", + "functionName": "wait", "description": "Wait for 5 seconds.", "parameters": [ { - "name": "wait_time_seconds", + "name": "duration", "type": "int", "default": 5 } @@ -171,8 +171,8 @@ "position": { "x": 0, "y": 1050 }, "data": { "label": "Join Background Task", - "operationId": "SystemClient.join_task", - "className": "SystemClient", + "operationId": "SessionManager.join_task", + "className": "SessionManager", "functionName": "join_task", "description": "Waits for the background task to complete.", "parameters": [ diff --git a/web-editor/package-lock.json b/web-editor/package-lock.json index ece395e..5547538 100644 --- a/web-editor/package-lock.json +++ b/web-editor/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@dagrejs/dagre": "^1.1.8", "@xyflow/react": "^12.9.3", + "js-yaml": "^4.1.1", "lucide-react": "^0.554.0", "react": "^19.2.0", "react-dom": "^19.2.0" @@ -19,6 +20,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", + "@types/js-yaml": "^4.0.9", "@types/node": "^24.10.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", @@ -1814,6 +1816,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2371,7 +2380,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -3471,7 +3479,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/web-editor/package.json b/web-editor/package.json index bc75dcc..0491413 100644 --- a/web-editor/package.json +++ b/web-editor/package.json @@ -13,6 +13,7 @@ "dependencies": { "@dagrejs/dagre": "^1.1.8", "@xyflow/react": "^12.9.3", + "js-yaml": "^4.1.1", "lucide-react": "^0.554.0", "react": "^19.2.0", "react-dom": "^19.2.0" @@ -22,6 +23,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", + "@types/js-yaml": "^4.0.9", "@types/node": "^24.10.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", diff --git a/web-editor/src/components/ScenarioEditor.tsx b/web-editor/src/components/ScenarioEditor.tsx index c26ba4f..441a619 100644 --- a/web-editor/src/components/ScenarioEditor.tsx +++ b/web-editor/src/components/ScenarioEditor.tsx @@ -16,6 +16,7 @@ import type { Operation, OperationParam, NodeData } from '../types/scenario'; import { CustomNode } from './ScenarioEditor/CustomNode'; import { Toolbox } from './ScenarioEditor/Toolbox'; +import { ScenarioList } from './ScenarioEditor/ScenarioList'; import { PropertiesPanel } from './ScenarioEditor/PropertiesPanel'; import { BottomPanel } from './ScenarioEditor/BottomPanel'; import { Header } from './ScenarioEditor/Header'; @@ -59,7 +60,7 @@ const ScenarioEditorContent = () => { useEffect(() => { const checkHealth = async () => { try { - const res = await fetch('http://localhost:8989/health'); + const res = await fetch('/health'); if (res.ok) { setIsConnected(true); } else { @@ -77,7 +78,7 @@ const ScenarioEditorContent = () => { useEffect(() => { if (isConnected) { - fetch('http://localhost:8989/operations') + fetch('/operations') .then(res => res.json()) .then(data => setOperations(data)) .catch(err => console.error('Failed to fetch operations:', err)); @@ -111,14 +112,18 @@ const ScenarioEditorContent = () => { }, [nodes, edges]); const { isRunning, runScenario } = useScenarioRunner(); - const { fileInputRef, handleExportJSON, handleLoadJSON, handleFileChange } = useScenarioFile( + const { handleSaveToServer } = useScenarioFile( nodes, edges, - setNodes, - setEdges, - reactFlowInstance + operations ); + const loadScenarioFromYaml = useCallback((newNodes: Node[], newEdges: Edge[]) => { + setNodes(newNodes); + setEdges(newEdges); + setTimeout(() => reactFlowInstance?.fitView({ padding: 0.2, duration: 400 }), 100); + }, [setNodes, setEdges, reactFlowInstance]); + useEffect(() => { document.documentElement.dataset.theme = theme; sessionStorage.setItem('editor-theme', theme); @@ -390,16 +395,15 @@ const ScenarioEditorContent = () => { toggleTheme={toggleTheme} onLayout={onLayout} onClear={handleClear} - onLoad={handleLoadJSON} - onExport={handleExportJSON} + onSave={handleSaveToServer} onRun={handleRun} isRunning={isRunning} - fileInputRef={fileInputRef as React.RefObject} - onFileChange={handleFileChange} />

- + + +
diff --git a/web-editor/src/components/ScenarioEditor/Header.tsx b/web-editor/src/components/ScenarioEditor/Header.tsx index d83ad6a..2be2a7b 100644 --- a/web-editor/src/components/ScenarioEditor/Header.tsx +++ b/web-editor/src/components/ScenarioEditor/Header.tsx @@ -1,5 +1,4 @@ -import React from 'react'; -import { Activity, Moon, Sun, Layout, Trash2, Upload, Save, Play, Loader2 } from 'lucide-react'; +import { Activity, Moon, Sun, Layout, Trash2, Save, Play, Loader2 } from 'lucide-react'; import styles from '../../styles/Header.module.css'; import btnStyles from '../../styles/Button.module.css'; @@ -8,12 +7,9 @@ interface HeaderProps { toggleTheme: () => void; onLayout: () => void; onClear: () => void; - onLoad: () => void; - onExport: () => void; + onSave: () => void; onRun: () => void; isRunning: boolean; - fileInputRef: React.RefObject; - onFileChange: (event: React.ChangeEvent) => void; } export const Header = ({ @@ -21,12 +17,9 @@ export const Header = ({ toggleTheme, onLayout, onClear, - onLoad, - onExport, + onSave, onRun, isRunning, - fileInputRef, - onFileChange }: HeaderProps) => { return (
@@ -46,20 +39,9 @@ export const Header = ({ Clear - - - +
+
- {groupedOperations.sortedKeys.map(category => ( - - ))} + {activeTab === 'toolbox' ? ( + groupedOperations.sortedKeys.map(category => ( + + )) + ) : ( + children + )}
); diff --git a/web-editor/src/components/ScenarioEditor/__tests__/Header.test.tsx b/web-editor/src/components/ScenarioEditor/__tests__/Header.test.tsx index 9b55d8e..2100ab4 100644 --- a/web-editor/src/components/ScenarioEditor/__tests__/Header.test.tsx +++ b/web-editor/src/components/ScenarioEditor/__tests__/Header.test.tsx @@ -1,7 +1,6 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi } from 'vitest'; import { Header } from '../Header'; -import React from 'react'; describe('Header', () => { const defaultProps = { @@ -9,12 +8,9 @@ describe('Header', () => { toggleTheme: vi.fn(), onLayout: vi.fn(), onClear: vi.fn(), - onLoad: vi.fn(), - onExport: vi.fn(), + onSave: vi.fn(), onRun: vi.fn(), isRunning: false, - fileInputRef: React.createRef() as React.RefObject, - onFileChange: vi.fn(), }; it('renders correctly', () => { @@ -43,18 +39,11 @@ describe('Header', () => { expect(defaultProps.onClear).toHaveBeenCalled(); }); - it('calls onLoad when Load Scenario button is clicked', () => { + it('calls onSave when Save button is clicked', () => { render(
); - const loadButton = screen.getByText('Load Scenario'); - fireEvent.click(loadButton); - expect(defaultProps.onLoad).toHaveBeenCalled(); - }); - - it('calls onExport when Save Scenario button is clicked', () => { - render(
); - const exportButton = screen.getByText('Export'); - fireEvent.click(exportButton); - expect(defaultProps.onExport).toHaveBeenCalled(); + const saveButton = screen.getByText('Save'); + fireEvent.click(saveButton); + expect(defaultProps.onSave).toHaveBeenCalled(); }); it('calls onRun when Run button is clicked', () => { diff --git a/web-editor/src/data/operations.json b/web-editor/src/data/operations.json deleted file mode 100644 index 25766db..0000000 --- a/web-editor/src/data/operations.json +++ /dev/null @@ -1,469 +0,0 @@ -[ - { - "id": "FlightBlenderClient.upload_geo_fence", - "name": "Upload Geo Fence", - "functionName": "upload_geo_fence", - "className": "FlightBlenderClient", - "description": "Upload an Area-of-Interest (Geo Fence) to Flight Blender.\n\nArgs:\n filename: Path to the GeoJSON file containing the geo-fence definition.\n\nReturns:\n The JSON response from the API, including the geo-fence ID if successful.\n\nRaises:\n FlightBlenderError: If the upload request fails.\n json.JSONDecodeError: If the file content is invalid JSON.", - "parameters": [ - { - "name": "filename", - "type": "str | None", - "default": null - } - ], - "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" - }, - { - "id": "FlightBlenderClient.get_geo_fence", - "name": "Get Geo Fence", - "functionName": "get_geo_fence", - "className": "FlightBlenderClient", - "description": "Retrieve the details of the most recently uploaded geo-fence.\n\nReturns:\n The JSON response from the API containing geo-fence details, or a dict\n indicating skip if no geo-fence ID is available.", - "parameters": [], - "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" - }, - { - "id": "FlightBlenderClient.delete_geo_fence", - "name": "Delete Geo Fence", - "functionName": "delete_geo_fence", - "className": "FlightBlenderClient", - "description": "Delete a geo-fence by ID.\n\nArgs:\n geo_fence_id: Optional ID of the geo-fence to delete. If not provided,\n uses the latest uploaded geo-fence ID.\n\nReturns:\n A dictionary with deletion status, including whether it was successful.\n\nNote:\n According to the schema, DELETE returns 204 on success. This method\n normalizes the response to a JSON dict for reporting.", - "parameters": [ - { - "name": "geo_fence_id", - "type": "str | None", - "default": null - } - ], - "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" - }, - { - "id": "FlightBlenderClient.upload_flight_declaration", - "name": "Upload Flight Declaration", - "functionName": "upload_flight_declaration", - "className": "FlightBlenderClient", - "description": "Upload a flight declaration to the Flight Blender API.\n\nAccepts either a filename (str) containing JSON declaration data, or a\nFlightDeclaration model instance. Adjusts datetimes to current time + offsets,\nand posts it. Raises an error if the declaration is not approved.\n\nArgs:\n declaration: Either a path to the JSON flight declaration file (str),\n or a FlightDeclaration model instance.\n\nReturns:\n The JSON response from the API.\n\nRaises:\n FlightBlenderError: If the declaration is not approved or the request fails.\n json.JSONDecodeError: If the file content is invalid JSON (when using filename).", - "parameters": [ - { - "name": "declaration", - "type": "str | BaseModel" - } - ], - "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" - }, - { - "id": "FlightBlenderClient.wait_for_user_input", - "name": "Wait for User Input", - "functionName": "wait_for_user_input", - "className": "FlightBlenderClient", - "description": "Wait for user input to proceed.\n\nThis method prompts the user for input and waits until the user responds.\n\nArgs:\n prompt: The message to display to the user.", - "parameters": [ - { - "name": "prompt", - "type": "str", - "default": "Press Enter to continue..." - } - ], - "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" - }, - { - "id": "FlightBlenderClient.update_operation_state", - "name": "Update Operation State", - "functionName": "update_operation_state", - "className": "FlightBlenderClient", - "description": "Update the state of a flight operation.\n\nPosts the new state and optionally waits for the specified duration.\n\nArgs:\n new_state: The new OperationState to set.\n duration_seconds: Optional seconds to sleep after update (default 0).\n\nReturns:\n The JSON response from the API.\n\nRaises:\n FlightBlenderError: If the update request fails.", - "parameters": [ - { - "name": "new_state", - "type": "OperationState", - "options": [ - { - "name": "PROCESSING", - "value": 0 - }, - { - "name": "ACCEPTED", - "value": 1 - }, - { - "name": "ACTIVATED", - "value": 2 - }, - { - "name": "NONCONFORMING", - "value": 3 - }, - { - "name": "CONTINGENT", - "value": 4 - }, - { - "name": "ENDED", - "value": 5 - }, - { - "name": "WITHDRAWN", - "value": 6 - }, - { - "name": "CANCELLED", - "value": 7 - }, - { - "name": "REJECTED", - "value": 8 - } - ], - "isEnum": true - }, - { - "name": "duration_seconds", - "type": "int", - "default": 0 - } - ], - "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" - }, - { - "id": "FlightBlenderClient.submit_telemetry_from_file", - "name": "Submit Telemetry (from file)", - "functionName": "submit_telemetry_from_file", - "className": "FlightBlenderClient", - "description": "Submit telemetry data for a flight operation.\n\nLoads telemetry states from file and submits them sequentially, with optional\nduration limiting and error handling for rate limits.\n\nArgs:\n filename: Path to the JSON file containing telemetry data.\n duration_seconds: Optional maximum duration in seconds to submit telemetry (default 0 for unlimited).\n\nReturns:\n The JSON response from the last telemetry submission, or None if no submissions occurred.\n\nRaises:\n FlightBlenderError: If maximum waiting time is exceeded due to rate limits.", - "parameters": [ - { - "name": "filename", - "type": "str" - }, - { - "name": "duration_seconds", - "type": "int", - "default": 0 - } - ], - "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" - }, - { - "id": "FlightBlenderClient.wait_x_seconds", - "name": "Wait X seconds", - "functionName": "wait_x_seconds", - "className": "FlightBlenderClient", - "description": "Wait for a specified number of seconds.", - "parameters": [ - { - "name": "wait_time_seconds", - "type": "int", - "default": 5 - } - ], - "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" - }, - { - "id": "FlightBlenderClient.submit_telemetry", - "name": "Submit Telemetry", - "functionName": "submit_telemetry", - "className": "FlightBlenderClient", - "description": "Submit telemetry data for a flight operation from in-memory states.\n\nSubmits telemetry states sequentially from the provided list, with optional\nduration limiting and error handling for rate limits.\n\nArgs:\n states: List of telemetry state dictionaries. If None, uses the generated telemetry states from context.\n duration_seconds: Optional maximum duration in seconds to submit telemetry (default 0 for unlimited).\n\nReturns:\n The JSON response from the last telemetry submission, or None if no submissions occurred.\n\nRaises:\n FlightBlenderError: If maximum waiting time is exceeded due to rate limits.", - "parameters": [ - { - "name": "states", - "type": "list[RIDAircraftState] | None", - "default": null - }, - { - "name": "duration_seconds", - "type": "int", - "default": 0 - } - ], - "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" - }, - { - "id": "FlightBlenderClient.check_operation_state", - "name": "Check Operation State", - "functionName": "check_operation_state", - "className": "FlightBlenderClient", - "description": "Check the operation state (simulated).\n\nThis is a placeholder method for state checking; it simulates waiting\nand returns a success status.\n\nArgs:\n expected_state: The expected OperationState.\n duration_seconds: Seconds to wait for processing.\n\nReturns:\n A dictionary with the check result.", - "parameters": [ - { - "name": "expected_state", - "type": "OperationState", - "options": [ - { - "name": "PROCESSING", - "value": 0 - }, - { - "name": "ACCEPTED", - "value": 1 - }, - { - "name": "ACTIVATED", - "value": 2 - }, - { - "name": "NONCONFORMING", - "value": 3 - }, - { - "name": "CONTINGENT", - "value": 4 - }, - { - "name": "ENDED", - "value": 5 - }, - { - "name": "WITHDRAWN", - "value": 6 - }, - { - "name": "CANCELLED", - "value": 7 - }, - { - "name": "REJECTED", - "value": 8 - } - ], - "isEnum": true - }, - { - "name": "duration_seconds", - "type": "int", - "default": 0 - } - ], - "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" - }, - { - "id": "FlightBlenderClient.check_operation_state_connected", - "name": "Check Operation State Connected", - "functionName": "check_operation_state_connected", - "className": "FlightBlenderClient", - "description": "Check the operation state by polling the API until the expected state is reached.\n\nArgs:\n expected_state: The expected OperationState.\n duration_seconds: Maximum seconds to poll for the state.\n\nReturns:\n The JSON response from the API when the state is reached.\n\nRaises:\n FlightBlenderError: If the expected state is not reached within the timeout.", - "parameters": [ - { - "name": "expected_state", - "type": "OperationState", - "options": [ - { - "name": "PROCESSING", - "value": 0 - }, - { - "name": "ACCEPTED", - "value": 1 - }, - { - "name": "ACTIVATED", - "value": 2 - }, - { - "name": "NONCONFORMING", - "value": 3 - }, - { - "name": "CONTINGENT", - "value": 4 - }, - { - "name": "ENDED", - "value": 5 - }, - { - "name": "WITHDRAWN", - "value": 6 - }, - { - "name": "CANCELLED", - "value": 7 - }, - { - "name": "REJECTED", - "value": 8 - } - ], - "isEnum": true - }, - { - "name": "duration_seconds", - "type": "int", - "default": 0 - } - ], - "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" - }, - { - "id": "FlightBlenderClient.delete_flight_declaration", - "name": "Delete Flight Declaration", - "functionName": "delete_flight_declaration", - "className": "FlightBlenderClient", - "description": "Delete a flight declaration by ID.\n\nReturns:\n A dictionary with deletion status, including whether it was successful.", - "parameters": [], - "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" - }, - { - "id": "FlightBlenderClient.submit_simulated_air_traffic", - "name": "Submit Simulated Air Traffic", - "functionName": "submit_simulated_air_traffic", - "className": "FlightBlenderClient", - "description": null, - "parameters": [ - { - "name": "observations", - "type": "list[list[FlightObservationSchema]]" - }, - { - "name": "single_or_multiple_sensors", - "type": "str", - "default": "single" - } - ], - "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" - }, - { - "id": "FlightBlenderClient.submit_air_traffic", - "name": "Submit Air Traffic", - "functionName": "submit_air_traffic", - "className": "FlightBlenderClient", - "description": "Submit air traffic observations to the Flight Blender API.\n\nArgs:\n observations: List of observation dictionaries containing flight data.\n\nReturns:\n The JSON response from the API.\n\nRaises:\n FlightBlenderError: If the submission request fails.", - "parameters": [ - { - "name": "observations", - "type": "list[FlightObservationSchema]" - } - ], - "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" - }, - { - "id": "FlightBlenderClient.start_stop_sdsp_session", - "name": "Start / Stop SDSP Session", - "functionName": "start_stop_sdsp_session", - "className": "FlightBlenderClient", - "description": "Starts or stops an SDSP (Strategic Deconfliction Service Provider) session based on the specified action.\nThis method interacts with the Flight Blender service to manage the lifecycle of an SDSP session.\nIt can be used to initiate a new session or terminate an existing one.\nArgs:\n session_id (str): The unique identifier of the SDSP session to start or stop.\n action (SDSPSessionAction): The action to perform on the session, such as START or STOP.\nReturns:\n bool: True if the action was successfully performed, False otherwise.\nRaises:\n ValueError: If the session_id is invalid or the action is not supported.\n ConnectionError: If there is an issue communicating with the Flight Blender service.\n FlightBlenderError: If the action fails due to service errors.", - "parameters": [ - { - "name": "session_id", - "type": "str" - }, - { - "name": "action", - "type": "SDSPSessionAction", - "options": [ - { - "name": "START", - "value": "start" - }, - { - "name": "STOP", - "value": "stop" - } - ], - "isEnum": true - } - ], - "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" - }, - { - "id": "FlightBlenderClient.initialize_verify_sdsp_track", - "name": "Verify SDSP Track", - "functionName": "initialize_verify_sdsp_track", - "className": "FlightBlenderClient", - "description": null, - "parameters": [ - { - "name": "expected_track_interval_seconds", - "type": "int" - }, - { - "name": "expected_track_count", - "type": "int" - }, - { - "name": "session_id", - "type": "str" - } - ], - "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" - }, - { - "id": "FlightBlenderClient.initialize_verify_sdsp_heartbeat", - "name": "Verify SDSP Heartbeat", - "functionName": "initialize_verify_sdsp_heartbeat", - "className": "FlightBlenderClient", - "description": null, - "parameters": [ - { - "name": "expected_heartbeat_interval_seconds", - "type": "int" - }, - { - "name": "expected_heartbeat_count", - "type": "int" - }, - { - "name": "session_id", - "type": "str" - } - ], - "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" - }, - { - "id": "FlightBlenderClient.teardown_flight_declaration", - "name": "Teardown Flight Declaration", - "functionName": "teardown_flight_declaration", - "className": "FlightBlenderClient", - "description": null, - "parameters": [], - "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" - }, - { - "id": "FlightBlenderClient.setup_flight_declaration", - "name": "Setup Flight Declaration", - "functionName": "setup_flight_declaration", - "className": "FlightBlenderClient", - "description": "Generates data and uploads flight declaration.", - "parameters": [ - { - "name": "flight_declaration_path", - "type": "str" - }, - { - "name": "trajectory_path", - "type": "str" - } - ], - "filePath": "src/openutm_verification/core/clients/flight_blender/flight_blender_client.py" - }, - { - "id": "AirTrafficClient.generate_simulated_air_traffic_data", - "name": "Generate Simulated Air Traffic Data", - "functionName": "generate_simulated_air_traffic_data", - "className": "AirTrafficClient", - "description": "Generate simulated air traffic data from GeoJSON configuration.\n\nLoads GeoJSON data from the specified config path and uses it to generate\nsimulated flight observations for the given duration. If no config path\nor duration is provided, uses the default settings from the client configuration.\n\nArgs:\n config_path: Path to the GeoJSON configuration file. Defaults to settings value.\n duration: Duration in seconds for which to generate data. Defaults to settings value.\n\nReturns:\n List of simulated flight observation dictionaries, or None if generation fails.", - "parameters": [ - { - "name": "config_path", - "type": "str | None", - "default": null - }, - { - "name": "duration", - "type": "int | None", - "default": null - } - ], - "filePath": "src/openutm_verification/core/clients/air_traffic/air_traffic_client.py" - }, - { - "id": "OpenSkyClient.fetch_data", - "name": "Fetch OpenSky Data", - "functionName": "fetch_data", - "className": "OpenSkyClient", - "description": "Fetch and process live flight data from OpenSky Network.\n\nRetrieves current flight states from the OpenSky API within the configured\nviewport bounds and processes them into standardized observation format.\n\nReturns:\n List of flight observation dictionaries, or None if no data is available.", - "parameters": [], - "filePath": "src/openutm_verification/core/clients/opensky/opensky_client.py" - } -] diff --git a/web-editor/src/hooks/__tests__/useScenarioFile.test.ts b/web-editor/src/hooks/__tests__/useScenarioFile.test.ts index 5ffcae7..35c4376 100644 --- a/web-editor/src/hooks/__tests__/useScenarioFile.test.ts +++ b/web-editor/src/hooks/__tests__/useScenarioFile.test.ts @@ -1,7 +1,7 @@ import { renderHook, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { useScenarioFile } from '../useScenarioFile'; -import type { Node, Edge, ReactFlowInstance } from '@xyflow/react'; +import type { Node, Edge } from '@xyflow/react'; import type { NodeData } from '../../types/scenario'; describe('useScenarioFile', () => { @@ -9,88 +9,63 @@ describe('useScenarioFile', () => { { id: '1', position: { x: 0, y: 0 }, data: { label: 'Node 1' } } ]; const mockEdges: Edge[] = []; - const setNodes = vi.fn(); - const setEdges = vi.fn(); - const mockReactFlowInstance = { - getViewport: vi.fn().mockReturnValue({ x: 0, y: 0, zoom: 1 }), - } as unknown as ReactFlowInstance, Edge>; beforeEach(() => { - globalThis.URL.createObjectURL = vi.fn(); - globalThis.URL.revokeObjectURL = vi.fn(); + globalThis.fetch = vi.fn(); + globalThis.prompt = vi.fn(); + globalThis.alert = vi.fn(); }); afterEach(() => { vi.restoreAllMocks(); }); - it('exports JSON correctly', () => { - const { result } = renderHook(() => useScenarioFile(mockNodes, mockEdges, setNodes, setEdges, mockReactFlowInstance)); + it('saves scenario to server successfully', async () => { + const { result } = renderHook(() => useScenarioFile(mockNodes, mockEdges, [])); - // Mock document.createElement and click - const mockLink = { - href: '', - download: '', - click: vi.fn(), - remove: vi.fn(), - }; - const createElementSpy = vi.spyOn(document, 'createElement').mockReturnValue(mockLink as unknown as HTMLAnchorElement); - const appendChildSpy = vi.spyOn(document.body, 'appendChild').mockImplementation(() => mockLink as unknown as HTMLAnchorElement); + vi.mocked(globalThis.prompt).mockReturnValue('test_scenario'); + vi.mocked(globalThis.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ message: 'Saved' }), + } as Response); - act(() => { - result.current.handleExportJSON(); + await act(async () => { + await result.current.handleSaveToServer(); }); - expect(createElementSpy).toHaveBeenCalledWith('a'); - expect(appendChildSpy).toHaveBeenCalled(); - expect(mockLink.click).toHaveBeenCalled(); - expect(mockLink.remove).toHaveBeenCalled(); - expect(globalThis.URL.createObjectURL).toHaveBeenCalled(); + expect(globalThis.prompt).toHaveBeenCalled(); + expect(globalThis.fetch).toHaveBeenCalledWith('/api/scenarios/test_scenario', expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + })); + expect(globalThis.alert).toHaveBeenCalledWith('Saved'); }); - it('loads JSON correctly', async () => { - const { result } = renderHook(() => useScenarioFile(mockNodes, mockEdges, setNodes, setEdges, mockReactFlowInstance)); - - const mockInput = { - click: vi.fn(), - }; + it('handles save error', async () => { + const { result } = renderHook(() => useScenarioFile(mockNodes, mockEdges, [])); - const fileInputRef = result.current.fileInputRef; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - fileInputRef.current = mockInput; + vi.mocked(globalThis.prompt).mockReturnValue('test_scenario'); + vi.mocked(globalThis.fetch).mockResolvedValue({ + ok: false, + statusText: 'Internal Server Error', + } as Response); - act(() => { - result.current.handleLoadJSON(); + await act(async () => { + await result.current.handleSaveToServer(); }); - expect(mockInput.click).toHaveBeenCalled(); + expect(globalThis.alert).toHaveBeenCalledWith('Failed to save scenario to server.'); }); - it('handles file change', async () => { - const { result } = renderHook(() => useScenarioFile(mockNodes, mockEdges, setNodes, setEdges, mockReactFlowInstance)); - - const fileContent = JSON.stringify({ - nodes: [{ id: '2', position: { x: 10, y: 10 }, data: { label: 'Node 2' } }], - edges: [] - }); - const file = new File([fileContent], 'scenario.json', { type: 'application/json' }); - // Mock text() method which might be missing in jsdom/node File implementation - file.text = vi.fn().mockResolvedValue(fileContent); + it('does nothing if prompt is cancelled', async () => { + const { result } = renderHook(() => useScenarioFile(mockNodes, mockEdges, [])); - const event = { - target: { - files: [file] - } - } as unknown as React.ChangeEvent; + vi.mocked(globalThis.prompt).mockReturnValue(null); await act(async () => { - await result.current.handleFileChange(event); + await result.current.handleSaveToServer(); }); - expect(setNodes).toHaveBeenCalledWith(expect.arrayContaining([ - expect.objectContaining({ id: '2' }) - ])); - expect(setEdges).toHaveBeenCalledWith([]); + expect(globalThis.fetch).not.toHaveBeenCalled(); }); }); diff --git a/web-editor/src/hooks/__tests__/useScenarioRunner.test.ts b/web-editor/src/hooks/__tests__/useScenarioRunner.test.ts index cabbc4e..5061657 100644 --- a/web-editor/src/hooks/__tests__/useScenarioRunner.test.ts +++ b/web-editor/src/hooks/__tests__/useScenarioRunner.test.ts @@ -39,7 +39,8 @@ describe('useScenarioRunner', () => { }); expect(executionResult).toEqual(mockResult); - expect(globalThis.fetch).toHaveBeenCalledWith('http://localhost:8989/run-scenario', expect.any(Object)); + expect(globalThis.fetch).toHaveBeenCalledWith('/session/reset', expect.any(Object)); + expect(globalThis.fetch).toHaveBeenCalledWith('/api/step', expect.any(Object)); expect(result.current.isRunning).toBe(false); }); diff --git a/web-editor/src/hooks/useScenarioFile.ts b/web-editor/src/hooks/useScenarioFile.ts index 3be01a3..b0e18b6 100644 --- a/web-editor/src/hooks/useScenarioFile.ts +++ b/web-editor/src/hooks/useScenarioFile.ts @@ -1,87 +1,44 @@ -import { useCallback, useRef } from 'react'; -import type { Node, Edge, ReactFlowInstance } from '@xyflow/react'; -import type { NodeData } from '../types/scenario'; +import { useCallback } from 'react'; +import type { Node, Edge } from '@xyflow/react'; +import type { NodeData, Operation } from '../types/scenario'; +import { convertGraphToYaml } from '../utils/scenarioConversion'; export const useScenarioFile = ( nodes: Node[], edges: Edge[], - setNodes: (nodes: Node[]) => void, - setEdges: (edges: Edge[]) => void, - reactFlowInstance: ReactFlowInstance, Edge> | null + operations: Operation[] ) => { - const fileInputRef = useRef(null); + const handleSaveToServer = useCallback(async () => { + const scenarioName = prompt("Enter scenario name (e.g. my_scenario):", "new_scenario"); + if (!scenarioName) return; - const handleExportJSON = useCallback(() => { - // Remove style and width from nodes to keep export clean - const cleanNodes = nodes.map((node) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { style, ...rest } = node; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { width: _width, ...data } = rest.data as { width?: number;[key: string]: unknown }; - return { - ...rest, - data, - }; - }); - - const flowData = { - nodes: cleanNodes, - edges, - viewport: reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 }, - }; - - const dataStr = JSON.stringify(flowData, null, 2); - const dataBlob = new Blob([dataStr], { type: 'application/json' }); - const url = URL.createObjectURL(dataBlob); - const link = document.createElement('a'); - link.href = url; - link.download = `scenario_${new Date().toISOString().replaceAll(/[:.]/g, '-')}.json`; - document.body.appendChild(link); - link.click(); - link.remove(); - URL.revokeObjectURL(url); - }, [nodes, edges, reactFlowInstance]); - - const handleLoadJSON = useCallback(() => { - fileInputRef.current?.click(); - }, []); - - const handleFileChange = useCallback(async (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; + const scenario = convertGraphToYaml(nodes, edges, operations); try { - const content = await file.text(); - const flowData = JSON.parse(content); - - if (flowData.nodes) { - // Apply custom node type to imported nodes - const nodesWithStyle = flowData.nodes.map((node: Node) => ({ - ...node, - type: 'custom', - style: undefined, // Remove hardcoded style - data: { - ...node.data, - } - })); - setNodes(nodesWithStyle); - } - if (flowData.edges) { - setEdges(flowData.edges); - } - if (flowData.viewport && reactFlowInstance) { - reactFlowInstance.setViewport(flowData.viewport); + const response = await fetch(`/api/scenarios/${scenarioName}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(scenario), + }); + + if (!response.ok) { + throw new Error(`Server error: ${response.statusText}`); } + + const result = await response.json(); + alert(result.message || "Scenario saved successfully!"); } catch (error) { - console.error('Error parsing JSON file:', error); - alert('Error loading file. Please ensure it is a valid JSON scenario file.'); + console.error('Error saving scenario:', error); + alert('Failed to save scenario to server.'); } + }, [nodes, edges]); - // Reset file input to allow loading the same file again - if (event.target) { - event.target.value = ''; - } - }, [setNodes, setEdges, reactFlowInstance]); + // handleLoadYAML is not needed anymore as we load from the sidebar list + // but we keep handleFileChange if we want to support local file loading as a fallback? + // The user said "We don't need to handle YAML files locally". + // So I will remove local file handling. - return { fileInputRef, handleExportJSON, handleLoadJSON, handleFileChange }; + return { handleSaveToServer }; }; diff --git a/web-editor/src/hooks/useScenarioRunner.ts b/web-editor/src/hooks/useScenarioRunner.ts index 63ecc32..49c9763 100644 --- a/web-editor/src/hooks/useScenarioRunner.ts +++ b/web-editor/src/hooks/useScenarioRunner.ts @@ -47,7 +47,7 @@ export const useScenarioRunner = () => { try { // 1. Reset Session - await fetch('http://localhost:8989/session/reset', { method: 'POST' }); + await fetch('/session/reset', { method: 'POST' }); const results: { id: string; status: string; result?: unknown; error?: unknown }[] = []; @@ -66,14 +66,14 @@ export const useScenarioRunner = () => { const stepDefinition = { id: node.id, - name: node.data.label, // Assuming label is the step name - parameters: params, - run_in_background: !!node.data.runInBackground + step: node.data.label, // The backend expects 'step', not 'name' + arguments: params, // The backend expects 'arguments', not 'parameters' + background: !!node.data.runInBackground // The backend expects 'background', not 'run_in_background' }; console.log(`Executing step ${node.id}: ${node.data.label}`, stepDefinition); - const response = await fetch('http://localhost:8989/api/step', { + const response = await fetch('/api/step', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(stepDefinition) diff --git a/web-editor/src/styles/Toolbox.module.css b/web-editor/src/styles/Toolbox.module.css index 5c9f204..f652c6a 100644 --- a/web-editor/src/styles/Toolbox.module.css +++ b/web-editor/src/styles/Toolbox.module.css @@ -59,3 +59,33 @@ .nodeItem:active { cursor: grabbing; } + +.tabContainer { + display: flex; + border-bottom: 1px solid var(--border-color); + background-color: var(--bg-secondary); +} + +.tabButton { + flex: 1; + padding: 12px; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-secondary); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.tabButton:hover { + color: var(--text-primary); + background-color: var(--bg-primary); +} + +.activeTab { + color: var(--accent-primary); + border-bottom-color: var(--accent-primary); + background-color: var(--bg-primary); +} diff --git a/web-editor/src/types/scenario.ts b/web-editor/src/types/scenario.ts index 567b717..f90f84b 100644 --- a/web-editor/src/types/scenario.ts +++ b/web-editor/src/types/scenario.ts @@ -25,10 +25,18 @@ export interface NodeData extends Record { } export interface ScenarioStep { - id: string; + id?: string; + step: string; + arguments: Record; + needs?: string[]; + background?: boolean; + description?: string; +} + +export interface ScenarioDefinition { name: string; - parameters: Record; - run_in_background?: boolean; + description?: string; + steps: ScenarioStep[]; } export interface StepResult { diff --git a/web-editor/src/utils/scenarioConversion.ts b/web-editor/src/utils/scenarioConversion.ts new file mode 100644 index 0000000..0622230 --- /dev/null +++ b/web-editor/src/utils/scenarioConversion.ts @@ -0,0 +1,165 @@ +import type { Node, Edge } from '@xyflow/react'; +import type { Operation, ScenarioDefinition, ScenarioStep, NodeData } from '../types/scenario'; + +export const convertYamlToGraph = ( + scenario: ScenarioDefinition, + operations: Operation[] +): { nodes: Node[]; edges: Edge[] } => { + const nodes: Node[] = []; + const edges: Edge[] = []; + + let yPos = 0; + const xPos = 250; + const gap = 150; + const usedIds = new Set(); + + scenario.steps.forEach((step, index) => { + const operation = operations.find(op => op.name === step.step); + if (!operation) { + console.warn(`Operation ${step.step} not found`); + return; + } + + let nodeId = step.id; + + if (!nodeId) { + // Use the step name as is for the ID + nodeId = step.step; + } + + // Ensure uniqueness (in case the provided ID was already used) + if (usedIds.has(nodeId)) { + nodeId = `node_${index}_${Date.now()}`; + } + + usedIds.add(nodeId); + + // Map arguments to parameters + const parameters = operation.parameters.map(param => ({ + ...param, + default: step.arguments?.[param.name] ?? param.default + })); + + const node: Node = { + id: nodeId, + type: 'custom', + position: { x: xPos, y: yPos }, + data: { + label: step.step, + operationId: operation.id, + description: step.description || operation.description, + parameters: parameters, + runInBackground: step.background + } + }; + + nodes.push(node); + yPos += gap; + + if (index > 0) { + const prevNode = nodes[index - 1]; + edges.push({ + id: `e_${prevNode.id}-${nodeId}`, + source: prevNode.id, + target: nodeId, + type: 'smoothstep' + }); + } + }); + + return { nodes, edges }; +}; + +export const convertGraphToYaml = ( + nodes: Node[], + edges: Edge[], + operations: Operation[] = [] +): ScenarioDefinition => { + // Sort nodes based on edges to determine order + const targetIds = new Set(edges.map(e => e.target)); + const roots = nodes.filter(n => !targetIds.has(n.id)); + + // If multiple roots, sort by y position + roots.sort((a, b) => a.position.y - b.position.y); + + const sortedNodes: Node[] = []; + const visited = new Set(); + + const visit = (node: Node) => { + if (visited.has(node.id)) return; + visited.add(node.id); + sortedNodes.push(node); + + // Find outgoing edges + const outgoing = edges + .filter(e => e.source === node.id) + .map(e => nodes.find(n => n.id === e.target)) + .filter((n): n is Node => !!n); + + // Sort outgoing by y position + outgoing.sort((a, b) => a.position.y - b.position.y); + + outgoing.forEach(visit); + }; + + roots.forEach(visit); + + // Handle disconnected nodes + nodes.forEach(node => { + if (!visited.has(node.id)) { + sortedNodes.push(node); + } + }); + + const steps: ScenarioStep[] = sortedNodes.map(node => { + const operation = operations.find(op => op.id === node.data.operationId); + const args: Record = {}; + + node.data.parameters?.forEach(param => { + const currentValue = param.default; + + // Skip null values + if (currentValue === null) return; + + // Skip undefined values + if (currentValue === undefined) return; + + // Skip default values if operation is available + if (operation) { + const originalParam = operation.parameters.find(p => p.name === param.name); + if (originalParam && originalParam.default === currentValue) { + return; + } + } + + args[param.name] = currentValue; + }); + + const step: ScenarioStep = { + step: node.data.label, + arguments: args, + }; + + // Don't save IDs to keep YAML clean + // if (node.id && !node.id.startsWith('node_')) { + // step.id = node.id; + // } + + // Description is not saved to YAML to keep it clean + // if (node.data.description) { + // step.description = node.data.description; + // } + + if (node.data.runInBackground) { + step.background = true; + } + + return step; + }); + + return { + name: "Exported Scenario", + description: "Exported from OpenUTM Scenario Designer", + steps + }; +}; diff --git a/web-editor/vite.config.ts b/web-editor/vite.config.ts index 6019f4e..d710931 100644 --- a/web-editor/vite.config.ts +++ b/web-editor/vite.config.ts @@ -5,6 +5,15 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + server: { + proxy: { + '/api': 'http://localhost:8989', + '/health': 'http://localhost:8989', + '/operations': 'http://localhost:8989', + '/session': 'http://localhost:8989', + '/run-scenario': 'http://localhost:8989', + } + }, test: { globals: true, environment: 'jsdom', From 5419138673aa4bf4b2eb8343a35075bc507f862e Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Tue, 30 Dec 2025 16:59:52 +0100 Subject: [PATCH 10/20] Fix errors --- .../flight_blender/flight_blender_client.py | 25 ++++++++-------- .../core/execution/dependencies.py | 2 ++ .../scenarios/test_add_flight_declaration.py | 5 ++-- .../scenarios/test_add_operational_intent.py | 8 ++--- .../scenarios/test_f1_flow.py | 5 ++-- .../test_f1_no_telemetry_with_user_input.py | 5 ++-- .../scenarios/test_f2_flow.py | 5 ++-- .../scenarios/test_f3_flow.py | 7 ++--- .../scenarios/test_f5_flow.py | 5 ++-- src/openutm_verification/server/runner.py | 2 +- tests/test_scenarios.py | 30 +++++++++---------- 11 files changed, 46 insertions(+), 53 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 201f4f4..f42dc90 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 @@ -15,7 +15,6 @@ from openutm_verification.core.clients.flight_blender.base_client import ( BaseBlenderAPIClient, ) -from openutm_verification.core.execution.config_models import DataFiles from openutm_verification.core.execution.scenario_runner import ( ScenarioContext, scenario_step, @@ -93,6 +92,7 @@ def __init__( credentials: dict, request_timeout: int = 10, flight_declaration_path: str | None = None, + flight_declaration_via_operational_intent: str | None = None, trajectory_path: str | None = None, geo_fence_path: str | None = None, ) -> None: @@ -107,6 +107,7 @@ def __init__( self.flight_declaration_path = flight_declaration_path self.trajectory_path = trajectory_path self.geo_fence_path = geo_fence_path + self.flight_declaration_via_operational_intent = flight_declaration_via_operational_intent logger.debug(f"Initialized FlightBlenderClient with base_url={base_url}, request_timeout={request_timeout}") @@ -720,7 +721,7 @@ async def initialize_verify_sdsp_track( all_received_messages = [] # Start Receiving messages from now till six seconds from now while arrow.now() < six_seconds_from_now: - await asyncio.sleep(0.1) + await asyncio.sleep(0.2) message = await asyncio.to_thread(ws_connection.recv) message = json.loads(message) if "track_data" not in message or not message["track_data"]: @@ -917,26 +918,24 @@ async def setup_flight_declaration( raise FlightBlenderError("Failed to upload flight declaration during setup_flight_declaration") @asynccontextmanager - async def create_flight_declaration(self, data_files: DataFiles): + async def create_flight_declaration(self): """Context manager to setup and teardown a flight operation based on scenario config.""" - assert data_files.flight_declaration is not None, "Flight declaration file path must be provided" - assert data_files.trajectory is not None, "Trajectory file path must be provided" - await self.setup_flight_declaration(data_files.flight_declaration, data_files.trajectory) + 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) try: yield finally: logger.info("All test steps complete..") @asynccontextmanager - async def create_flight_declaration_via_operational_intent(self, data_files: DataFiles): + async def create_flight_declaration_via_operational_intent(self): """Context manager to setup and teardown a flight operation based on scenario config.""" - assert data_files.flight_declaration_via_operational_intent is not None, ( - "Flight declaration via operational intent file path must be provided" - ) - assert data_files.trajectory is not None, "Trajectory file path must be provided" + assert self.flight_declaration_via_operational_intent is not None, "Flight declaration via operational intent file path must be provided" + assert self.trajectory_path is not None, "Trajectory file path must be provided" await self.setup_flight_declaration_via_operational_intent( - flight_declaration_via_operational_intent_path=data_files.flight_declaration_via_operational_intent, - trajectory_path=data_files.trajectory, + flight_declaration_via_operational_intent_path=self.flight_declaration_via_operational_intent, + trajectory_path=self.trajectory_path, ) try: yield diff --git a/src/openutm_verification/core/execution/dependencies.py b/src/openutm_verification/core/execution/dependencies.py index 71d18b9..7d771ce 100644 --- a/src/openutm_verification/core/execution/dependencies.py +++ b/src/openutm_verification/core/execution/dependencies.py @@ -142,6 +142,7 @@ def data_files(scenario_id: ScenarioId) -> Generator[DataFiles, None, None]: trajectory = config.data_files.trajectory flight_declaration = config.data_files.flight_declaration geo_fence = config.data_files.geo_fence + flight_declaration_via_operational_intent = config.data_files.flight_declaration_via_operational_intent data = DataFiles( trajectory=trajectory, @@ -181,6 +182,7 @@ async def flight_blender_client(config: AppConfig, data_files: DataFiles) -> Asy base_url=config.flight_blender.url, credentials=credentials, flight_declaration_path=data_files.flight_declaration, + flight_declaration_via_operational_intent=data_files.flight_declaration_via_operational_intent, trajectory_path=data_files.trajectory, geo_fence_path=data_files.geo_fence, ) as fb_client: diff --git a/src/openutm_verification/scenarios/test_add_flight_declaration.py b/src/openutm_verification/scenarios/test_add_flight_declaration.py index b6d6bf1..6ef51d3 100644 --- a/src/openutm_verification/scenarios/test_add_flight_declaration.py +++ b/src/openutm_verification/scenarios/test_add_flight_declaration.py @@ -1,11 +1,10 @@ from openutm_verification.core.clients.flight_blender.flight_blender_client import FlightBlenderClient -from openutm_verification.core.execution.config_models import DataFiles from openutm_verification.models import OperationState from openutm_verification.scenarios.registry import register_scenario @register_scenario("add_flight_declaration") -async def test_add_flight_declaration(fb_client: FlightBlenderClient, data_files: DataFiles) -> None: +async def test_add_flight_declaration(fb_client: FlightBlenderClient) -> None: """Runs the add flight declaration scenario. This scenario replicates the behavior of the add_flight_declaration.py importer: @@ -22,7 +21,7 @@ async def test_add_flight_declaration(fb_client: FlightBlenderClient, data_files Returns: A ScenarioResult object containing the results of the scenario execution. """ - async with fb_client.create_flight_declaration(data_files): + async with fb_client.create_flight_declaration(): await fb_client.update_operation_state(state=OperationState.ACTIVATED, duration=20) await fb_client.submit_telemetry(duration=30) await fb_client.update_operation_state(state=OperationState.ENDED) diff --git a/src/openutm_verification/scenarios/test_add_operational_intent.py b/src/openutm_verification/scenarios/test_add_operational_intent.py index d80ed72..d1c56d0 100644 --- a/src/openutm_verification/scenarios/test_add_operational_intent.py +++ b/src/openutm_verification/scenarios/test_add_operational_intent.py @@ -1,11 +1,11 @@ +from openutm_verification.core.clients.common.common_client import CommonClient from openutm_verification.core.clients.flight_blender.flight_blender_client import FlightBlenderClient -from openutm_verification.core.execution.config_models import DataFiles from openutm_verification.models import OperationState from openutm_verification.scenarios.registry import register_scenario @register_scenario("add_flight_declaration_via_operational_intent") -async def test_add_flight_declaration_via_operational_intent(fb_client: FlightBlenderClient, data_files: DataFiles) -> None: +async def test_add_flight_declaration_via_operational_intent(fb_client: FlightBlenderClient, common_client: CommonClient) -> None: """Runs the add flight declaration scenario. This scenario replicates the behavior of the add_flight_declaration.py importer: @@ -23,7 +23,7 @@ async def test_add_flight_declaration_via_operational_intent(fb_client: FlightBl A ScenarioResult object containing the results of the scenario execution. """ - async with fb_client.create_flight_declaration_via_operational_intent(data_files): + async with fb_client.create_flight_declaration_via_operational_intent(): await fb_client.update_operation_state(new_state=OperationState.ACTIVATED, duration_seconds=5) - await fb_client.wait_x_seconds(10) + await common_client.wait(10) await fb_client.update_operation_state(new_state=OperationState.ENDED) diff --git a/src/openutm_verification/scenarios/test_f1_flow.py b/src/openutm_verification/scenarios/test_f1_flow.py index 44076b3..6e37ac6 100644 --- a/src/openutm_verification/scenarios/test_f1_flow.py +++ b/src/openutm_verification/scenarios/test_f1_flow.py @@ -1,11 +1,10 @@ from openutm_verification.core.clients.flight_blender.flight_blender_client import FlightBlenderClient -from openutm_verification.core.execution.config_models import DataFiles from openutm_verification.models import OperationState from openutm_verification.scenarios.registry import register_scenario @register_scenario("F1_happy_path") -async def test_f1_happy_path(fb_client: FlightBlenderClient, data_files: DataFiles): +async def test_f1_happy_path(fb_client: FlightBlenderClient): """Runs the F1 happy path scenario. This scenario simulates a complete, successful flight operation: @@ -20,7 +19,7 @@ async def test_f1_happy_path(fb_client: FlightBlenderClient, data_files: DataFil Returns: A ScenarioResult object containing the results of the scenario execution. """ - async with fb_client.create_flight_declaration(data_files): + async with fb_client.create_flight_declaration(): await fb_client.update_operation_state(state=OperationState.ACTIVATED) await fb_client.submit_telemetry(duration=30) await fb_client.update_operation_state(state=OperationState.ENDED) diff --git a/src/openutm_verification/scenarios/test_f1_no_telemetry_with_user_input.py b/src/openutm_verification/scenarios/test_f1_no_telemetry_with_user_input.py index 0cb60b0..dcaf6c0 100644 --- a/src/openutm_verification/scenarios/test_f1_no_telemetry_with_user_input.py +++ b/src/openutm_verification/scenarios/test_f1_no_telemetry_with_user_input.py @@ -1,12 +1,11 @@ from openutm_verification.core.clients.common.common_client import CommonClient from openutm_verification.core.clients.flight_blender.flight_blender_client import FlightBlenderClient -from openutm_verification.core.execution.config_models import DataFiles from openutm_verification.models import OperationState from openutm_verification.scenarios.registry import register_scenario @register_scenario("F1_flow_no_telemetry_with_user_input") -async def test_f1_no_telemetry_with_user_input(fb_client: FlightBlenderClient, data_files: DataFiles, common_client: CommonClient): +async def test_f1_no_telemetry_with_user_input(fb_client: FlightBlenderClient, common_client: CommonClient): """Runs the F1 no telemetry with user input scenario. This scenario simulates a complete, successful flight operation: @@ -21,7 +20,7 @@ async def test_f1_no_telemetry_with_user_input(fb_client: FlightBlenderClient, d Returns: A ScenarioResult object containing the results of the scenario execution. """ - async with fb_client.create_flight_declaration(data_files): + async with fb_client.create_flight_declaration(): await common_client.wait(duration=5) await fb_client.update_operation_state(state=OperationState.ACTIVATED) await fb_client.wait_for_user_input(prompt="Press Enter to end the operation...") diff --git a/src/openutm_verification/scenarios/test_f2_flow.py b/src/openutm_verification/scenarios/test_f2_flow.py index af2ea6c..38f01fe 100644 --- a/src/openutm_verification/scenarios/test_f2_flow.py +++ b/src/openutm_verification/scenarios/test_f2_flow.py @@ -1,11 +1,10 @@ from openutm_verification.core.clients.flight_blender.flight_blender_client import FlightBlenderClient -from openutm_verification.core.execution.config_models import DataFiles from openutm_verification.models import OperationState from openutm_verification.scenarios.registry import register_scenario @register_scenario("F2_contingent_path") -async def test_f2_contingent_path(fb_client: FlightBlenderClient, data_files: DataFiles): +async def test_f2_contingent_path(fb_client: FlightBlenderClient): """Runs the F2 contingent path scenario. This scenario simulates a flight operation that enters a contingent state: @@ -21,7 +20,7 @@ async def test_f2_contingent_path(fb_client: FlightBlenderClient, data_files: Da Returns: A ScenarioResult object containing the results of the scenario execution. """ - async with fb_client.create_flight_declaration(data_files): + async with fb_client.create_flight_declaration(): await fb_client.update_operation_state(state=OperationState.ACTIVATED) await fb_client.submit_telemetry(duration=10) await fb_client.update_operation_state(state=OperationState.CONTINGENT, duration=7) diff --git a/src/openutm_verification/scenarios/test_f3_flow.py b/src/openutm_verification/scenarios/test_f3_flow.py index 26deff5..cf8b975 100644 --- a/src/openutm_verification/scenarios/test_f3_flow.py +++ b/src/openutm_verification/scenarios/test_f3_flow.py @@ -1,12 +1,11 @@ from openutm_verification.core.clients.common.common_client import CommonClient from openutm_verification.core.clients.flight_blender.flight_blender_client import FlightBlenderClient -from openutm_verification.core.execution.config_models import DataFiles from openutm_verification.models import OperationState from openutm_verification.scenarios.registry import register_scenario @register_scenario("F3_non_conforming_path") -async def test_f3_non_conforming_path(fb_client: FlightBlenderClient, data_files: DataFiles, common_client: CommonClient): +async def test_f3_non_conforming_path(fb_client: FlightBlenderClient, common_client: CommonClient): """Runs the F3 non-conforming path scenario. This scenario simulates a flight that deviates from its declared flight plan, @@ -18,12 +17,10 @@ async def test_f3_non_conforming_path(fb_client: FlightBlenderClient, data_files Args: fb_client: The FlightBlenderClient instance for API interaction. - data_files: The DataFiles instance containing file paths for telemetry, flight declaration, and geo-fence. - Returns: A ScenarioResult object containing the results of the scenario execution. """ - async with fb_client.create_flight_declaration(data_files): + async with fb_client.create_flight_declaration(): await fb_client.update_operation_state(state=OperationState.ACTIVATED) await common_client.wait(5) await fb_client.submit_telemetry(duration=20) diff --git a/src/openutm_verification/scenarios/test_f5_flow.py b/src/openutm_verification/scenarios/test_f5_flow.py index e23b9af..076b5f5 100644 --- a/src/openutm_verification/scenarios/test_f5_flow.py +++ b/src/openutm_verification/scenarios/test_f5_flow.py @@ -1,12 +1,11 @@ from openutm_verification.core.clients.flight_blender.flight_blender_client import FlightBlenderClient -from openutm_verification.core.execution.config_models import DataFiles from openutm_verification.models import OperationState from openutm_verification.scenarios.registry import register_scenario @register_scenario("F5_non_conforming_path") -async def test_f5_non_conforming_contingent_path(fb_client: FlightBlenderClient, data_files: DataFiles) -> None: - async with fb_client.create_flight_declaration(data_files): +async def test_f5_non_conforming_contingent_path(fb_client: FlightBlenderClient) -> None: + async with fb_client.create_flight_declaration(): await fb_client.update_operation_state(state=OperationState.ACTIVATED) await fb_client.submit_telemetry(duration=20) await fb_client.check_operation_state_connected(expected_state=OperationState.NONCONFORMING, duration=5) diff --git a/src/openutm_verification/server/runner.py b/src/openutm_verification/server/runner.py index 81271a2..d0c2649 100644 --- a/src/openutm_verification/server/runner.py +++ b/src/openutm_verification/server/runner.py @@ -333,7 +333,7 @@ async def _execute_step(self, step: StepDefinition) -> Dict[str, Any]: return {"id": step.id, "step": step.step, "status": status_str, "result": result_data} async def execute_single_step(self, step: StepDefinition) -> Dict[str, Any]: - if not self.session_resolver: + if not self.session_resolver or not self.session_context: logger.info("Session resolver not found, initializing session") await self.initialize_session() diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index d02a958..7ea6f2d 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -39,38 +39,38 @@ def common_client(): return AsyncMock() -async def test_add_flight_declaration_scenario(fb_client, data_files): - await scenario_add_flight_declaration(fb_client, data_files) +async def test_add_flight_declaration_scenario(fb_client): + await scenario_add_flight_declaration(fb_client) - fb_client.create_flight_declaration.assert_called_once_with(data_files) + fb_client.create_flight_declaration.assert_called_once_with() fb_client.update_operation_state.assert_any_call(state=OperationState.ACTIVATED, duration=20) fb_client.submit_telemetry.assert_called_once_with(duration=30) fb_client.update_operation_state.assert_any_call(state=OperationState.ENDED) -async def test_f1_happy_path_scenario(fb_client, data_files): - await scenario_f1_happy_path(fb_client, data_files) +async def test_f1_happy_path_scenario(fb_client): + await scenario_f1_happy_path(fb_client) - fb_client.create_flight_declaration.assert_called_once_with(data_files) + fb_client.create_flight_declaration.assert_called_once_with() fb_client.update_operation_state.assert_any_call(state=OperationState.ACTIVATED) fb_client.submit_telemetry.assert_called_once_with(duration=30) fb_client.update_operation_state.assert_any_call(state=OperationState.ENDED) -async def test_f2_contingent_path_scenario(fb_client, data_files): - await scenario_f2_contingent_path(fb_client, data_files) +async def test_f2_contingent_path_scenario(fb_client): + await scenario_f2_contingent_path(fb_client) - fb_client.create_flight_declaration.assert_called_once_with(data_files) + fb_client.create_flight_declaration.assert_called_once_with() fb_client.update_operation_state.assert_any_call(state=OperationState.ACTIVATED) fb_client.submit_telemetry.assert_called_once_with(duration=10) fb_client.update_operation_state.assert_any_call(state=OperationState.CONTINGENT, duration=7) fb_client.update_operation_state.assert_any_call(state=OperationState.ENDED) -async def test_f3_non_conforming_path_scenario(fb_client, data_files, common_client): - await scenario_f3_non_conforming_path(fb_client, data_files, common_client) +async def test_f3_non_conforming_path_scenario(fb_client, common_client): + await scenario_f3_non_conforming_path(fb_client, common_client) - fb_client.create_flight_declaration.assert_called_once_with(data_files) + fb_client.create_flight_declaration.assert_called_once_with() fb_client.update_operation_state.assert_any_call(state=OperationState.ACTIVATED) common_client.wait.assert_called_once_with(5) fb_client.submit_telemetry.assert_called_once_with(duration=20) @@ -78,10 +78,10 @@ async def test_f3_non_conforming_path_scenario(fb_client, data_files, common_cli fb_client.update_operation_state.assert_any_call(state=OperationState.ENDED) -async def test_f5_non_conforming_contingent_path_scenario(fb_client, data_files): - await scenario_f5_non_conforming_contingent_path(fb_client, data_files) +async def test_f5_non_conforming_contingent_path_scenario(fb_client): + await scenario_f5_non_conforming_contingent_path(fb_client) - fb_client.create_flight_declaration.assert_called_once_with(data_files) + fb_client.create_flight_declaration.assert_called_once_with() fb_client.update_operation_state.assert_any_call(state=OperationState.ACTIVATED) fb_client.submit_telemetry.assert_called_once_with(duration=20) fb_client.check_operation_state_connected.assert_called_once_with(expected_state=OperationState.NONCONFORMING, duration=5) From d2e3c6c0c603186438e9b365c13db97fdb9a75ca Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Tue, 30 Dec 2025 17:59:08 +0100 Subject: [PATCH 11/20] Fix async websocket issue --- pyproject.toml | 2 +- .../clients/flight_blender/base_client.py | 12 +++--- .../flight_blender/flight_blender_client.py | 37 +++++++---------- .../utils/websocket_utils.py | 27 ++++++++++++ tests/test_flight_blender_base_client.py | 41 +++++++++++-------- uv.lock | 38 +++++++++++++---- 6 files changed, 101 insertions(+), 56 deletions(-) create mode 100644 src/openutm_verification/utils/websocket_utils.py diff --git a/pyproject.toml b/pyproject.toml index c4866eb..2b02dee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,7 +42,7 @@ dependencies = [ "loguru>=0.7.2", "pydantic>=2.11.7", "pydantic-settings>=2.10.1", - "websocket-client==1.9.0", + "websockets>=12.0", "markdown>=3.10", "uas-standards==4.2.0", "fastapi>=0.121.3", diff --git a/src/openutm_verification/core/clients/flight_blender/base_client.py b/src/openutm_verification/core/clients/flight_blender/base_client.py index af2b4d4..f5725f6 100644 --- a/src/openutm_verification/core/clients/flight_blender/base_client.py +++ b/src/openutm_verification/core/clients/flight_blender/base_client.py @@ -1,6 +1,6 @@ import httpx from loguru import logger -from websocket import WebSocket, create_connection +from websockets.asyncio.client import ClientConnection, connect from openutm_verification.models import FlightBlenderError @@ -63,7 +63,7 @@ async def __aenter__(self): async def __aexit__(self, exc_type, exc_val, exc_tb): await self.client.aclose() - def create_websocket_connection(self, endpoint) -> WebSocket: + async def create_websocket_connection(self, endpoint) -> ClientConnection: """Create and return a WebSocket connection to the Flight Blender service. This method establishes a WebSocket connection using the configured @@ -76,18 +76,18 @@ def create_websocket_connection(self, endpoint) -> WebSocket: websocket_base_url = self.base_url.replace("http", "ws") websocket_url = f"{websocket_base_url}{endpoint}" try: - websocket_connection = create_connection(websocket_url) + websocket_connection = await connect(websocket_url) except ConnectionRefusedError: logger.error(f"Failed to connect to WebSocket at {websocket_url}") raise FlightBlenderError("WebSocket connection failed") from None if "Authorization" in self.client.headers: - websocket_connection.send(self.client.headers["Authorization"]) + await websocket_connection.send(self.client.headers["Authorization"]) return websocket_connection - def close_websocket_connection(self, ws_connection: WebSocket) -> None: + async def close_websocket_connection(self, ws_connection: ClientConnection) -> None: """Close the given WebSocket connection. Args: ws_connection: The WebSocket connection object to close. """ - ws_connection.close() + await ws_connection.close() 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 f42dc90..f500a62 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 @@ -10,7 +10,7 @@ from loguru import logger from pydantic import BaseModel from uas_standards.astm.f3411.v22a.api import RIDAircraftState -from websocket import WebSocket +from websockets.asyncio.client import ClientConnection from openutm_verification.core.clients.flight_blender.base_client import ( BaseBlenderAPIClient, @@ -43,6 +43,7 @@ FlightObservationSchema, ) from openutm_verification.utils.time_utils import parse_duration +from openutm_verification.utils.websocket_utils import receive_messages_for_duration def _create_rid_operator_details(operation_id: str) -> RIDOperatorDetails: @@ -695,14 +696,14 @@ async def start_stop_sdsp_session(self, session_id: str, action: SDSPSessionActi logger.error(f"Failed to perform action {action.value} on SDSP session {session_id}. Response: {response.text}") raise FlightBlenderError(f"{action.value} Heartbeat Track message not received for {session_id}") - def initialize_heartbeat_websocket_connection(self, session_id: str) -> WebSocket: + async def initialize_heartbeat_websocket_connection(self, session_id: str) -> ClientConnection: endpoint = f"/ws/surveillance/heartbeat/{session_id}" - ws = self.create_websocket_connection(endpoint=endpoint) + ws = await self.create_websocket_connection(endpoint=endpoint) return ws - def initialize_track_websocket_connection(self, session_id: str) -> WebSocket: + async def initialize_track_websocket_connection(self, session_id: str) -> ClientConnection: endpoint = f"/ws/surveillance/track/{session_id}" - ws = self.create_websocket_connection(endpoint=endpoint) + ws = await self.create_websocket_connection(endpoint=endpoint) return ws @scenario_step("Verify SDSP Track") @@ -712,17 +713,12 @@ async def initialize_verify_sdsp_track( expected_track_count: int, session_id: str, ) -> StepResult: - ws_connection = self.initialize_track_websocket_connection(session_id=session_id) + ws_connection = await self.initialize_track_websocket_connection(session_id=session_id) start_time = time.time() - now = arrow.now() - - six_seconds_from_now = now.shift(seconds=6) all_received_messages = [] # Start Receiving messages from now till six seconds from now - while arrow.now() < six_seconds_from_now: - await asyncio.sleep(0.2) - message = await asyncio.to_thread(ws_connection.recv) + async for message in receive_messages_for_duration(ws_connection, 6): message = json.loads(message) if "track_data" not in message or not message["track_data"]: logger.debug("WebSocket connection established message received or empty track data") @@ -732,7 +728,7 @@ async def initialize_verify_sdsp_track( logger.debug(f"Received WebSocket message: {message}") all_received_messages.append(SDSPTrackMessage(message=track, timestamp=arrow.now().isoformat())) logger.info(f"Received {len(all_received_messages)} messages in the first six seconds") - self.close_heartbeat_websocket_connection(ws_connection) + await self.close_heartbeat_websocket_connection(ws_connection) end_time = time.time() duration = end_time - start_time # Sort messages by timestamp @@ -777,17 +773,12 @@ async def initialize_verify_sdsp_heartbeat( expected_heartbeat_count: int, session_id: str, ) -> StepResult: - ws_connection = self.initialize_heartbeat_websocket_connection(session_id=session_id) + ws_connection = await self.initialize_heartbeat_websocket_connection(session_id=session_id) start_time = time.time() - now = arrow.now() - - six_seconds_from_now = now.shift(seconds=6) all_received_messages = [] # Start Receiving messages from now till six seconds from now - while arrow.now() < six_seconds_from_now: - await asyncio.sleep(0.1) - message = await asyncio.to_thread(ws_connection.recv) + async for message in receive_messages_for_duration(ws_connection, 6): message = json.loads(message) if "heartbeat_data" not in message: logger.debug("WebSocket connection established message received") @@ -796,7 +787,7 @@ async def initialize_verify_sdsp_heartbeat( logger.debug(f"Received WebSocket message: {message}") all_received_messages.append(SDSPHeartbeatMessage(message=heartbeat_message, timestamp=arrow.now().isoformat())) logger.info(f"Received {len(all_received_messages)} messages in the first six seconds") - self.close_heartbeat_websocket_connection(ws_connection) + await self.close_heartbeat_websocket_connection(ws_connection) end_time = time.time() # Sort messages by timestamp @@ -843,8 +834,8 @@ async def initialize_verify_sdsp_heartbeat( duration=duration, ) - def close_heartbeat_websocket_connection(self, ws_connection: WebSocket) -> None: - ws_connection.close() + async def close_heartbeat_websocket_connection(self, ws_connection: ClientConnection) -> None: + await ws_connection.close() @scenario_step("Teardown Flight Declaration") async def teardown_flight_declaration(self): diff --git a/src/openutm_verification/utils/websocket_utils.py b/src/openutm_verification/utils/websocket_utils.py new file mode 100644 index 0000000..0d00c80 --- /dev/null +++ b/src/openutm_verification/utils/websocket_utils.py @@ -0,0 +1,27 @@ +import asyncio +from typing import AsyncGenerator + +import arrow +from loguru import logger +from websockets.asyncio.client import ClientConnection + + +async def receive_messages_for_duration( + ws_connection: ClientConnection, + duration_seconds: float, +) -> AsyncGenerator[str | bytes, None]: + """ + Yields messages from a WebSocket connection for a specified duration. + Stops when the duration expires or a timeout occurs. + """ + end_time = arrow.now().shift(seconds=duration_seconds) + while arrow.now() < end_time: + remaining_time = (end_time - arrow.now()).total_seconds() + if remaining_time <= 0: + break + try: + message = await asyncio.wait_for(ws_connection.recv(), timeout=remaining_time) + yield message + except asyncio.TimeoutError: + logger.debug("Timeout waiting for websocket message") + break diff --git a/tests/test_flight_blender_base_client.py b/tests/test_flight_blender_base_client.py index 775b387..56ae295 100644 --- a/tests/test_flight_blender_base_client.py +++ b/tests/test_flight_blender_base_client.py @@ -109,40 +109,45 @@ async def test_http_verbs_delegate_to_request(mock_request: AsyncMock): mock_request.assert_any_call("DELETE", "/d", silent_status=None) -@patch("openutm_verification.core.clients.flight_blender.base_client.create_connection") -def test_create_websocket_connection_sends_auth(create_conn: MagicMock): - ws = MagicMock() - create_conn.return_value = ws +@pytest.mark.asyncio +@patch("openutm_verification.core.clients.flight_blender.base_client.connect", new_callable=AsyncMock) +async def test_create_websocket_connection_sends_auth(mock_connect: AsyncMock): + ws = AsyncMock() + mock_connect.return_value = ws client = make_client(with_token=True) - conn = client.create_websocket_connection("/ws") + conn = await client.create_websocket_connection("/ws") - create_conn.assert_called_once_with("wss://example.com/ws") + mock_connect.assert_called_once_with("wss://example.com/ws") ws.send.assert_called_once_with("Bearer token123") assert conn is ws -@patch("openutm_verification.core.clients.flight_blender.base_client.create_connection") -def test_create_websocket_connection_no_auth_does_not_send(create_conn: MagicMock): - ws = MagicMock() - create_conn.return_value = ws +@pytest.mark.asyncio +@patch("openutm_verification.core.clients.flight_blender.base_client.connect", new_callable=AsyncMock) +async def test_create_websocket_connection_no_auth_does_not_send(mock_connect: AsyncMock): + ws = AsyncMock() + mock_connect.return_value = ws client = make_client(with_token=False) - client.create_websocket_connection("/ws") + await client.create_websocket_connection("/ws") - create_conn.assert_called_once_with("wss://example.com/ws") + mock_connect.assert_called_once_with("wss://example.com/ws") ws.send.assert_not_called() -@patch("openutm_verification.core.clients.flight_blender.base_client.create_connection", side_effect=ConnectionRefusedError) -def test_create_websocket_connection_refused_raises(_: MagicMock): +@pytest.mark.asyncio +@patch("openutm_verification.core.clients.flight_blender.base_client.connect", new_callable=AsyncMock) +async def test_create_websocket_connection_refused_raises(mock_connect: AsyncMock): + mock_connect.side_effect = ConnectionRefusedError client = make_client() with pytest.raises(FlightBlenderError): - client.create_websocket_connection("/ws") + await client.create_websocket_connection("/ws") -def test_close_websocket_connection_calls_close(): +@pytest.mark.asyncio +async def test_close_websocket_connection_calls_close(): client = make_client() - ws = MagicMock() - client.close_websocket_connection(ws) + ws = AsyncMock() + await client.close_websocket_connection(ws) ws.close.assert_called_once() diff --git a/uv.lock b/uv.lock index 7f07d27..cab8123 100644 --- a/uv.lock +++ b/uv.lock @@ -966,7 +966,7 @@ dependencies = [ { name = "uas-standards" }, { name = "uvicorn" }, { name = "walrus" }, - { name = "websocket-client" }, + { name = "websockets" }, ] [package.dev-dependencies] @@ -1019,7 +1019,7 @@ requires-dist = [ { name = "uas-standards", specifier = "==4.2.0" }, { name = "uvicorn", specifier = ">=0.38.0" }, { name = "walrus", specifier = "==0.9.4" }, - { name = "websocket-client", specifier = "==1.9.0" }, + { name = "websockets", specifier = ">=12.0" }, ] [package.metadata.requires-dev] @@ -2023,12 +2023,34 @@ wheels = [ ] [[package]] -name = "websocket-client" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] [[package]] From 11790e74ea305dede9b87feeaf4b7d8b485b1552 Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Tue, 30 Dec 2025 17:59:26 +0100 Subject: [PATCH 12/20] Fix visualization for dependencies --- web-editor/examples/sdsp_track_async.json | 199 ------------------ web-editor/src/components/ScenarioEditor.tsx | 67 +++++- .../ScenarioEditor/PropertiesPanel.tsx | 71 ++++++- .../ScenarioEditor/ScenarioList.tsx | 54 +---- .../__tests__/PropertiesPanel.test.tsx | 1 + web-editor/src/hooks/useScenarioGraph.ts | 30 ++- web-editor/src/hooks/useScenarioRunner.ts | 18 +- web-editor/src/utils/scenarioConversion.ts | 42 +++- 8 files changed, 202 insertions(+), 280 deletions(-) delete mode 100644 web-editor/examples/sdsp_track_async.json diff --git a/web-editor/examples/sdsp_track_async.json b/web-editor/examples/sdsp_track_async.json deleted file mode 100644 index 8dacc50..0000000 --- a/web-editor/examples/sdsp_track_async.json +++ /dev/null @@ -1,199 +0,0 @@ -{ - "nodes": [ - { - "id": "node_1", - "type": "custom", - "position": { "x": 0, "y": 0 }, - "data": { - "label": "Start SDSP Session", - "operationId": "FlightBlenderClient.start_stop_sdsp_session", - "className": "FlightBlenderClient", - "functionName": "start_stop_sdsp_session", - "description": "Starts or stops an SDSP session.", - "parameters": [ - { - "name": "action", - "type": "SDSPSessionAction", - "options": [ - { "name": "START", "value": "start" }, - { "name": "STOP", "value": "stop" } - ], - "isEnum": true, - "default": "start" - }, - { - "name": "session_id", - "type": "str", - "default": "sdsp-session-123" - } - ] - }, - "measured": { "width": 250, "height": 60 } - }, - { - "id": "node_2", - "type": "custom", - "position": { "x": 0, "y": 150 }, - "data": { - "label": "Generate Air Traffic Data", - "operationId": "AirTrafficClient.generate_simulated_air_traffic_data", - "className": "AirTrafficClient", - "functionName": "generate_simulated_air_traffic_data", - "description": "Generates simulated air traffic data.", - "parameters": [] - }, - "measured": { "width": 250, "height": 60 } - }, - { - "id": "node_3", - "type": "custom", - "position": { "x": 0, "y": 300 }, - "data": { - "label": "Submit Air Traffic (Async)", - "operationId": "FlightBlenderClient.submit_simulated_air_traffic", - "className": "FlightBlenderClient", - "functionName": "submit_simulated_air_traffic", - "description": "Submits simulated air traffic data in background.", - "runInBackground": true, - "parameters": [ - { - "name": "observations", - "type": "list[Observation] | None", - "default": null - } - ] - }, - "measured": { "width": 250, "height": 60 } - }, - { - "id": "node_4", - "type": "custom", - "position": { "x": 0, "y": 450 }, - "data": { - "label": "Wait 2 Seconds", - "operationId": "CommonClient.wait", - "className": "CommonClient", - "functionName": "wait", - "description": "Wait for 2 seconds.", - "parameters": [ - { - "name": "duration", - "type": "int", - "default": 2 - } - ] - }, - "measured": { "width": 250, "height": 60 } - }, - { - "id": "node_5", - "type": "custom", - "position": { "x": 0, "y": 600 }, - "data": { - "label": "Initialize Verify SDSP Track", - "operationId": "FlightBlenderClient.initialize_verify_sdsp_track", - "className": "FlightBlenderClient", - "functionName": "initialize_verify_sdsp_track", - "description": "Verifies SDSP track.", - "parameters": [ - { - "name": "session_id", - "type": "str", - "default": "sdsp-session-123" - }, - { - "name": "expected_track_interval_seconds", - "type": "int", - "default": 1 - }, - { - "name": "expected_track_count", - "type": "int", - "default": 3 - } - ] - }, - "measured": { "width": 250, "height": 60 } - }, - { - "id": "node_6", - "type": "custom", - "position": { "x": 0, "y": 750 }, - "data": { - "label": "Wait 5 Seconds", - "operationId": "CommonClient.wait", - "className": "CommonClient", - "functionName": "wait", - "description": "Wait for 5 seconds.", - "parameters": [ - { - "name": "duration", - "type": "int", - "default": 5 - } - ] - }, - "measured": { "width": 250, "height": 60 } - }, - { - "id": "node_7", - "type": "custom", - "position": { "x": 0, "y": 900 }, - "data": { - "label": "Stop SDSP Session", - "operationId": "FlightBlenderClient.start_stop_sdsp_session", - "className": "FlightBlenderClient", - "functionName": "start_stop_sdsp_session", - "description": "Stops the SDSP session.", - "parameters": [ - { - "name": "action", - "type": "SDSPSessionAction", - "options": [ - { "name": "START", "value": "start" }, - { "name": "STOP", "value": "stop" } - ], - "isEnum": true, - "default": "stop" - }, - { - "name": "session_id", - "type": "str", - "default": "sdsp-session-123" - } - ] - }, - "measured": { "width": 250, "height": 60 } - }, - { - "id": "node_8", - "type": "custom", - "position": { "x": 0, "y": 1050 }, - "data": { - "label": "Join Background Task", - "operationId": "SessionManager.join_task", - "className": "SessionManager", - "functionName": "join_task", - "description": "Waits for the background task to complete.", - "parameters": [ - { - "name": "task_id", - "type": "str", - "default": "" - } - ] - }, - "measured": { "width": 250, "height": 60 } - } - ], - "edges": [ - { "id": "e1-2", "source": "node_1", "target": "node_2", "animated": true, "style": { "stroke": "var(--accent-primary)" } }, - { "id": "e2-3", "source": "node_2", "target": "node_3", "animated": true, "style": { "stroke": "var(--accent-primary)" } }, - { "id": "e3-4", "source": "node_3", "target": "node_4", "animated": true, "style": { "stroke": "var(--accent-primary)" } }, - { "id": "e4-5", "source": "node_4", "target": "node_5", "animated": true, "style": { "stroke": "var(--accent-primary)" } }, - { "id": "e5-6", "source": "node_5", "target": "node_6", "animated": true, "style": { "stroke": "var(--accent-primary)" } }, - { "id": "e6-7", "source": "node_6", "target": "node_7", "animated": true, "style": { "stroke": "var(--accent-primary)" } }, - { "id": "e7-8", "source": "node_7", "target": "node_8", "animated": true, "style": { "stroke": "var(--accent-primary)" } } - ], - "viewport": { "x": 0, "y": 0, "zoom": 1 } -} diff --git a/web-editor/src/components/ScenarioEditor.tsx b/web-editor/src/components/ScenarioEditor.tsx index 441a619..94c44c5 100644 --- a/web-editor/src/components/ScenarioEditor.tsx +++ b/web-editor/src/components/ScenarioEditor.tsx @@ -134,6 +134,7 @@ const ScenarioEditorContent = () => { setEdges(eds => eds.map(edge => ({ ...edge, style: { + ...edge.style, stroke: edge.id === selectedEdgeId ? 'var(--success)' : 'var(--accent-primary)', strokeWidth: edge.id === selectedEdgeId ? 2 : 1 } @@ -251,6 +252,24 @@ const ScenarioEditorContent = () => { } return node; }); + } else if (typeof taskIdValue === 'string') { + // If it's a string, it's likely the label of the background node + const targetNode = updatedNodes.find(n => n.data.label === taskIdValue); + if (targetNode) { + return updatedNodes.map(node => { + if (node.id === targetNode.id) { + return { + ...node, + data: { + ...node.data, + status: 'success', + result: stepResult.result + } + }; + } + return node; + }); + } } } @@ -277,8 +296,45 @@ const ScenarioEditorContent = () => { }, [runScenario, setNodes, updateNodesWithResults, reactFlowInstance]); const updateNodeParameter = useCallback((nodeId: string, paramName: string, value: unknown) => { - setNodes((nds) => - nds.map((node) => { + setNodes((nds) => { + // Special handling for Join Background Task -> task_id + // If we are updating task_id, we might need to create/update an edge + if (paramName === 'task_id' && typeof value === 'string') { + const targetNode = nds.find(n => n.id === nodeId); + if (targetNode && targetNode.data.label === "Join Background Task") { + // Find the source node by label (value) + const sourceNode = nds.find(n => n.data.label === value); + + // Update edges: Remove existing background connection edges to this node + setEdges(eds => { + // Keep regular sequence edges (where source is NOT a background node) + const filtered = eds.filter(e => { + if (e.target !== nodeId) return true; + const edgeSourceNode = nds.find(n => n.id === e.source); + // If source is background node, remove it (we are replacing it) + // If source is NOT background node, keep it (it's the sequence flow) + return !edgeSourceNode?.data?.runInBackground; + }); + + if (sourceNode) { + return [ + ...filtered, + { + id: `e${sourceNode.id}-${nodeId}`, + source: sourceNode.id, + target: nodeId, + type: 'smoothstep', + selectable: false, + style: { strokeDasharray: '5 5' } + } + ]; + } + return filtered; + }); + } + } + + return nds.map((node) => { if (node.id === nodeId) { const updatedParameters = updateParameterInList( (node.data.parameters || []), @@ -291,8 +347,8 @@ const ScenarioEditorContent = () => { }; } return node; - }) - ); + }); + }); setSelectedNode((prev) => { if (!prev || prev.id !== nodeId) return prev; @@ -306,7 +362,7 @@ const ScenarioEditorContent = () => { data: { ...prev.data, parameters: updatedParameters }, }; }); - }, [setNodes]); + }, [setNodes, setEdges]); const updateNodeRunInBackground = useCallback((nodeId: string, value: boolean) => { const joinOp = operations.find(op => op.name === "Join Background Task"); @@ -451,6 +507,7 @@ const ScenarioEditorContent = () => { setSelectedNode(null)} onUpdateParameter={updateNodeParameter} onUpdateRunInBackground={updateNodeRunInBackground} diff --git a/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx b/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx index b932e62..e4dbd6e 100644 --- a/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx +++ b/web-editor/src/components/ScenarioEditor/PropertiesPanel.tsx @@ -36,12 +36,23 @@ const DocstringViewer = ({ text }: { text: string }) => { interface PropertiesPanelProps { selectedNode: Node; connectedNodes: Node[]; + allNodes: Node[]; onClose: () => void; onUpdateParameter: (nodeId: string, paramName: string, value: unknown) => void; onUpdateRunInBackground: (nodeId: string, value: boolean) => void; } -export const PropertiesPanel = ({ selectedNode, connectedNodes, onClose, onUpdateParameter, onUpdateRunInBackground }: PropertiesPanelProps) => { +const parseRefString = (value: unknown) => { + if (typeof value !== 'string') return null; + const match = value.match(/^\$\{\{\s*steps\.([^.]+)\.result(?:\.(.*))?\s*\}\}$/); + if (!match) return null; + return { + stepId: match[1], + fieldPath: match[2] ? match[2].trim() : '' + }; +}; + +export const PropertiesPanel = ({ selectedNode, connectedNodes, allNodes, onClose, onUpdateParameter, onUpdateRunInBackground }: PropertiesPanelProps) => { const [width, setWidth] = useState(480); const [isResizing, setIsResizing] = useState(false); @@ -149,7 +160,41 @@ export const PropertiesPanel = ({ selectedNode, connectedNodes, onClose, onUpdat

Parameters

{(selectedNode.data.parameters || []).length > 0 ? ( (selectedNode.data.parameters || []).map(param => { - const isLinked = typeof param.default === 'object' && param.default !== null && '$ref' in param.default; + // Special handling for Join Background Task -> task_id + if (selectedNode.data.label === "Join Background Task" && param.name === "task_id") { + return ( +
+ + +
+ Select the background step to wait for. +
+
+ ); + } + + const refData = (typeof param.default === 'object' && param.default !== null && '$ref' in param.default) + ? { stepId: (param.default as { $ref: string }).$ref.split('.')[0], fieldPath: (param.default as { $ref: string }).$ref.split('.').slice(1).join('.') } + : parseRefString(param.default); + + const isLinked = !!refData; + + // Helper to resolve step name/ID to the actual node ID in the graph + const resolvedStepId = refData ? (allNodes.find(n => n.id === refData.stepId || n.data.label === refData.stepId)?.id || refData.stepId) : ''; return (
@@ -175,16 +220,14 @@ export const PropertiesPanel = ({ selectedNode, connectedNodes, onClose, onUpdat
- {isLinked ? ( + {isLinked && refData ? (
{ const fieldPath = e.target.value; - const currentRef = (param.default as { $ref: string }).$ref; - const sourceId = currentRef.split('.')[0]; + const sourceId = resolvedStepId; onUpdateParameter(selectedNode.id, param.name, { $ref: `${sourceId}.${fieldPath}` }); }} /> +
+ Reference: {`\${{ steps.${refData.stepId}.result${refData.fieldPath ? '.' + refData.fieldPath : ''} }}`} +
) : ( param.isEnum && param.options ? ( diff --git a/web-editor/src/components/ScenarioEditor/ScenarioList.tsx b/web-editor/src/components/ScenarioEditor/ScenarioList.tsx index b55e4a1..3b04514 100644 --- a/web-editor/src/components/ScenarioEditor/ScenarioList.tsx +++ b/web-editor/src/components/ScenarioEditor/ScenarioList.tsx @@ -3,6 +3,7 @@ import { FileText } from 'lucide-react'; import styles from '../../styles/Toolbox.module.css'; import type { Operation, ScenarioDefinition, NodeData } from '../../types/scenario'; import type { Node, Edge } from '@xyflow/react'; +import { convertYamlToGraph } from '../../utils/scenarioConversion'; interface ScenarioListProps { onLoadScenario: (nodes: Node[], edges: Edge[]) => void; @@ -27,58 +28,7 @@ export const ScenarioList = ({ onLoadScenario, operations }: ScenarioListProps) const res = await fetch(`/api/scenarios/${filename}`); const scenario: ScenarioDefinition = await res.json(); - const nodes: Node[] = []; - const edges: Edge[] = []; - - let yPos = 0; - const xPos = 250; - const gap = 150; - - // Note: We assume sequential execution based on the list order - // since 'needs' was removed. - scenario.steps.forEach((step, index) => { - // Find operation by name (which matches 'step' in YAML) - const operation = operations.find(op => op.name === step.step); - if (!operation) { - console.warn(`Operation ${step.step} not found`); - return; - } - - const nodeId = step.id || step.step; - - // Map arguments to parameters - const parameters = operation.parameters.map(param => ({ - ...param, - default: step.arguments?.[param.name] ?? param.default - })); - - const node: Node = { - id: nodeId, - type: 'custom', - position: { x: xPos, y: yPos }, - data: { - label: step.step, - operationId: operation.id, - description: step.description || operation.description, - parameters: parameters, - runInBackground: step.background - } - }; - - nodes.push(node); - yPos += gap; - - // Create edge from previous node - if (index > 0) { - const prevNode = nodes[index - 1]; - edges.push({ - id: `e_${prevNode.id}-${nodeId}`, - source: prevNode.id, - target: nodeId, - type: 'smoothstep' - }); - } - }); + const { nodes, edges } = convertYamlToGraph(scenario, operations); onLoadScenario(nodes, edges); } catch (err) { diff --git a/web-editor/src/components/ScenarioEditor/__tests__/PropertiesPanel.test.tsx b/web-editor/src/components/ScenarioEditor/__tests__/PropertiesPanel.test.tsx index 3821a24..e73b84f 100644 --- a/web-editor/src/components/ScenarioEditor/__tests__/PropertiesPanel.test.tsx +++ b/web-editor/src/components/ScenarioEditor/__tests__/PropertiesPanel.test.tsx @@ -21,6 +21,7 @@ describe('PropertiesPanel', () => { const defaultProps = { selectedNode: mockNode, connectedNodes: [], + allNodes: [], onClose: vi.fn(), onUpdateParameter: vi.fn(), onUpdateRunInBackground: vi.fn(), diff --git a/web-editor/src/hooks/useScenarioGraph.ts b/web-editor/src/hooks/useScenarioGraph.ts index a66f7ed..f63d11e 100644 --- a/web-editor/src/hooks/useScenarioGraph.ts +++ b/web-editor/src/hooks/useScenarioGraph.ts @@ -100,13 +100,29 @@ export const useScenarioGraph = () => { }, [nodes, edges, isDragging]); const onConnect = useCallback( - (params: Connection) => setEdges((eds) => addEdge({ - ...params, - animated: true, - style: { stroke: 'var(--accent-primary)', strokeWidth: 1 }, - markerEnd: { type: MarkerType.ArrowClosed, color: 'var(--accent-primary)' } - }, eds)), - [setEdges], + (params: Connection) => { + // Enforce 0 or 1 next/previous step constraint + const sourceHasOutgoing = edges.some(e => e.source === params.source); + const targetHasIncoming = edges.some(e => e.target === params.target); + + if (sourceHasOutgoing) { + alert("A step can only have one next step."); + return; + } + + if (targetHasIncoming) { + alert("A step can only have one previous step."); + return; + } + + setEdges((eds) => addEdge({ + ...params, + animated: true, + style: { stroke: 'var(--accent-primary)', strokeWidth: 1 }, + markerEnd: { type: MarkerType.ArrowClosed, color: 'var(--accent-primary)' } + }, eds)); + }, + [edges, setEdges], ); const onDrop = useCallback( diff --git a/web-editor/src/hooks/useScenarioRunner.ts b/web-editor/src/hooks/useScenarioRunner.ts index 49c9763..ab695c7 100644 --- a/web-editor/src/hooks/useScenarioRunner.ts +++ b/web-editor/src/hooks/useScenarioRunner.ts @@ -13,8 +13,11 @@ export const useScenarioRunner = () => { ) => { if (nodes.length === 0) return null; + // Filter out visual/dependency edges (dotted lines) + const sequenceEdges = edges.filter(e => e.style?.strokeDasharray !== '5 5'); + // Simple topological sort / path following - const incomingEdges = new Set(edges.map(e => e.target)); + const incomingEdges = new Set(sequenceEdges.map(e => e.target)); const startNodes = nodes.filter(n => !incomingEdges.has(n.id)); if (startNodes.length === 0) { @@ -34,7 +37,7 @@ export const useScenarioRunner = () => { visited.add(node.id); sortedNodes.push(node); - const outgoing = edges.filter(e => e.source === node.id); + const outgoing = sequenceEdges.filter(e => e.source === node.id); for (const edge of outgoing) { const targetNode = nodes.find(n => n.id === edge.target); if (targetNode) { @@ -59,7 +62,16 @@ export const useScenarioRunner = () => { const params = (node.data.parameters || []).reduce((acc, param) => { if (param.default !== undefined && param.default !== null && param.default !== '') { - acc[param.name] = param.default; + let value = param.default; + // Transform reference object to string format expected by backend + if (typeof value === 'object' && value !== null && '$ref' in value) { + const ref = (value as { $ref: string }).$ref; + const parts = ref.split('.'); + const stepName = parts[0]; + const fieldPath = parts.slice(1).join('.'); + value = `\${{ steps.${stepName}.result.${fieldPath} }}`; + } + acc[param.name] = value; } return acc; }, {} as Record); diff --git a/web-editor/src/utils/scenarioConversion.ts b/web-editor/src/utils/scenarioConversion.ts index 0622230..681b8ed 100644 --- a/web-editor/src/utils/scenarioConversion.ts +++ b/web-editor/src/utils/scenarioConversion.ts @@ -1,4 +1,4 @@ -import type { Node, Edge } from '@xyflow/react'; +import { type Node, type Edge, MarkerType } from '@xyflow/react'; import type { Operation, ScenarioDefinition, ScenarioStep, NodeData } from '../types/scenario'; export const convertYamlToGraph = ( @@ -62,9 +62,30 @@ export const convertYamlToGraph = ( id: `e_${prevNode.id}-${nodeId}`, source: prevNode.id, target: nodeId, - type: 'smoothstep' + type: 'smoothstep', + animated: true, + style: { stroke: 'var(--accent-primary)', strokeWidth: 1 }, + markerEnd: { type: MarkerType.ArrowClosed, color: 'var(--accent-primary)' } }); } + + // Add visual connection for Join Background Task + if (step.step === "Join Background Task" && step.arguments?.['task_id']) { + const targetLabel = step.arguments['task_id']; + // Find the node with this label + const backgroundNode = nodes.find(n => n.data.label === targetLabel); + + if (backgroundNode) { + edges.push({ + id: `e${backgroundNode.id}-${nodeId}`, + source: backgroundNode.id, + target: nodeId, + type: 'smoothstep', + selectable: false, + style: { strokeDasharray: '5 5' } + }); + } + } }); return { nodes, edges }; @@ -75,8 +96,11 @@ export const convertGraphToYaml = ( edges: Edge[], operations: Operation[] = [] ): ScenarioDefinition => { + // Filter out visual/dependency edges (dotted lines) + const sequenceEdges = edges.filter(e => e.style?.strokeDasharray !== '5 5'); + // Sort nodes based on edges to determine order - const targetIds = new Set(edges.map(e => e.target)); + const targetIds = new Set(sequenceEdges.map(e => e.target)); const roots = nodes.filter(n => !targetIds.has(n.id)); // If multiple roots, sort by y position @@ -91,7 +115,7 @@ export const convertGraphToYaml = ( sortedNodes.push(node); // Find outgoing edges - const outgoing = edges + const outgoing = sequenceEdges .filter(e => e.source === node.id) .map(e => nodes.find(n => n.id === e.target)) .filter((n): n is Node => !!n); @@ -124,6 +148,16 @@ export const convertGraphToYaml = ( // Skip undefined values if (currentValue === undefined) return; + // Transform reference object to string format expected by backend + if (typeof currentValue === 'object' && currentValue !== null && '$ref' in currentValue) { + const ref = (currentValue as { $ref: string }).$ref; + const parts = ref.split('.'); + const stepName = parts[0]; + const fieldPath = parts.slice(1).join('.'); + args[param.name] = `\${{ steps.${stepName}.result.${fieldPath} }}`; + return; + } + // Skip default values if operation is available if (operation) { const originalParam = operation.parameters.find(p => p.name === param.name); From 5447482341bfa391616f3a95a4adcac03a5f0ae5 Mon Sep 17 00:00:00 2001 From: atti92 Date: Wed, 7 Jan 2026 09:14:10 +0100 Subject: [PATCH 13/20] Fix scenario path in docker image --- Dockerfile | 2 ++ Dockerfile.dev | 1 + src/openutm_verification/server/router.py | 3 ++- tests/test_yaml_scenarios.py | 3 ++- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 76523c1..21d30fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,7 @@ ENV PYTHONUNBUFFERED=1 # Copy dependency files first for better layer caching COPY pyproject.toml uv.lock ./ COPY docs ./docs +COPY scenarios ./scenarios # Install project dependencies using uv sync with cache mount for faster builds # --frozen: ensures reproducible builds from uv.lock @@ -73,6 +74,7 @@ ENV PYTHONUNBUFFERED=1 ENV TZ=UTC ENV PATH="/app/.venv/bin:$PATH" ENV WEB_EDITOR_PATH=/app/web-editor +ENV SCENARIOS_PATH=/app/scenarios # Create non-root user and group for enhanced security RUN (getent group "${GID}" || groupadd -g "${GID}" "${APP_GROUP}") \ diff --git a/Dockerfile.dev b/Dockerfile.dev index 9ae3ef9..819cdc9 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -39,6 +39,7 @@ ENV UV_LINK_MODE=copy ENV PYTHONUNBUFFERED=1 ENV PYTHONPATH=/app/src ENV WEB_EDITOR_PATH=/app/web-editor +ENV SCENARIOS_PATH=/app/scenarios # Copy dependency files COPY --chown=${UID}:${GID} LICENSE README.md pyproject.toml uv.lock ./ diff --git a/src/openutm_verification/server/router.py b/src/openutm_verification/server/router.py index b87d521..48d2d87 100644 --- a/src/openutm_verification/server/router.py +++ b/src/openutm_verification/server/router.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from typing import Any, Type, TypeVar @@ -12,7 +13,7 @@ # Define the scenarios directory relative to this file # src/openutm_verification/server/router.py -> .../scenarios -SCENARIOS_DIR = Path(__file__).parents[3] / "scenarios" +SCENARIOS_DIR = Path(os.getenv("SCENARIOS_PATH", str(Path(__file__).parents[3] / "scenarios"))) def get_runner(request: Request) -> Any: diff --git a/tests/test_yaml_scenarios.py b/tests/test_yaml_scenarios.py index 45380a8..906cce5 100644 --- a/tests/test_yaml_scenarios.py +++ b/tests/test_yaml_scenarios.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -8,7 +9,7 @@ from openutm_verification.core.execution.definitions import ScenarioDefinition from openutm_verification.server.runner import SessionManager -SCENARIOS_DIR = Path(__file__).parent.parent / "scenarios" +SCENARIOS_DIR = Path(os.getenv("SCENARIOS_PATH", Path(__file__).parent.parent / "scenarios")) YAML_FILES = list(SCENARIOS_DIR.glob("*.yaml")) From 74dffc09aec2fcff1f033f766a712581210bd656 Mon Sep 17 00:00:00 2001 From: atti92 Date: Wed, 7 Jan 2026 09:19:48 +0100 Subject: [PATCH 14/20] fix config --- config/pull_request.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/pull_request.yaml b/config/pull_request.yaml index df65cc2..62d3da5 100644 --- a/config/pull_request.yaml +++ b/config/pull_request.yaml @@ -21,7 +21,7 @@ opensky: # Air traffic data configuration air_traffic_simulator_settings: number_of_aircraft: 3 - simulation_duration_seconds: 10 + simulation_duration: 10 single_or_multiple_sensors: "multiple" # this setting specifiies if the traffic data is submitted from a single sensor or multiple sensors sensor_ids: ["a0b7d47e5eac45dc8cbaf47e6fe0e558"] # List of sensor IDs to use when 'multiple' is selected From 379ac5917478f08feb077874a024c920bc9feb1f Mon Sep 17 00:00:00 2001 From: atti92 Date: Wed, 7 Jan 2026 09:29:21 +0100 Subject: [PATCH 15/20] volume mount the scenarios when running the compose --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index a0a15d2..10c84a6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: volumes: - ./config:/app/config:Z - ./reports:/app/reports:Z + - ./scenarios:/app/scenarios:Z # Network configuration for local Flight Blender access network_mode: host # Resource limits @@ -97,6 +98,7 @@ services: volumes: - ./config:/app/config:Z - ./reports:/app/reports:Z + - ./scenarios:/app/scenarios:Z ports: - "8989:8989" extra_hosts: From bd695873c56fad4443fcca9deb0960567d12fcf1 Mon Sep 17 00:00:00 2001 From: Attila Kobor Date: Thu, 8 Jan 2026 10:50:07 +0100 Subject: [PATCH 16/20] Enrich UI with: markdown viewer, new/save/save-as, scenario settings panel. --- Dockerfile | 1 + Dockerfile.dev | 1 + .../F1_flow_no_telemetry_with_user_input.md | 17 + docs/scenarios/F1_happy_path.md | 21 + docs/scenarios/F2_contingent_path.md | 17 + docs/scenarios/F3_non_conforming_path.md | 28 + docs/scenarios/F5_non_conforming_path.md | 19 + docs/scenarios/add_flight_declaration.md | 15 + docs/scenarios/geo_fence_upload.md | 9 + docs/scenarios/opensky_live_data.md | 15 + .../scenarios/openutm_sim_air_traffic_data.md | 9 + docs/scenarios/sdsp_heartbeat.md | 17 + docs/scenarios/sdsp_track.md | 21 + scenarios/F1_happy_path.yaml | 10 +- scenarios/F3_non_conforming_path.yaml | 12 +- scenarios/sdsp_track.yaml | 73 +- scripts/generate_docs.py | 78 + src/openutm_verification/server/router.py | 34 +- src/openutm_verification/utils/paths.py | 37 +- web-editor/package-lock.json | 1630 ++++++++++++++++- web-editor/package.json | 4 +- web-editor/src/components/ScenarioEditor.tsx | 194 +- .../components/ScenarioEditor/CustomNode.tsx | 12 + .../ScenarioEditor/DocumentationModal.tsx | 75 + .../src/components/ScenarioEditor/Header.tsx | 18 +- .../ScenarioEditor/PropertiesPanel.tsx | 22 +- .../ScenarioEditor/ScenarioInfoPanel.tsx | 145 ++ .../ScenarioEditor/ScenarioList.tsx | 31 +- .../src/components/ScenarioEditor/Toolbox.tsx | 1 + .../__tests__/CustomNode.test.tsx | 29 +- .../ScenarioEditor/__tests__/Header.test.tsx | 18 +- .../__tests__/PropertiesPanel.test.tsx | 1 + .../hooks/__tests__/useScenarioFile.test.ts | 28 +- .../hooks/__tests__/useScenarioRunner.test.ts | 13 +- web-editor/src/hooks/useScenarioFile.ts | 58 +- web-editor/src/hooks/useScenarioGraph.ts | 42 +- .../src/styles/DocumentationModal.module.css | 77 + web-editor/src/styles/Toolbox.module.css | 13 + web-editor/src/types/scenario.ts | 2 + web-editor/src/utils/scenarioConversion.ts | 21 +- 40 files changed, 2626 insertions(+), 242 deletions(-) create mode 100644 docs/scenarios/F1_flow_no_telemetry_with_user_input.md create mode 100644 scripts/generate_docs.py create mode 100644 web-editor/src/components/ScenarioEditor/DocumentationModal.tsx create mode 100644 web-editor/src/components/ScenarioEditor/ScenarioInfoPanel.tsx create mode 100644 web-editor/src/styles/DocumentationModal.module.css diff --git a/Dockerfile b/Dockerfile index 21d30fd..bcc9d8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -75,6 +75,7 @@ ENV TZ=UTC ENV PATH="/app/.venv/bin:$PATH" ENV WEB_EDITOR_PATH=/app/web-editor ENV SCENARIOS_PATH=/app/scenarios +ENV DOCS_PATH=/app/docs # Create non-root user and group for enhanced security RUN (getent group "${GID}" || groupadd -g "${GID}" "${APP_GROUP}") \ diff --git a/Dockerfile.dev b/Dockerfile.dev index 819cdc9..435d4b9 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -40,6 +40,7 @@ ENV PYTHONUNBUFFERED=1 ENV PYTHONPATH=/app/src ENV WEB_EDITOR_PATH=/app/web-editor ENV SCENARIOS_PATH=/app/scenarios +ENV DOCS_PATH=/app/docs # Copy dependency files COPY --chown=${UID}:${GID} LICENSE README.md pyproject.toml uv.lock ./ diff --git a/docs/scenarios/F1_flow_no_telemetry_with_user_input.md b/docs/scenarios/F1_flow_no_telemetry_with_user_input.md new file mode 100644 index 0000000..79a23f1 --- /dev/null +++ b/docs/scenarios/F1_flow_no_telemetry_with_user_input.md @@ -0,0 +1,17 @@ +# F1 Flow No Telemetry With User Input + +Runs the F1 no telemetry with user input scenario. + +## Steps Sequence + +### 1. Setup Flight Declaration + +### 2. Wait 5 seconds + +### 3. Activate Operation State + +### 4. Wait for User Input + +### 5. End Operation State + +### 6. Teardown Flight Declaration diff --git a/docs/scenarios/F1_happy_path.md b/docs/scenarios/F1_happy_path.md index e69de29..84f8d11 100644 --- a/docs/scenarios/F1_happy_path.md +++ b/docs/scenarios/F1_happy_path.md @@ -0,0 +1,21 @@ +# F1 Happy Path + +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 Sequence + +### 1. Setup Flight Declaration +Creates a fresh flight declaration in the DSS. + +### 2. Activate Operation State +Activates the flight operation, transitioning it to the active state. + +### 3. Submit Telemetry +Simulates the broadcast of telemetry data for 30 seconds. + +### 4. End Operation State +Marks the operation as ended after the flight is complete. + +### 5. Teardown Flight Declaration +Cleans up the flight declaration and any associated resources. diff --git a/docs/scenarios/F2_contingent_path.md b/docs/scenarios/F2_contingent_path.md index e69de29..10d1c0a 100644 --- a/docs/scenarios/F2_contingent_path.md +++ b/docs/scenarios/F2_contingent_path.md @@ -0,0 +1,17 @@ +# F2 Contingent Path + +Runs the F2 contingent path scenario. + +## Steps Sequence + +### 1. Setup Flight Declaration + +### 2. Activate Operation State + +### 3. Submit Telemetry for 10 seconds + +### 4. Update Operation State to Contingent for 7 seconds + +### 5. End Operation State + +### 6. Teardown Flight Declaration diff --git a/docs/scenarios/F3_non_conforming_path.md b/docs/scenarios/F3_non_conforming_path.md index e69de29..7a8015f 100644 --- a/docs/scenarios/F3_non_conforming_path.md +++ b/docs/scenarios/F3_non_conforming_path.md @@ -0,0 +1,28 @@ +# F3 Non Conforming Path + +Verifies the system's ability to detect and handle a non-conforming flight. The flight deviates from its declared intent, and we expect the system to transition the operation state to NONCONFORMING. + + +## Steps Sequence + +### 1. Setup Flight Declaration +Initializes the flight declaration. + + +### 2. Update Operation State +Activates the operation. + +### 3. Wait 5 seconds +Pauses execution to allow initial state propagation. + +### 4. Submit Telemetry for 20 seconds +Sends telemetry updates that intentionally deviate from the plan. + +### 5. Check Operation State +Verifies that the system has correctly identified the Non-Conforming state. + +### 6. Update Operation State +Ends the operation. + +### 7. Teardown Flight Declaration +Cleanup. diff --git a/docs/scenarios/F5_non_conforming_path.md b/docs/scenarios/F5_non_conforming_path.md index e69de29..c2b9be4 100644 --- a/docs/scenarios/F5_non_conforming_path.md +++ b/docs/scenarios/F5_non_conforming_path.md @@ -0,0 +1,19 @@ +# F5 Non Conforming Path + +Runs the F5 non-conforming path scenario. + +## Steps Sequence + +### 1. Setup Flight Declaration + +### 2. Activate Operation State + +### 3. Submit Telemetry for 20 seconds + +### 4. Check Operation State Connected if NONCONFORMING + +### 5. Update Operation State to CONTINGENT + +### 6. End Operation State + +### 7. Teardown Flight Declaration diff --git a/docs/scenarios/add_flight_declaration.md b/docs/scenarios/add_flight_declaration.md index e69de29..56fe680 100644 --- a/docs/scenarios/add_flight_declaration.md +++ b/docs/scenarios/add_flight_declaration.md @@ -0,0 +1,15 @@ +# Add Flight Declaration + +Runs the add flight declaration scenario. + +## Steps Sequence + +### 1. Setup Flight Declaration + +### 2. Activate Operation State + +### 3. Submit Telemetry for 30 seconds + +### 4. End Operation State + +### 5. Teardown Flight Declaration diff --git a/docs/scenarios/geo_fence_upload.md b/docs/scenarios/geo_fence_upload.md index e69de29..ae59002 100644 --- a/docs/scenarios/geo_fence_upload.md +++ b/docs/scenarios/geo_fence_upload.md @@ -0,0 +1,9 @@ +# Geo Fence Upload + +Upload a geo-fence (Area of Interest) and then delete it (teardown). + +## Steps Sequence + +### 1. Upload Geo Fence + +### 2. Get Geo Fence diff --git a/docs/scenarios/opensky_live_data.md b/docs/scenarios/opensky_live_data.md index e69de29..8678403 100644 --- a/docs/scenarios/opensky_live_data.md +++ b/docs/scenarios/opensky_live_data.md @@ -0,0 +1,15 @@ +# Opensky Live Data + +Fetch live flight data from OpenSky and submit to Flight Blender. + +## Steps Sequence + +### 1. Fetch OpenSky Data + +### 2. Submit Air Traffic + +### 3. Wait 3 seconds + +### 4. Fetch OpenSky Data + +### 5. Submit Air Traffic diff --git a/docs/scenarios/openutm_sim_air_traffic_data.md b/docs/scenarios/openutm_sim_air_traffic_data.md index e69de29..11fe77c 100644 --- a/docs/scenarios/openutm_sim_air_traffic_data.md +++ b/docs/scenarios/openutm_sim_air_traffic_data.md @@ -0,0 +1,9 @@ +# Openutm Sim Air Traffic Data + +Generate simulated air traffic data using OpenSky client and submit to Flight Blender. + +## Steps Sequence + +### 1. Generate Simulated Air Traffic Data + +### 2. Submit Simulated Air Traffic diff --git a/docs/scenarios/sdsp_heartbeat.md b/docs/scenarios/sdsp_heartbeat.md index e69de29..e30b7a8 100644 --- a/docs/scenarios/sdsp_heartbeat.md +++ b/docs/scenarios/sdsp_heartbeat.md @@ -0,0 +1,17 @@ +# Sdsp Heartbeat + +Runs the SDSP heartbeat scenario. + +## Steps Sequence + +### 1. Generate UUID + +### 2. Start SDSP Session + +### 3. Wait 2 seconds + +### 4. Verify SDSP Heartbeat + +### 5. Wait 5 seconds + +### 6. Stop SDSP Session diff --git a/docs/scenarios/sdsp_track.md b/docs/scenarios/sdsp_track.md index e69de29..2120ba4 100644 --- a/docs/scenarios/sdsp_track.md +++ b/docs/scenarios/sdsp_track.md @@ -0,0 +1,21 @@ +# Exported Scenario + +Exported from OpenUTM Scenario Designer + +## Steps Sequence + +### 1. Generate UUID + +### 2. Start SDSP Session + +### 3. Generate Simulated Air Traffic Data + +### 4. Submit Simulated Air Traffic in the background + +### 5. Wait 2 seconds + +### 6. Verify SDSP Track + +### 7. Wait 5 seconds + +### 8. Stop SDSP Session diff --git a/scenarios/F1_happy_path.yaml b/scenarios/F1_happy_path.yaml index 2d208f0..68b0ab8 100644 --- a/scenarios/F1_happy_path.yaml +++ b/scenarios/F1_happy_path.yaml @@ -1,15 +1,23 @@ name: F1_happy_path -description: Runs the F1 happy path scenario. +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: 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 - step: Submit Telemetry + description: Simulates the broadcast of telemetry data for 30 seconds. arguments: duration: 30 - id: update_state_ended step: Update Operation State + description: Marks the operation as ended after the flight is complete. arguments: state: ENDED - step: Teardown Flight Declaration + description: Cleans up the flight declaration and any associated resources. diff --git a/scenarios/F3_non_conforming_path.yaml b/scenarios/F3_non_conforming_path.yaml index 66f984c..9b2811a 100644 --- a/scenarios/F3_non_conforming_path.yaml +++ b/scenarios/F3_non_conforming_path.yaml @@ -1,22 +1,32 @@ name: F3_non_conforming_path -description: Runs the F3 non-conforming path scenario. +description: > + Verifies the system's ability to detect and handle a non-conforming flight. + The flight deviates from its declared intent, and we expect the system + to transition the operation state to NONCONFORMING. steps: - step: Setup Flight Declaration + description: Initializes the flight declaration. - step: Update Operation State + description: Activates the operation. arguments: state: ACTIVATED - step: Wait X seconds + description: Pauses execution to allow initial state propagation. arguments: duration: 5 - step: Submit Telemetry + description: Sends telemetry updates that intentionally deviate from the plan. arguments: duration: 20 - step: Check Operation State + description: Verifies that the system has correctly identified the Non-Conforming state. arguments: expected_state: NONCONFORMING duration: 5 - id: update_state_ended step: Update Operation State + description: Ends the operation. arguments: state: ENDED - step: Teardown Flight Declaration + description: Cleanup. diff --git a/scenarios/sdsp_track.yaml b/scenarios/sdsp_track.yaml index 64eb86e..7f74c16 100644 --- a/scenarios/sdsp_track.yaml +++ b/scenarios/sdsp_track.yaml @@ -1,44 +1,35 @@ name: sdsp_track description: Runs the SDSP track scenario. - steps: - - step: Generate UUID - - - id: start_session - step: Start / Stop SDSP Session - arguments: - action: START - session_id: ${{ steps.Generate UUID.result }} - - - step: Generate Simulated Air Traffic Data - - - step: Submit Simulated Air Traffic - arguments: - observations: ${{ steps.Generate Simulated Air Traffic Data.result }} - background: true - - - id: wait_initial - step: Wait X seconds - arguments: - duration: 2 - - - step: Verify SDSP Track - arguments: - session_id: ${{ steps.Generate UUID.result }} - expected_track_interval_seconds: 1 - expected_track_count: 3 - - - id: wait_verification - step: Wait X seconds - arguments: - duration: 5 - - - id: stop_session - step: Start / Stop SDSP Session - arguments: - action: STOP - session_id: ${{ steps.Generate UUID.result }} - - - step: Join Background Task - arguments: - task_id: Submit Simulated Air Traffic +- step: Generate UUID +- id: start_sdsp_session + step: Start / Stop SDSP Session + arguments: + session_id: ${{ steps.Generate UUID.result }} + action: START +- step: Generate Simulated Air Traffic Data +- step: Submit Simulated Air Traffic + arguments: + observations: ${{ steps.Generate Simulated Air Traffic Data.result }} + background: true +- id: wait_2_seconds + step: Wait X seconds + arguments: + duration: 2 +- step: Verify SDSP Track + arguments: + expected_track_interval_seconds: 1 + expected_track_count: 3 + session_id: ${{ steps.Generate UUID.result }} +- id: wait_5_seconds + step: Wait X seconds + arguments: + duration: 5 +- id: stop_sdsp_session + step: Start / Stop SDSP Session + arguments: + session_id: ${{ steps.Generate UUID.result }} + action: STOP +- step: Join Background Task + arguments: + task_id: Submit Simulated Air Traffic diff --git a/scripts/generate_docs.py b/scripts/generate_docs.py new file mode 100644 index 0000000..34dc018 --- /dev/null +++ b/scripts/generate_docs.py @@ -0,0 +1,78 @@ +from pathlib import Path + +import yaml + +# Define paths +BASE_DIR = Path(__file__).resolve().parents[1] +SCENARIOS_DIR = BASE_DIR / "scenarios" +DOCS_DIR = BASE_DIR / "docs" / "scenarios" + + +def format_title(name): + return name.replace("_", " ").title() + + +def generate_markdown(scenario_data): + name = scenario_data.get("name", "Unknown Scenario") + description = scenario_data.get("description", "No description provided.") + steps = scenario_data.get("steps", []) + + md_content = f"# {format_title(name)}\n\n" + md_content += f"{description}\n\n" + md_content += "## Steps Sequence\n\n" + + for idx, step in enumerate(steps, 1): + step_name = step.get("step", "Unknown Step") + step_id = step.get("id") + step_desc = step.get("description") + arguments = step.get("arguments") + + md_content += f"### {idx}. {step_name}\n" + if step_desc: + md_content += f"{step_desc}\n\n" + + if step_id: + md_content += f"**ID:** `{step_id}`\n\n" + + if arguments: + md_content += "**Arguments:**\n" + for key, value in arguments.items(): + md_content += f"- `{key}`: {value}\n" + md_content += "\n" + else: + md_content += "\n" + + return md_content + + +def main(): + if not SCENARIOS_DIR.exists(): + print(f"Scenarios directory not found: {SCENARIOS_DIR}") + return + + DOCS_DIR.mkdir(parents=True, exist_ok=True) + + for yaml_file in SCENARIOS_DIR.glob("*.yaml"): + print(f"Processing {yaml_file.name}...") + try: + with open(yaml_file, "r") as f: + data = yaml.safe_load(f) + + if not data: + print(f"Skipping empty file: {yaml_file.name}") + continue + + md_content = generate_markdown(data) + + md_filename = DOCS_DIR / yaml_file.with_suffix(".md").name + with open(md_filename, "w") as f: + f.write(md_content) + + print(f"Generated {md_filename}") + + except Exception as e: + print(f"Error processing {yaml_file.name}: {e}") + + +if __name__ == "__main__": + main() diff --git a/src/openutm_verification/server/router.py b/src/openutm_verification/server/router.py index 48d2d87..60336eb 100644 --- a/src/openutm_verification/server/router.py +++ b/src/openutm_verification/server/router.py @@ -1,20 +1,16 @@ -import os -from pathlib import Path from typing import Any, Type, TypeVar import yaml from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import PlainTextResponse from openutm_verification.core.execution.definitions import ScenarioDefinition, StepDefinition +from openutm_verification.utils.paths import get_docs_directory, get_scenarios_directory T = TypeVar("T") scenario_router = APIRouter() -# Define the scenarios directory relative to this file -# src/openutm_verification/server/router.py -> .../scenarios -SCENARIOS_DIR = Path(os.getenv("SCENARIOS_PATH", str(Path(__file__).parents[3] / "scenarios"))) - def get_runner(request: Request) -> Any: return request.app.state.runner @@ -38,15 +34,17 @@ async def execute_step(step: StepDefinition, runner: Any = Depends(get_runner)): @scenario_router.get("/api/scenarios") async def list_scenarios(): """List all available scenarios.""" - if not SCENARIOS_DIR.exists(): + path = get_scenarios_directory() + if not path.exists(): return [] - return [f.stem for f in SCENARIOS_DIR.glob("*.yaml")] + return [f.stem for f in path.glob("*.yaml")] @scenario_router.get("/api/scenarios/{scenario}") async def get_scenario(scenario: str): """Get the content of a specific scenario.""" - file_path = (SCENARIOS_DIR / scenario).with_suffix(".yaml") + path = get_scenarios_directory() + file_path = (path / scenario).with_suffix(".yaml") if not file_path.exists(): raise HTTPException(status_code=404, detail="Scenario not found") @@ -61,10 +59,11 @@ async def get_scenario(scenario: str): @scenario_router.post("/api/scenarios/{name}") async def save_scenario(name: str, scenario: ScenarioDefinition): """Save a scenario to a YAML file.""" - file_path = (SCENARIOS_DIR / name).with_suffix(".yaml") + path = get_scenarios_directory() + file_path = (path / name).with_suffix(".yaml") # Ensure directory exists - SCENARIOS_DIR.mkdir(parents=True, exist_ok=True) + path.mkdir(parents=True, exist_ok=True) try: # Convert Pydantic model to dict, excluding None values to keep YAML clean @@ -76,3 +75,16 @@ async def save_scenario(name: str, scenario: ScenarioDefinition): return {"message": f"Scenario saved to {file_path.name}"} except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to save scenario: {e}") + + +@scenario_router.get("/api/scenarios/{scenario}/docs") +async def get_scenario_docs(scenario: str): + """Get the documentation for a specific scenario.""" + file_path = (get_docs_directory() / scenario).with_suffix(".md") + + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Documentation not found") + + with open(file_path, "r") as f: + content = f.read() + return PlainTextResponse(content) diff --git a/src/openutm_verification/utils/paths.py b/src/openutm_verification/utils/paths.py index 7d0f95a..cde2aae 100644 --- a/src/openutm_verification/utils/paths.py +++ b/src/openutm_verification/utils/paths.py @@ -1,7 +1,10 @@ +import functools +import os from pathlib import Path -def get_docs_directory() -> Path | None: +@functools.cache +def get_docs_directory() -> Path: """ Determines the directory containing documentation and images. @@ -12,7 +15,8 @@ def get_docs_directory() -> Path | None: # 1. Try installed package location: src/openutm_verification/docs/scenarios/ # This file is in src/openutm_verification/utils/ package_root = Path(__file__).parent.parent - docs_dir = package_root / "docs" / "scenarios" + docs_dir = os.getenv("DOCS_PATH", str(package_root / "docs" / "scenarios")) + docs_dir = Path(docs_dir) if docs_dir.exists(): return docs_dir @@ -20,7 +24,30 @@ def get_docs_directory() -> Path | None: # 2. Try development location: project_root/docs/scenarios/ # src/openutm_verification/utils/ -> src/openutm_verification/ -> src/ -> root/ (4 levels) docs_dir = Path(__file__).parents[3] / "docs" / "scenarios" - if docs_dir.exists(): - return docs_dir - return None + return docs_dir + + +@functools.cache +def get_scenarios_directory() -> Path: + """ + Determines the directory containing scenario definitions. + + Strategies: + 1. Installed package location: openutm_verification/scenarios/ + 2. Development location: project_root/scenarios/ + """ + # 1. Try installed package location: src/openutm_verification/scenarios/ + # This file is in src/openutm_verification/utils/ + package_root = Path(__file__).parent.parent + scenarios_dir = os.getenv("SCENARIOS_PATH", str(package_root / "scenarios")) + scenarios_dir = Path(scenarios_dir) + + if scenarios_dir.exists(): + return scenarios_dir + + # 2. Try development location: project_root/scenarios/ + # src/openutm_verification/utils/ -> src/openutm_verification/ -> src/ -> root/ (4 levels) + scenarios_dir = Path(__file__).parents[3] / "scenarios" + + return scenarios_dir diff --git a/web-editor/package-lock.json b/web-editor/package-lock.json index 5547538..3e8aed8 100644 --- a/web-editor/package-lock.json +++ b/web-editor/package-lock.json @@ -13,7 +13,9 @@ "js-yaml": "^4.1.1", "lucide-react": "^0.554.0", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -1802,6 +1804,15 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1813,9 +1824,26 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/js-yaml": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", @@ -1830,6 +1858,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.10.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", @@ -1845,7 +1888,6 @@ "version": "19.2.6", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz", "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", - "devOptional": true, "license": "MIT", "peer": true, "dependencies": { @@ -1863,6 +1905,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.47.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz", @@ -2135,6 +2183,12 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz", @@ -2402,6 +2456,16 @@ "node": ">=12" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2519,6 +2583,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", @@ -2546,6 +2620,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/classcat": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", @@ -2572,6 +2686,16 @@ "dev": true, "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2641,7 +2765,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/d3-color": { @@ -2768,7 +2891,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2789,6 +2911,19 @@ "dev": true, "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2800,12 +2935,24 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -3080,6 +3227,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -3110,6 +3267,12 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3303,6 +3466,46 @@ "node": ">=8" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -3333,6 +3536,16 @@ "node": ">=18" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -3421,6 +3634,46 @@ "node": ">=8" } }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3444,6 +3697,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3454,6 +3717,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -3622,6 +3897,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3661,109 +3946,963 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", "license": "MIT", - "engines": { - "node": ">= 8" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", "license": "MIT", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" }, - "engines": { - "node": ">=8.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "license": "MIT", "engines": { - "node": ">=4" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" }, - "engines": { - "node": "*" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", "license": "MIT", "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", @@ -3818,6 +4957,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse5": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", @@ -3945,6 +5109,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4006,6 +5180,33 @@ "dev": true, "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -4030,6 +5231,72 @@ "node": ">=8" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -4203,6 +5470,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -4217,6 +5494,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -4243,6 +5534,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4395,6 +5704,26 @@ "node": ">=20" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -4467,6 +5796,93 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", @@ -4517,6 +5933,34 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", @@ -4929,6 +6373,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/web-editor/package.json b/web-editor/package.json index 0491413..bd81075 100644 --- a/web-editor/package.json +++ b/web-editor/package.json @@ -16,7 +16,9 @@ "js-yaml": "^4.1.1", "lucide-react": "^0.554.0", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/web-editor/src/components/ScenarioEditor.tsx b/web-editor/src/components/ScenarioEditor.tsx index 94c44c5..1868b4b 100644 --- a/web-editor/src/components/ScenarioEditor.tsx +++ b/web-editor/src/components/ScenarioEditor.tsx @@ -20,10 +20,12 @@ import { ScenarioList } from './ScenarioEditor/ScenarioList'; import { PropertiesPanel } from './ScenarioEditor/PropertiesPanel'; import { BottomPanel } from './ScenarioEditor/BottomPanel'; import { Header } from './ScenarioEditor/Header'; +import { ScenarioInfoPanel } from './ScenarioEditor/ScenarioInfoPanel'; import { useScenarioGraph, generateNodeId } from '../hooks/useScenarioGraph'; import { useScenarioRunner } from '../hooks/useScenarioRunner'; import { useScenarioFile } from '../hooks/useScenarioFile'; +import { convertYamlToGraph } from '../utils/scenarioConversion'; const nodeTypes: NodeTypes = { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -40,10 +42,29 @@ const updateParameterInList = (params: OperationParam[], paramName: string, valu // Memoize child components to prevent unnecessary re-renders const MemoizedToolbox = React.memo(Toolbox); const MemoizedPropertiesPanel = React.memo(PropertiesPanel); +const MemoizedScenarioInfoPanel = React.memo(ScenarioInfoPanel); const MemoizedBottomPanel = React.memo(BottomPanel); const MemoizedHeader = React.memo(Header); const ScenarioEditorContent = () => { + // Synchronously read autosave state for lazy initialization + const [initialState] = useState(() => { + if (typeof window === 'undefined') return { nodes: [], edges: [], desc: "", isDirty: false, isRestored: false }; + + const savedIsDirty = sessionStorage.getItem('editor-is-dirty') === 'true'; + if (savedIsDirty) { + try { + const nodes = JSON.parse(sessionStorage.getItem('editor-autosave-nodes') || '[]'); + const edges = JSON.parse(sessionStorage.getItem('editor-autosave-edges') || '[]'); + const desc = sessionStorage.getItem('editor-autosave-description') || ""; + return { nodes, edges, desc, isDirty: true, isRestored: true }; + } catch (e) { + console.error("Failed to parse autosave data", e); + } + } + return { nodes: [], edges: [], desc: "", isDirty: false, isRestored: false }; + }); + const reactFlowWrapper = useRef(null); const [theme, setTheme] = useState<'light' | 'dark'>(() => { if (typeof window !== 'undefined') { @@ -56,6 +77,19 @@ const ScenarioEditorContent = () => { const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [operations, setOperations] = useState([]); const [isConnected, setIsConnected] = useState(false); + const [currentScenarioName, setCurrentScenarioName] = useState(() => { + if (typeof window !== 'undefined') { + return sessionStorage.getItem('currentScenarioName'); + } + return null; + }); + const [currentScenarioDescription, setCurrentScenarioDescription] = useState(initialState.desc); + const [scenarioListRefreshKey, setScenarioListRefreshKey] = useState(0); + const [isDirty, setIsDirty] = useState(initialState.isDirty); + + const incrementScenarioListRefreshKey = useCallback(() => { + setScenarioListRefreshKey(prev => prev + 1); + }, []); useEffect(() => { const checkHealth = async () => { @@ -100,7 +134,7 @@ const ScenarioEditorContent = () => { reactFlowInstance, onGraphDragStart, onGraphDragStop - } = useScenarioGraph(); + } = useScenarioGraph(initialState.nodes, initialState.edges); // Refs to keep track of latest nodes/edges without triggering re-renders in callbacks const nodesRef = useRef(nodes); @@ -112,18 +146,83 @@ const ScenarioEditorContent = () => { }, [nodes, edges]); const { isRunning, runScenario } = useScenarioRunner(); - const { handleSaveToServer } = useScenarioFile( + const { handleSaveToServer, handleSaveAs } = useScenarioFile( nodes, edges, - operations + operations, + currentScenarioName, + setCurrentScenarioName, + currentScenarioDescription, + incrementScenarioListRefreshKey, + () => setIsDirty(false) ); const loadScenarioFromYaml = useCallback((newNodes: Node[], newEdges: Edge[]) => { setNodes(newNodes); setEdges(newEdges); + setIsDirty(false); setTimeout(() => reactFlowInstance?.fitView({ padding: 0.2, duration: 400 }), 100); }, [setNodes, setEdges, reactFlowInstance]); + useEffect(() => { + // Also load description from the ScenarioList load if possible, + // but for now the list only loads scenarios by name. + // We might want to clear description if we can't load it? + }, [loadScenarioFromYaml]); + + useEffect(() => { + if (currentScenarioName) { + sessionStorage.setItem('currentScenarioName', currentScenarioName); + } else { + sessionStorage.removeItem('currentScenarioName'); + } + }, [currentScenarioName]); + + // Autosave dirty state + useEffect(() => { + const saveState = () => { + if (isDirty) { + sessionStorage.setItem('editor-is-dirty', 'true'); + sessionStorage.setItem('editor-autosave-nodes', JSON.stringify(nodes)); + sessionStorage.setItem('editor-autosave-edges', JSON.stringify(edges)); + sessionStorage.setItem('editor-autosave-description', currentScenarioDescription); + } else { + sessionStorage.removeItem('editor-is-dirty'); + sessionStorage.removeItem('editor-autosave-nodes'); + sessionStorage.removeItem('editor-autosave-edges'); + sessionStorage.removeItem('editor-autosave-description'); + } + }; + + // Debounce save to avoid performance impact + const timeoutId = setTimeout(saveState, 500); + return () => clearTimeout(timeoutId); + }, [isDirty, nodes, edges, currentScenarioDescription]); + + // Load saved scenario on mount (refresh) if available + const [isRestored, setIsRestored] = useState(initialState.isRestored); + + // Fallback to server load on mount if not restored from autosave + useEffect(() => { + if (isRestored) return; + + // Fallback to server load if not dirty or autosave failed + if (operations.length > 0 && currentScenarioName && nodes.length === 0) { + fetch(`/api/scenarios/${currentScenarioName}`) + .then(res => res.json()) + .then(scenario => { + const { nodes: newNodes, edges: newEdges } = convertYamlToGraph(scenario, operations); + loadScenarioFromYaml(newNodes, newEdges); + setCurrentScenarioDescription(scenario.description || ""); + setIsRestored(true); + }) + .catch(err => { + console.error('Failed to restore scenario:', err); + setIsRestored(true); + }); + } + }, [operations, currentScenarioName, nodes.length, loadScenarioFromYaml, isRestored]); + useEffect(() => { document.documentElement.dataset.theme = theme; sessionStorage.setItem('editor-theme', theme); @@ -176,16 +275,20 @@ const ScenarioEditorContent = () => { }, []); const handleDrop = useCallback((event: React.DragEvent) => { + setIsDirty(true); onDrop(event, operations); }, [onDrop, operations]); - const handleClear = useCallback(() => { - if (globalThis.confirm('Are you sure you want to clear the current scenario? All unsaved changes will be lost.')) { + const handleNew = useCallback(() => { + if (!isDirty || globalThis.confirm('Create new scenario? Any unsaved changes will be lost.')) { clearGraph(); setSelectedNode(null); setSelectedEdgeId(null); + setCurrentScenarioName(null); + setCurrentScenarioDescription(""); + setIsDirty(false); } - }, [clearGraph]); + }, [clearGraph, isDirty]); const updateNodesWithResults = useCallback((currentNodes: Node[], results: { id: string; status: 'success' | 'failure' | 'error'; result?: unknown }[]) => { return currentNodes.map(node => { @@ -296,6 +399,7 @@ const ScenarioEditorContent = () => { }, [runScenario, setNodes, updateNodesWithResults, reactFlowInstance]); const updateNodeParameter = useCallback((nodeId: string, paramName: string, value: unknown) => { + setIsDirty(true); setNodes((nds) => { // Special handling for Join Background Task -> task_id // If we are updating task_id, we might need to create/update an edge @@ -365,6 +469,7 @@ const ScenarioEditorContent = () => { }, [setNodes, setEdges]); const updateNodeRunInBackground = useCallback((nodeId: string, value: boolean) => { + setIsDirty(true); const joinOp = operations.find(op => op.name === "Join Background Task"); const shouldCreateJoinNode = value && !!joinOp; const newNodeId = shouldCreateJoinNode && joinOp ? generateNodeId(nodes, joinOp.name) : null; @@ -432,6 +537,29 @@ const ScenarioEditorContent = () => { }); }, [setNodes, setEdges, operations, nodes]); + const updateNodeStepId = useCallback((nodeId: string, stepId: string) => { + setIsDirty(true); + setNodes((nds) => { + return nds.map((node) => { + if (node.id === nodeId) { + return { + ...node, + data: { ...node.data, stepId: stepId }, + }; + } + return node; + }); + }); + + setSelectedNode((prev) => { + if (!prev || prev.id !== nodeId) return prev; + return { + ...prev, + data: { ...prev.data, stepId: stepId }, + }; + }); + }, [setNodes]); + const getConnectedSourceNodes = useCallback((targetNodeId: string) => { const sourceNodeIds = new Set(edges .filter(edge => edge.target === targetNodeId) @@ -450,15 +578,23 @@ const ScenarioEditorContent = () => { theme={theme} toggleTheme={toggleTheme} onLayout={onLayout} - onClear={handleClear} + onNew={handleNew} onSave={handleSaveToServer} + onSaveAs={handleSaveAs} onRun={handleRun} isRunning={isRunning} />
- +
@@ -467,9 +603,24 @@ const ScenarioEditorContent = () => { nodes={nodes} edges={edges} nodeTypes={nodeTypes} - onNodesChange={onNodesChange} - onEdgesChange={onEdgesChange} - onConnect={onConnect} + onNodesChange={(changes) => { + // Only mark dirty if relevant changes occur (add, remove, reset, position drag end?) + // simple 'position' change fires on every mouse move, we might want to be less aggressive or just accept it. + // But selection changes also fire onNodesChange. + const isRelevantChange = changes.some(c => c.type !== 'select' && c.type !== 'dimensions'); + if (isRelevantChange) setIsDirty(true); + onNodesChange(changes); + }} + onEdgesChange={(changes) => { + // Selection changes fire onEdgesChange too + const isRelevantChange = changes.some(c => c.type !== 'select'); + if (isRelevantChange) setIsDirty(true); + onEdgesChange(changes); + }} + onConnect={(connection) => { + setIsDirty(true); + onConnect(connection); + }} onInit={setReactFlowInstance} onDrop={handleDrop} onDragOver={onDragOver} @@ -486,6 +637,11 @@ const ScenarioEditorContent = () => {
+ {currentScenarioName && ( +
+ {currentScenarioName}{isDirty && *} +
+ )}
{isConnected ? 'Connected' : 'Disconnected'} @@ -503,7 +659,7 @@ const ScenarioEditorContent = () => { )}
- {selectedNode && ( + {selectedNode ? ( { onClose={() => setSelectedNode(null)} onUpdateParameter={updateNodeParameter} onUpdateRunInBackground={updateNodeRunInBackground} + onUpdateStepId={updateNodeStepId} + /> + ) : ( + { + setCurrentScenarioName(name); + setIsDirty(true); + }} + onUpdateDescription={(desc) => { + setCurrentScenarioDescription(desc); + setIsDirty(true); + }} /> )}
diff --git a/web-editor/src/components/ScenarioEditor/CustomNode.tsx b/web-editor/src/components/ScenarioEditor/CustomNode.tsx index 01683ba..6ff44e2 100644 --- a/web-editor/src/components/ScenarioEditor/CustomNode.tsx +++ b/web-editor/src/components/ScenarioEditor/CustomNode.tsx @@ -22,6 +22,10 @@ export const CustomNode = ({ data, selected }: NodeProps>) => {
{ + e.stopPropagation(); + data.onShowResult?.(data.result); + }} >
@@ -30,6 +34,10 @@ export const CustomNode = ({ data, selected }: NodeProps>) => {
{ + e.stopPropagation(); + data.onShowResult?.(data.result); + }} >
@@ -38,6 +46,10 @@ export const CustomNode = ({ data, selected }: NodeProps>) => {
{ + e.stopPropagation(); + data.onShowResult?.(data.result); + }} >
diff --git a/web-editor/src/components/ScenarioEditor/DocumentationModal.tsx b/web-editor/src/components/ScenarioEditor/DocumentationModal.tsx new file mode 100644 index 0000000..5b4ab8d --- /dev/null +++ b/web-editor/src/components/ScenarioEditor/DocumentationModal.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { X } from 'lucide-react'; +import styles from '../../styles/DocumentationModal.module.css'; + +interface DocumentationModalProps { + scenarioName: string | null; + isOpen: boolean; + onClose: () => void; +} + +export const DocumentationModal = ({ scenarioName, isOpen, onClose }: DocumentationModalProps) => { + const [content, setContent] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isOpen || !scenarioName) return; + + // Use a timeout to avoid preventing cascading renders warning from synchronous setState + const timer = setTimeout(() => { + setLoading(true); + setError(null); + + fetch(`/api/scenarios/${scenarioName}/docs`) + .then(async (res) => { + if (!res.ok) { + if (res.status === 404) { + throw new Error('Documentation not found for this scenario.'); + } + throw new Error('Failed to load documentation.'); + } + return res.text(); + }) + .then(text => setContent(text)) + .catch(err => { + console.error(err); + setError(err.message); + setContent(''); + }) + .finally(() => setLoading(false)); + }, 0); + + return () => clearTimeout(timer); + }, [isOpen, scenarioName]); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> + + + {loading &&
Loading documentation...
} + + {error && ( +
+ {error} +
+ )} + + {!loading && !error && ( +
+ + {content} + +
+ )} +
+
+ ); +}; diff --git a/web-editor/src/components/ScenarioEditor/Header.tsx b/web-editor/src/components/ScenarioEditor/Header.tsx index 2be2a7b..7d7f745 100644 --- a/web-editor/src/components/ScenarioEditor/Header.tsx +++ b/web-editor/src/components/ScenarioEditor/Header.tsx @@ -1,4 +1,4 @@ -import { Activity, Moon, Sun, Layout, Trash2, Save, Play, Loader2 } from 'lucide-react'; +import { Activity, Moon, Sun, Layout, FilePlus, Save, Play, Loader2, Copy } from 'lucide-react'; import styles from '../../styles/Header.module.css'; import btnStyles from '../../styles/Button.module.css'; @@ -6,8 +6,9 @@ interface HeaderProps { theme: 'light' | 'dark'; toggleTheme: () => void; onLayout: () => void; - onClear: () => void; + onNew: () => void; onSave: () => void; + onSaveAs: () => void; onRun: () => void; isRunning: boolean; } @@ -16,8 +17,9 @@ export const Header = ({ theme, toggleTheme, onLayout, - onClear, + onNew, onSave, + onSaveAs, onRun, isRunning, }: HeaderProps) => { @@ -35,14 +37,18 @@ export const Header = ({ Auto Layout - +

{selectedNode.data.label}

-
- ID: {selectedNode.id} +
+
Node ID: {selectedNode.id}
+ +
+ + onUpdateStepId(selectedNode.id, e.target.value)} + placeholder={selectedNode.data.label} + /> +
+ Uniquely identify this step in the YAML. Explicitly set this if you have multiple steps with the same name. +
+
+
diff --git a/web-editor/src/components/ScenarioEditor/ScenarioInfoPanel.tsx b/web-editor/src/components/ScenarioEditor/ScenarioInfoPanel.tsx new file mode 100644 index 0000000..adb09f5 --- /dev/null +++ b/web-editor/src/components/ScenarioEditor/ScenarioInfoPanel.tsx @@ -0,0 +1,145 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { X, FileText, Info, BookOpen } from 'lucide-react'; +import layoutStyles from '../../styles/EditorLayout.module.css'; +import styles from '../../styles/SidebarPanel.module.css'; +import { DocumentationModal } from './DocumentationModal'; + +interface ScenarioInfoPanelProps { + name: string | null; + description: string; + onUpdateName: (name: string) => void; + onUpdateDescription: (description: string) => void; + onClose?: () => void; +} + +export const ScenarioInfoPanel = ({ name, description, onUpdateName, onUpdateDescription, onClose }: ScenarioInfoPanelProps) => { + const [width, setWidth] = useState(480); + const [isResizing, setIsResizing] = useState(false); + const [isDocsOpen, setIsDocsOpen] = useState(false); + + const startResizing = useCallback((mouseDownEvent: React.MouseEvent) => { + mouseDownEvent.preventDefault(); + setIsResizing(true); + }, []); + + const stopResizing = useCallback(() => { + setIsResizing(false); + }, []); + + const resize = useCallback( + (mouseMoveEvent: MouseEvent) => { + if (isResizing) { + const newWidth = window.innerWidth - mouseMoveEvent.clientX; + if (newWidth > 300 && newWidth < 800) { + setWidth(newWidth); + } + } + }, + [isResizing] + ); + + useEffect(() => { + window.addEventListener("mousemove", resize); + window.addEventListener("mouseup", stopResizing); + return () => { + window.removeEventListener("mousemove", resize); + window.removeEventListener("mouseup", stopResizing); + }; + }, [resize, stopResizing]); + + return ( +